diff --git a/COPY.md b/COPY.md index 9351d523..7cc5d660 100644 --- a/COPY.md +++ b/COPY.md @@ -84,6 +84,24 @@ The watch promotion says: `Watch a repo. Each month the digest lists what change The link to `/weekly/preview` says: `Preview the weekly`. The comparison table says: `Watch for changes, monthly digest`. +### Ranked boards + +Every ranked section says what orders it. ADR-0004 and ADR-0010 set the rules. + +| Surface | String | +| --- | --- | +| Trending header | `Ranked by how many separate devs talked about each one.` | +| Trending header, with star rows | `Ranked by how many separate devs talked about each one. GitHub stars rank the rest of the board.` | +| Trending star row label | `Ranked by GitHub stars` | +| Track talked heading | `{Noun} skills devs talked about this week`, or `this month` on the month board | +| Track talked line | The trending header string, unchanged | +| Track pinned heading | `Hand-picked {noun} skills` | +| Track stars heading | `More {noun} skills, ranked by GitHub stars`, or `{Noun} skills, ranked by GitHub stars` when no section comes before it | +| Track quiet line | `Devs talked about {n} of these skills this week. A list ranked by devs starts at 5.` | + +`{noun}` is the track's `noun` field in `clusters.ts`, such as `design` or `SEO`. +Never call a ranked section top, popular, hot, best, or a leaderboard. + ## Discord digest The weekly card uses `Trending skills this week` as its title and links to the trending page. diff --git a/GLOSSARY.md b/GLOSSARY.md index 4500aba5..efacd921 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -114,6 +114,8 @@ Collisions **Is:** a page of Skills for one kind of work, at `/skills/`. A person writes its label, its second-person line and its pinned Skills; a classifier category fills in the depth beneath them. +**Use for:** "hand-picked" names a track's pinned Skills in UI copy. "Pinned" stays in the code. + **Never:** cluster, category, outcome, topic, use case (in UI). `CLUSTERS` and `abstractness_category` are the internal names and stay in the code. **Collides with:** collection. A track is ours, permanent, and one per kind of work. A collection belongs to a curator, at `/@login/slug`, and any number can exist. diff --git a/VISION.md b/VISION.md index 7ad1c741..21354776 100644 --- a/VISION.md +++ b/VISION.md @@ -156,7 +156,7 @@ The interface recedes; the content speaks. Warm stone, rare rose, mono chrome, b 1. **skilld-maintained Skills stay separate from registry provenance.** The skilld project may publish visible skilld-maintained Skills for generation, review, search, and install guidance. They name the project as author and remain readable source assets. Generated output never enters the registry as platform-authored maintainer content. Registry Skills retain their Repository owner and exact GitHub provenance. Machine-generated search metadata stays labeled and stays metadata. 2. **We are not AI-powered.** The product category is a curated registry. "AI" never modifies skilld itself in copy, positioning, or feature naming. 3. **No pay-to-play, ever.** No sponsored listings, promoted skills, paid placement, or ads. This is the money posture made permanent: curation that can be bought is not curation. -4. **Popularity is never the quality signal.** Unverifiable third-party counts (installs scraped from skills.sh) never order a surface, never feed trust tiers, and never appear as our numbers; we cannot vouch for a figure we cannot source. GitHub stars are the one sanctioned popularity signal: verifiable at the canonical source, synced for free, honest to display. Stars may order browse surfaces (catalog sort, the human-reviewed leaderboard) and may feed trust as a supporting input (a well-starred repository has earned community evidence), but stars alone never grant the top trust tier: admission and the highest trust signals stay human judgments, the homepage's organizing principle stays curation and freshness, and no star count ever substitutes for review. **Likes carve-out (ADR-0003, 2026-08-10):** Skill likes are first-party (a row we wrote, keyed to a GitHub-authenticated user, so we can state exactly what the number counts) and Skill-grained (stars measure a repository and lend the same count to all twenty Skills inside it). Like counts may be displayed and may back an opt-in `?sort=likes`. They never feed trust, never affect admission, never become a default order, and never order a trending surface: a board ranked by our own users' taste is a mirror. The carve-out covers display and that one sort key; every other clause above holds unchanged. **Trending carve-out (ADR-0004, 2026-08-19):** `/skills/trending` and the weekly email are ordered by social mentions we record ourselves from the public X and Bluesky APIs, and by star surges on repositories holding exactly one Skill. Both pass the test that admitted stars and rejected installs: we can state what the number counts, and a reader can check it, because every socially attributed row ships the post that named the Skill with its author and link. Each row states which route named it; a heading may never claim people named something when a star count did. Neither signal feeds trust or admission, and installs stay barred. +4. **Popularity is never the quality signal.** Unverifiable third-party counts (installs scraped from skills.sh) never order a surface, never feed trust tiers, and never appear as our numbers; we cannot vouch for a figure we cannot source. GitHub stars are the one sanctioned popularity signal: verifiable at the canonical source, synced for free, honest to display. Stars may order browse surfaces (catalog sort, the human-reviewed leaderboard) and may feed trust as a supporting input (a well-starred repository has earned community evidence), but stars alone never grant the top trust tier: admission and the highest trust signals stay human judgments, the homepage's organizing principle stays curation and freshness, and no star count ever substitutes for review. **Likes carve-out (ADR-0003, 2026-08-10):** Skill likes are first-party (a row we wrote, keyed to a GitHub-authenticated user, so we can state exactly what the number counts) and Skill-grained (stars measure a repository and lend the same count to all twenty Skills inside it). Like counts may be displayed and may back an opt-in `?sort=likes`. They never feed trust, never affect admission, never become a default order, and never order a trending surface: a board ranked by our own users' taste is a mirror. The carve-out covers display and that one sort key; every other clause above holds unchanged. **Trending carve-out (ADR-0004, 2026-08-19):** `/skills/trending` and the weekly email are ordered by social mentions we record ourselves from the public X and Bluesky APIs, and by star surges on repositories holding exactly one Skill. Both pass the test that admitted stars and rejected installs: we can state what the number counts, and a reader can check it, because every socially attributed row ships the post that named the Skill with its author and link. Each row states which route named it; a heading may never claim people named something when a star count did. Neither signal feeds trust or admission, and installs stay barred. **Track carve-out (ADR-0010, 2026-10-06):** a track page may lead with the Skills devs talked about, by posts alone and under the same rules, once at least five qualify; every other section names its own order. 5. **We are not the npm of skills.** No publish flow and no namespace ownership. GitHub is the registry of record. Artifact delivery may cache immutable signed bytes for installation. It never becomes the source of truth. Protocol work serves the skilld CLI and requires a dated ADR. 6. **We do not optimize engagement.** Sessions per week is not a goal. Time-to-install and digest usefulness are. No streaks, no notification escalation, no re-engagement campaigns beyond the digest the user asked for. 7. **We are not an enterprise product.** No private registries, SSO, seats, or compliance surface. Private GitHub Artifact delivery is transient and account scoped. The free posture cannot carry enterprise support obligations. diff --git a/docs/adr/0010-social-mentions-on-track-pages.md b/docs/adr/0010-social-mentions-on-track-pages.md new file mode 100644 index 00000000..f0b9cc81 --- /dev/null +++ b/docs/adr/0010-social-mentions-on-track-pages.md @@ -0,0 +1,68 @@ +# ADR-0010: Social mentions on track pages + +**Status:** Accepted +**Date:** 2026-10-06 + +## Context + +ADR-0004 admitted social mentions as an ordering signal on two surfaces only: +`/skills/trending` and the weekly email. Its constraints keep both signals off +the default browse surfaces. + +A track page (`/skills/`{lang="html"}) lists the Skills for one kind of work. Until +now it led with three pinned Skills, then a card grid in a fixed order: pinned +Skills, then classified Skills, then [GitHub](https://github.com) stars. Nothing on the page said +what ordered the grid. Harlan asked for track pages to reuse the trending +board, so `/skills/design` shows the design Skills devs talk about. + +The data supports this for some tracks only. Measured in production on +2026-10-06, the Skills with at least one qualifying post in the last seven +days numbered, per track: planning 7, design 6, context-engineering 5, +diagrams 3, coding 2, seven tracks 1, and two tracks 0. Over 30 days the count +reached 5 or more on six tracks. About three quarters of the Skills devs post +about have no classifier category yet, so they cannot belong to a track. + +## Decision + +A track page may order a section of its board by social mentions, under the +same test ADR-0004 applies: we can state what the number counts, and a reader +can check it at the source. + +- **One section, headed with its claim.** The section heading names the track + and the window, such as "Design skills devs talked about this week". The line + under it states the ranking rule. The score is the trending board's, + narrowed to the track's members. The board's demotion of the most-starred + repositories does not apply: it can put a Skill one dev posted about above + one seven devs posted about, which the stated rule forbids. +- **Posts only.** Star surges never enter the section. The heading says devs + talked about each row, and a surge is not a dev talking. +- **Every row ships its posts.** A socially ranked row shows the post that put + it there, with its author and link, as on `/skills/trending`. +- **A minimum before the section leads.** The section appears only when at + least five Skills in the track qualify (`MIN_TALKED_SKILLS`). Below that, the + Skills stay in the lists below and draw no posts, and a line states how many + of them devs talked about. +- **Every other section names its order.** Pinned Skills sit under a + hand-picked heading in the order a person set. The rest sit under a heading + that ends "ranked by GitHub stars". No section borrows the talked section's claim. + +## Constraints that still hold + +- Neither signal feeds trust tiers or admission. Track membership stays the + classifier category and the pins a person writes. +- Installs remain barred. Likes remain barred from ordering a ranked board. +- The URL, ``{lang="html"}, meta description, and indexability rule of each track + page do not change. A `?range=month` board canonicalises to the track URL. +- The homepage's organizing principle stays curation and freshness. This ADR + covers track pages only. + +## Consequences + +VISION anti-scope 4 gains a pointer to this ADR beside the ADR-0004 carve-out. + +The talked section depends on the classifier. A Skill without a category +cannot appear in a track, however much devs post about it. Classifying newer +Skills faster widens the section; nothing in this ADR does that. + +If a track section ever ranks by mentions without its posts, or a heading +claims devs talked about rows they did not, this ADR no longer covers it. diff --git a/layers/marketing/app/components/BoardRangeSwitch.vue b/layers/marketing/app/components/BoardRangeSwitch.vue new file mode 100644 index 00000000..17687ec6 --- /dev/null +++ b/layers/marketing/app/components/BoardRangeSwitch.vue @@ -0,0 +1,151 @@ +<script setup lang="ts"> +/** + * The board's time range, as real links. + * + * Not a JS toggle. Each range is its own document, so + * a crawler has to be able to follow one. Rendered above every board state so + * a reader who lands on an empty range can switch away from it. A column in + * the sidebar on wide screens, a compact row above the board on narrow ones. + */ +const { options, current } = defineProps<{ + /** `label` is a short verb-free noun; `hint` says what the range covers, on wide screens. */ + options: readonly { id: string, label: string, hint: string, path: string }[] + current: string +}>() + +const headingId = useId() +</script> + +<template> + <h2 :id="headingId" class="range-heading"> + Time range + </h2> + <nav class="range-switcher" :aria-labelledby="headingId"> + <NuxtLink + v-for="option in options" + :key="option.id" + :to="option.path" + class="range-link" + :class="{ 'range-link--current': option.id === current }" + :aria-current="option.id === current ? 'page' : undefined" + > + <span>{{ option.label }}</span> + <span class="range-link__hint">{{ option.hint }}</span> + </NuxtLink> + </nav> +</template> + +<style scoped> +/* Narrow screens show the range row alone; the heading names it for assistive tech. */ +.range-heading { + position: absolute; + inline-size: 1px; + block-size: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* + * One control with a position per range, rather than separate buttons. Mono + * and border-driven, like every other control on the site; the current range + * is marked by a raised surface, so nothing moves and no accent is spent on + * navigation. + */ +.range-switcher { + display: inline-flex; + gap: 0.25rem; + padding: 0.25rem; + border: 1px solid var(--ui-border); + border-radius: calc(var(--ui-radius) + 0.25rem); +} + +.range-link { + display: inline-flex; + align-items: center; + min-height: 2.75rem; + padding-inline: 1rem; + border-radius: var(--ui-radius); + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--ui-text-muted); + transition: color 200ms, background-color 200ms; +} + +@media (hover: hover) { + .range-link:hover { + color: var(--ui-text); + } +} + +.range-link--current { + background: var(--ui-bg-elevated); + box-shadow: inset 0 0 0 1px var(--ui-border); + color: var(--ui-text-highlighted); +} + +.range-link__hint { + display: none; +} + +/* Wide screens: a list with the window each range covers. */ +@media (min-width: 64rem) { + .range-heading { + position: static; + display: flex; + align-items: center; + inline-size: auto; + block-size: auto; + min-block-size: 1.75rem; + margin-block-end: 0.5rem; + overflow: visible; + clip-path: none; + font-size: 0.875rem; + font-weight: 600; + line-height: 1.25rem; + color: var(--ui-text); + } + + .range-switcher { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0; + border: 0; + border-radius: 0; + } + + /* Set like the maintainer rows in the /skills sidebar: the name, then a mono line. */ + .range-link { + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-height: 2.75rem; + padding: 0.375rem 0.5rem; + font-family: inherit; + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.25rem; + color: var(--ui-text-toned); + } + + .range-link--current { + box-shadow: none; + } + + @media (hover: hover) { + .range-link:hover { + background: var(--ui-bg-elevated); + } + } + + .range-link__hint { + display: block; + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 400; + line-height: 1rem; + color: var(--ui-text-muted); + } +} +</style> diff --git a/layers/marketing/app/components/BoardRankedList.vue b/layers/marketing/app/components/BoardRankedList.vue new file mode 100644 index 00000000..9d53a503 --- /dev/null +++ b/layers/marketing/app/components/BoardRankedList.vue @@ -0,0 +1,102 @@ +<script setup lang="ts"> +import type { TrendingBoardRow } from '#shared/trending-range' +import TrendingBoardItem from './TrendingBoardItem.vue' +import TrendingWeeklyCta from './TrendingWeeklyCta.vue' + +/** + * One ranked list of board rows, with the narrow screen's weekly invitation. + * + * Wide screens carry the invitation in the sidebar. Narrow ones have no + * sidebar, and above the board it pushed the first Skill below the fold, so + * it waits until a reader has seen the head of the list. A list no longer + * than its head carries it at the end instead. A page renders it in one list + * only, so it stays the page's one acquisition CTA. + */ +const { rows, start = 1, showWeeklyCta = false, ctaPending = false, surface } = defineProps<{ + rows: readonly TrendingBoardRow[] + /** One-based rank of the first row. */ + start?: number + showWeeklyCta?: boolean + /** The session has not loaded, so the invitation holds its space unseen. */ + ctaPending?: boolean + /** Analytics surface for each row's run chip, such as `trending-row`. */ + surface: string +}>() + +const emit = defineEmits<{ + /** The owner's avatar failed, which means the GitHub account is gone. */ + avatarError: [owner: string] +}>() + +/** Rows above the invitation on narrow screens. */ +const HEAD_ROWS = 5 + +interface ListChunk { + /** One-based rank of the chunk's first row, for the `<ol start>`. */ + start: number + rows: readonly TrendingBoardRow[] +} + +const chunks = computed<ListChunk[]>(() => [ + { start, rows: rows.slice(0, HEAD_ROWS) }, + { start: start + HEAD_ROWS, rows: rows.slice(HEAD_ROWS) }, +].filter(chunk => chunk.rows.length > 0)) +</script> + +<template> + <template v-for="(chunk, chunkIndex) in chunks" :key="chunk.start"> + <div + v-if="chunkIndex === 1 && showWeeklyCta" + class="board-cta--inline" + :class="{ invisible: ctaPending }" + > + <TrendingWeeklyCta /> + </div> + <ol class="board-list editorial-ledger list-none p-0" :start="chunk.start"> + <li v-for="(row, offset) in chunk.rows" :key="row.key"> + <TrendingBoardItem + :row="row" + :rank="chunk.start + offset" + :surface="surface" + @avatar-error="owner => emit('avatarError', owner)" + /> + </li> + </ol> + </template> + <div + v-if="chunks.length === 1 && showWeeklyCta" + class="board-cta--inline" + :class="{ invisible: ctaPending }" + > + <TrendingWeeklyCta /> + </div> +</template> + +<style scoped> +.board-list { + margin: 0; +} + +.board-cta--inline { + margin-block: 1.5rem; +} + +/* + * The two halves of one list. Where nothing shows between them, the second + * drops its top rule rather than doubling the first one's bottom: always when + * no invitation renders, and on wide screens, where it moves to the sidebar. + */ +.board-list + .board-list { + border-top: 0; +} + +@media (min-width: 64rem) { + .board-cta--inline { + display: none; + } + + .board-list ~ .board-list { + border-top: 0; + } +} +</style> diff --git a/layers/marketing/app/components/BoardShell.vue b/layers/marketing/app/components/BoardShell.vue new file mode 100644 index 00000000..cdcded59 --- /dev/null +++ b/layers/marketing/app/components/BoardShell.vue @@ -0,0 +1,136 @@ +<script setup lang="ts"> +import TrendingWeeklyCta from './TrendingWeeklyCta.vue' + +/** + * The page shell every ranked board shares: `/skills/trending` and each track. + * + * The same shell as /skills: the heading lines up with the results column, a + * sticky sidebar sits left of the board, and nothing sits right of it, so the + * board takes every column the sidebar leaves. + * + * The rail's foot holds the page's one acquisition CTA, the weekly + * invitation, and the install chip under it. The board places the narrow + * screen's copy of the invitation itself; see `BoardRankedList`. + */ +const { headingId, surface, showWeeklyCta, ctaPending } = defineProps<{ + /** The page's `<h1>` id, which names the header and the board. */ + headingId: string + /** Analytics prefix for the install chip, such as `trending`. */ + surface: string + /** False for a reader already receiving the weekly, and for an empty or failed board. */ + showWeeklyCta: boolean + /** The session has not loaded, so the invitation holds its space unseen. */ + ctaPending: boolean +}>() +</script> + +<template> + <div class="overflow-clip"> + <div class="w-full px-4 pb-10 pt-10 sm:px-6 lg:px-8 xl:px-10"> + <header :aria-labelledby="headingId" class="mx-auto mb-7 max-w-7xl lg:ml-56 lg:mr-0"> + <slot name="header" /> + <!-- Narrow screens have no sidebar, so the install sits under the heading. --> + <div class="mt-5 lg:hidden"> + <div class="board-teach"> + <p class="font-mono text-xs text-muted"> + Teach your agent skilld + </p> + <SkilldInstallChip :surface="`${surface}-header`" /> + </div> + </div> + </header> + + <div class="board-layout relative grid items-start gap-6 lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-8"> + <div class="board-sidebar"> + <slot name="sidebar" /> + + <!-- + The server renders this page signed out for every visitor, so the + invitation keeps its space but stays invisible until the browser + knows who is looking; a weekly reader never sees it first. + `invisible` already hides it from assistive tech and the tab order, + so it carries no `aria-hidden`, which HTML validation rejects over + a link. + --> + <div class="board-sidebar__foot"> + <div v-if="showWeeklyCta" :class="{ invisible: ctaPending }"> + <TrendingWeeklyCta variant="rail" /> + </div> + <!-- The weekly button is the rail's one solid rose element, so the install inks its dot while it shows. --> + <div class="board-teach"> + <p class="font-mono text-xs text-muted"> + Teach your agent skilld + </p> + <SkilldInstallChip :surface="`${surface}-sidebar`" :quiet="showWeeklyCta" /> + </div> + </div> + + <!-- + Below the invitation, so a long list here never pushes the page's + one acquisition CTA out of the rail's first screen. + --> + <slot name="sidebar-end" /> + </div> + + <section class="min-w-0 lg:pt-6" :aria-labelledby="headingId"> + <slot /> + </section> + </div> + </div> + </div> +</template> + +<style scoped> +/* The full-bleed rule above the sidebar and the board, as on /skills. */ +.board-layout::before { + position: absolute; + inset-block-start: 0; + inset-inline-start: 50%; + inline-size: 100vw; + transform: translateX(-50%); + border-block-start: 1px solid var(--ui-border); + content: ''; + pointer-events: none; +} + +.board-sidebar { + min-inline-size: 0; + padding-top: 1.5rem; +} + +.board-sidebar__foot { + display: none; +} + +.board-teach { + display: grid; + gap: 0.5rem; + min-inline-size: 0; +} + +/* + * Wide screens: the sidebar column from /skills, sticky under the site header. + * The invitation and the install sit under the sidebar's own navigation, + * where the board's right edge used to be spent on them. + */ +@media (min-width: 64rem) { + .board-sidebar { + position: sticky; + inset-block-start: 4rem; + display: flex; + flex-direction: column; + block-size: calc(100dvh - 4rem); + overflow-y: auto; + padding-block-end: 1.5rem; + padding-inline-end: 1.5rem; + border-inline-end: 1px solid var(--ui-border); + } + + .board-sidebar__foot { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1.5rem; + margin-block-start: 2rem; + } +} +</style> diff --git a/layers/marketing/app/components/TrendingBoardItem.vue b/layers/marketing/app/components/TrendingBoardItem.vue index f18bbc39..84ea9d1e 100644 --- a/layers/marketing/app/components/TrendingBoardItem.vue +++ b/layers/marketing/app/components/TrendingBoardItem.vue @@ -12,10 +12,12 @@ import TrendingStarSpark from './TrendingStarSpark.vue' * Skill carries the weight, larger and first, and the posts sit beside it as * testimony, one card at a time. */ -const { row, rank } = defineProps<{ +const { row, rank, surface = 'trending-row' } = defineProps<{ row: TrendingBoardRow /** One-based position on the board. */ rank: number + /** Analytics surface for the run chip. */ + surface?: string }>() const emit = defineEmits<{ @@ -40,8 +42,15 @@ const mentions = computed(() => { const days = row.reason._tag === 'posts' ? row.reason.mentionsByDay : null return days?.some(count => count > 0) ? days : null }) -/** Whether the last slot of the metadata line has anything to say. */ -const hasSignal = computed(() => !isSocial.value || mentions.value !== null) +/** + * Whether the last slot of the metadata line has anything to say. A track + * member says nothing there: its section heading already states the order. + */ +const hasSignal = computed(() => { + if (row.reason._tag === 'member') + return false + return !isSocial.value || mentions.value !== null +}) </script> <template> @@ -91,12 +100,12 @@ const hasSignal = computed(() => !isSocial.value || mentions.value !== null) <!-- The two reasons with no post say why the row is here in words, since nothing beside them does. A surge has only its stars; filler has to - say it is filler, or a popular repository passes for a trending one. + say what ranked it, or a starred repository passes for a trending one. --> <span v-else-if="row.reason._tag === 'surge'" class="board-row__surge"> {{ `Star surge: +${row.reason.gain.toLocaleString()} stars in a day` }}<template v-if="row.reason.when">, {{ row.reason.when }}</template> </span> - <span v-else-if="row.reason._tag === 'filler'">Popular on GitHub</span> + <span v-else-if="row.reason._tag === 'filler'">Ranked by GitHub stars</span> </span> </p> <p v-if="row.description" class="board-row__description"> @@ -109,7 +118,7 @@ const hasSignal = computed(() => !isSocial.value || mentions.value !== null) :owner="row.skill.owner" :repo="row.skill.repo" :skill="row.skill.name" - surface="trending-row" + :surface="surface" variant="compact" /> </div> diff --git a/layers/marketing/app/pages/skills/[cluster].vue b/layers/marketing/app/pages/skills/[cluster].vue index 728b94a0..43e086aa 100644 --- a/layers/marketing/app/pages/skills/[cluster].vue +++ b/layers/marketing/app/pages/skills/[cluster].vue @@ -1,30 +1,39 @@ <script setup lang="ts"> +import type { ClusterCard } from '#layers/registry/server/api/clusters/index.get' +import type { TrackTalkedResponse } from '#layers/registry/server/presenters/track-talked' +import type { TrackMemberInput } from '#shared/track-board' +import { setResponseHeaders } from 'h3' import { comparisonLinkForTrack } from '#shared/comparison-navigation' -import { githubAvatarProxyUrl } from '#shared/image-proxy' +import { + MIN_TALKED_SKILLS, + resolveTrackRange, + TRACK_RANGES, + trackBoard, + trackRangeMeta, + trackRangePath, +} from '#shared/track-board' +import BoardRangeSwitch from '../../components/BoardRangeSwitch.vue' +import BoardRankedList from '../../components/BoardRankedList.vue' +import BoardShell from '../../components/BoardShell.vue' import { resolveClusterViewState } from '../../utils/cluster-view-state' -interface ClusterSkill { - owner: string - name: string - repo: string - displayName: string - description: string | null - stars: number +interface ClusterSkill extends TrackMemberInput { modifiedAt: number | null slug: string - registryPath: string } interface ClusterDetailResponse { cluster: { slug: string label: string + /** How the track reads before "skills" in a board heading. */ + noun: string icon: string userVoice: string /** Keyword-shaped <title>; the H1 keeps `label`. */ seoTitle: string seoDescription: string - /** Preamble inherited from the collection merged into this category. */ + /** Preamble inherited from the collection merged into this track. */ curatorNote: string | null } items: ClusterSkill[] @@ -33,28 +42,50 @@ interface ClusterDetailResponse { pages: number } +const { state: auth } = useAuth() +/** Someone already getting the weekly is never shown an invitation to get it. */ +const receivingWeekly = computed(() => auth.value._tag === 'signed-in' && auth.value.user.onboarded === true) + const route = useRoute() const clusterSlug = computed(() => route.params.cluster as string) const comparisonLink = computed(() => comparisonLinkForTrack(clusterSlug.value)) +/** + * Parsed once, at the boundary. An unrecognised `?range=` is the week board + * rather than an error. + */ +const range = computed(() => resolveTrackRange(route.query.range)) +const rangeOptions = computed(() => TRACK_RANGES.map(option => ({ + ...option, + path: trackRangePath(clusterSlug.value, option.id), +}))) // Blocking, not lazy. `useLazyFetch` does not hold SSR, so the server rendered -// this page with `data === null`: every category served the fallback title -// ("Outcome skills", now "Agent skills by category") and, once the thin-category -// guard landed, `noindex` as well. Only the client filled in the real title -// after hydration, which is precisely what a crawler never runs. This page -// exists to rank, so it has to be right in the server response. -// `await`, and that word is the whole fix. Without it `setup` stays synchronous, -// so Vue renders before the request settles and the result only reaches the -// payload: the server response had the data embedded but no <h1> and the -// fallback <title>, plus `noindex` once the thin-category guard landed. Only -// hydration repaired it, which is exactly what a crawler does not do. +// this page with `data === null`: every track served the fallback title and, +// once the thin-track guard landed, `noindex` as well. Only the client filled +// in the real title after hydration, which is exactly what a crawler never +// runs. This page exists to rank, so it has to be right in the server response. // -// Suspense holds the render while this resolves, so the server response now -// carries the real title and skills. The loading branch below is still reached -// on client-side navigation. -const { data, error, status, refresh } = await useFetch<ClusterDetailResponse>( - () => `/api/clusters/${clusterSlug.value}`, -) +// All three at once: none reads another. The talked list and the track list +// are secondary. If either fails, the members still render. +const [ + { data, error, status, refresh }, + { data: talked, error: talkedError }, + { data: tracks }, +] = await Promise.all([ + useFetch<ClusterDetailResponse>(() => `/api/clusters/${clusterSlug.value}`), + useFetch<TrackTalkedResponse>(() => `/api/clusters/${clusterSlug.value}/talked`, { + query: { range }, + }), + useFetch<{ items: ClusterCard[] }>('/api/clusters'), +]) + +// A board that lost its posts must not enter a shared cache, which would keep +// serving it without them. +if (import.meta.server && talkedError.value) { + const event = useRequestEvent() + if (event) + setResponseHeaders(event, { 'cloudflare-cdn-cache-control': 'no-store', 'cache-control': 'private, no-store' }) +} const clusterView = computed(() => resolveClusterViewState({ data: data.value, @@ -64,7 +95,7 @@ const clusterView = computed(() => resolveClusterViewState({ watch(clusterView, (view) => { if (view._tag === 'not-found') - showError(createError({ statusCode: 404, statusMessage: 'Unknown cluster' })) + showError(createError({ statusCode: 404, statusMessage: 'Unknown track' })) }, { immediate: true }) const clusterData = computed(() => @@ -72,27 +103,78 @@ const clusterData = computed(() => ) const skills = computed(() => clusterData.value?.items ?? []) const total = computed(() => clusterData.value?.total ?? 0) -const leadingSkills = computed(() => skills.value.slice(0, 3)) -const remainingSkills = computed(() => skills.value.slice(3)) -const visibleCount = ref(15) -const visibleSkills = computed(() => remainingSkills.value.slice(0, visibleCount.value)) -const hasMore = computed(() => visibleCount.value < remainingSkills.value.length) -const sourceCount = computed(() => - new Set(skills.value.map(skill => `${skill.owner}/${skill.repo}`)).size, -) -const maintainerOwners = computed(() => - [...new Set(skills.value.map(skill => skill.owner))].slice(0, 4), -) -function showMore() { - visibleCount.value += 15 +/** + * Owners whose avatar failed to load, which means the GitHub account is gone. + * Same rule as the trending board: a row we cannot put a face to has dead + * provenance, and it leaves the board. + */ +const missingAvatars = ref(new Set<string>()) +function onAvatarError(owner: string) { + missingAvatars.value = new Set(missingAvatars.value).add(owner) +} + +/** The talked payload's clock, so every post dates the same on server and client. */ +const clock = computed(() => talked.value?.computedAt ?? 0) + +const board = computed(() => trackBoard({ + noun: clusterData.value?.cluster.noun ?? 'track', + range: range.value, + talked: (talked.value?.skills ?? []).filter(skill => !missingAvatars.value.has(skill.owner)), + members: skills.value.filter(skill => !missingAvatars.value.has(skill.owner)), + clockSeconds: clock.value, +})) + +const period = computed(() => trackRangeMeta(range.value).period) +const hasTalkedSection = computed(() => board.value.sections[0]?._tag === 'talked') + +/** + * Why the board shows no posts, when devs did talk about a few of its Skills. + * Silence would read as nobody posting at all. + */ +const quietLine = computed(() => { + const count = board.value.talkedCount + if (hasTalkedSection.value || count === 0) + return null + return `Devs talked about ${count} of these skills ${period.value}. A list ranked by devs starts at ${MIN_TALKED_SKILLS}.` +}) + +/** The dates the talked section covers, stated so a stale board shows as stale. */ +const rangeWindow = computed(() => { + if (!clock.value) + return null + const end = new Date(clock.value * 1000) + const start = new Date(end.getTime() - trackRangeMeta(range.value).windowHours * 3_600_000) + const day = (d: Date) => d.getUTCDate() + const month = (d: Date) => d.toLocaleString('en', { month: 'short', timeZone: 'UTC' }) + const year = end.getUTCFullYear() + return start.getUTCMonth() === end.getUTCMonth() + ? `${day(start)}–${day(end)} ${month(end)} ${year}` + : `${day(start)} ${month(start)} – ${day(end)} ${month(end)} ${year}` +}) + +/** Star-ranked rows render in pages, so a long track does not ship every row up front. */ +const STARS_STEP = 15 +const visibleStars = ref(STARS_STEP) +function showMoreStars() { + visibleStars.value += STARS_STEP } +/** + * Every track with Skills behind it, for the sidebar. The track list is a + * navigation aid: if it fails to load, the board still renders without it. + */ +const allTracks = computed(() => tracks.value?.items ?? []) +const otherTracks = computed(() => allTracks.value.filter(track => track.slug !== clusterSlug.value)) + +const isEmpty = computed(() => board.value.sections.length === 0) +const showWeeklyCta = computed(() => !receivingWeekly.value && !isEmpty.value) + // The <title> is keyword-shaped ("Agent Skills for UI and Design") while the // H1 below keeps the editorial label. VISION principle 6 grants that carve-out // for search surfaces only; it never reaches product UI. The skill count is // appended rather than baked into seoDescription so the copy stays honest when -// a category is thin. +// a track is thin. // No ` · skilld` suffix here: a global titleTemplate already appends it, and // adding one produced "... · skilld · skilld". const title = computed(() => @@ -107,7 +189,7 @@ const description = computed( ) // The 2026-06 suppression came from publishing pages before they had anything -// on them. A category admitted ahead of its curation is exactly that shape, so +// on them. A track admitted ahead of its curation is exactly that shape, so // it stays out of the index until a curator has filled it. Under the threshold // the page still renders and still serves internal nav; it just does not ask // Google to rank an empty list. @@ -121,6 +203,8 @@ useSeoMeta({ robots: () => (total.value >= MIN_INDEXABLE_SKILLS ? 'index,follow' : 'noindex,follow'), }) +// Every range points at the bare track URL. The month board reorders the same +// members, and a second indexable copy of each track would split its ranking. useHead({ link: [{ rel: 'canonical', @@ -135,12 +219,12 @@ defineOgImage('Page.takumi', { </script> <template> - <div class="overflow-clip"> + <div> <section v-if="clusterView._tag === 'loading'" class="mx-auto max-w-5xl px-4 py-12 sm:px-6 md:py-16" aria-busy="true" - aria-label="Loading outcome skills" + aria-label="Loading track skills" > <div class="editorial-state"> <USkeleton class="h-4 w-28" /> @@ -156,14 +240,14 @@ defineOgImage('Page.takumi', { > <div class="editorial-state" role="alert"> <h1 id="cluster-error-heading" class="text-xl font-semibold"> - Could not load this outcome. + Couldn't load this track. </h1> <p class="mt-2 text-base text-muted"> - Check your connection and try this outcome again. + Check your connection and try again. </p> <div class="mt-4 flex flex-wrap gap-3"> <UButton - label="Retry outcome" + label="Retry" color="neutral" variant="outline" class="min-h-11" @@ -171,7 +255,7 @@ defineOgImage('Page.takumi', { /> <UButton to="/skills" - label="Browse outcomes" + label="Browse tracks" color="neutral" variant="ghost" class="min-h-11" @@ -180,174 +264,70 @@ defineOgImage('Page.takumi', { </div> </section> - <CompactPageHeader + <BoardShell v-else-if="clusterData" - :title="clusterData.cluster.label" - :description="clusterData.cluster.userVoice" heading-id="cluster-heading" + surface="track" + :show-weekly-cta="showWeeklyCta" + :cta-pending="auth._tag === 'pending'" > - <template #aside> - <dl class="flex flex-wrap gap-x-6 gap-y-3 md:justify-end"> - <div> - <dt class="data-label"> - Skills - </dt> - <dd class="mt-1 font-mono text-sm tabular-nums"> - {{ total }} - </dd> - </div> - <div> - <dt class="data-label"> - Source repos - </dt> - <dd class="mt-1 font-mono text-sm tabular-nums"> - {{ sourceCount }} - </dd> - </div> - <div> - <dt class="data-label"> - Maintainers - </dt> - <dd class="mt-1 flex -space-x-2"> - <img - v-for="owner in maintainerOwners" - :key="owner" - :src="githubAvatarProxyUrl(owner, 64)" - alt="" - width="32" - height="32" - class="size-8 rounded-full border-2 border-[var(--ui-bg)] bg-muted" - loading="lazy" - decoding="async" - > - </dd> - </div> - </dl> - </template> - - <div class="flex flex-wrap gap-3"> + <template #header> + <h1 id="cluster-heading" class="text-3xl font-semibold tracking-tight text-balance"> + {{ clusterData.cluster.label }} + </h1> + <p class="mt-2 text-sm text-muted"> + {{ clusterData.cluster.userVoice }} + </p> <UButton v-if="comparisonLink" :to="comparisonLink.to" :label="comparisonLink.label" color="neutral" variant="outline" - class="min-h-11" - /> - <UButton - to="/skills" - label="Browse tracks" - color="neutral" - variant="outline" - icon="i-lucide-arrow-left" - class="min-h-11" + class="mt-4 min-h-11" /> - </div> - </CompactPageHeader> + </template> - <section - v-if="clusterData && leadingSkills.length" - class="editorial-band border-b border-default bg-muted" - aria-labelledby="starting-sequence-heading" - > - <div - class="editorial-atmosphere" - data-palette="stone" - data-geometry="wash" - data-intensity="subtle" - aria-hidden="true" - /> - <div class="editorial-band__content mx-auto max-w-5xl px-4 py-12 sm:px-6 md:py-16"> - <div class="grid gap-8 lg:grid-cols-[minmax(0,0.7fr)_minmax(0,1.3fr)] lg:gap-12"> - <div> - <h2 id="starting-sequence-heading" class="cluster-section-title max-w-[13ch]"> - Three skills to inspect. - </h2> - <p v-if="clusterData.cluster.curatorNote" class="data-label mt-3"> - Curated by Harlan - </p> - <p class="mt-4 max-w-md text-base leading-relaxed text-muted text-pretty"> - {{ - clusterData.cluster.curatorNote - ?? 'Descriptions are brief. Open the repository for the full instructions.' - }} - </p> - </div> - - <ol class="editorial-ledger list-none p-0"> - <li v-for="(skill, index) in leadingSkills" :key="skill.slug"> - <NuxtLink - :to="skill.registryPath" - class="cluster-sequence-row group" - > - <span class="cluster-sequence-number">{{ String(index + 1).padStart(2, '0') }}</span> - <img - :src="githubAvatarProxyUrl(skill.owner, 80)" - alt="" - width="40" - height="40" - class="size-10 rounded-full border-2 border-[var(--ui-bg)] bg-muted outline outline-1 outline-[var(--ui-border)]" - loading="lazy" - decoding="async" - > - <span class="min-w-0"> - <span class="block font-mono text-base font-medium">/{{ skill.name }}</span> - <span class="mt-1 block text-sm text-muted">{{ skill.owner }}/{{ skill.repo }}</span> - <span v-if="skill.description" class="mt-2 line-clamp-2 text-base leading-relaxed text-muted text-pretty"> - {{ skill.description }} - </span> - </span> - <UIcon - name="i-lucide-arrow-up-right" - class="size-4 shrink-0 text-muted transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" - aria-hidden="true" - /> - </NuxtLink> - </li> - </ol> - </div> - </div> - </section> + <template #sidebar> + <BoardRangeSwitch :options="rangeOptions" :current="range" /> + </template> - <section - v-if="clusterData" - class="mx-auto max-w-5xl px-4 py-12 sm:px-6 md:py-16" - aria-labelledby="cluster-directory-heading" - > - <div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> - <div> - <h2 id="cluster-directory-heading" class="cluster-section-title"> - Browse the rest. - </h2> - <p class="mt-3 max-w-2xl text-base leading-relaxed text-muted"> - These all target the same kind of work. - </p> - </div> - <p class="data-label"> - Showing {{ visibleSkills.length + leadingSkills.length }} of {{ total }} - </p> - </div> + <!-- Wide screens only. Narrow ones list the other tracks under the board. --> + <template v-if="allTracks.length" #sidebar-end> + <h2 id="track-nav-heading" class="track-nav-heading"> + Tracks + </h2> + <nav class="track-nav" aria-labelledby="track-nav-heading"> + <NuxtLink + v-for="track in allTracks" + :key="track.slug" + :to="`/skills/${track.slug}`" + class="track-link" + :class="{ 'track-link--current': track.slug === clusterSlug }" + :aria-current="track.slug === clusterSlug ? 'page' : undefined" + > + <span>{{ track.label }}</span> + <span class="track-link__hint">{{ `${track.skillCount.toLocaleString()} ${track.skillCount === 1 ? 'skill' : 'skills'}` }}</span> + </NuxtLink> + </nav> + </template> - <ul - v-if="visibleSkills.length" - class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 list-none p-0" - > - <li v-for="skill in visibleSkills" :key="skill.slug"> - <SkillCard - :skill - variant="grid" - signal="stars" - show-owner-path - /> - </li> - </ul> + <p class="data-label mb-3 flex min-h-7 items-center"> + {{ `${total.toLocaleString()} ${total === 1 ? 'skill' : 'skills'}` }} + </p> + <p v-if="talkedError" class="mb-3 text-sm text-muted" role="status"> + Couldn't load the posts for this range. No list below is ranked by devs. + </p> + <p v-else-if="quietLine" class="mb-6 text-sm text-muted"> + {{ quietLine }} + </p> - <div v-else-if="!leadingSkills.length" class="editorial-state" role="status"> + <div v-if="isEmpty" class="editorial-state" role="status"> <p class="font-medium"> No skills here yet. </p> <p class="mt-1 text-base text-muted"> - Try another outcome. This one has no indexed skills yet. + Try another track. This one has no skills listed yet. </p> <UButton to="/skills" @@ -358,62 +338,143 @@ defineOgImage('Page.takumi', { /> </div> - <div v-if="hasMore" class="mt-8 flex justify-center"> - <UButton - label="Show 15 more" - color="neutral" - variant="outline" - trailing-icon="i-lucide-chevron-down" - class="min-h-11" - @click="showMore" - /> - </div> - </section> + <section + v-for="(section, index) in board.sections" + :key="section.id" + class="track-section" + :aria-labelledby="section.id" + > + <h2 :id="section.id" class="text-xl font-semibold tracking-tight text-balance"> + {{ section.heading }} + </h2> + <template v-if="section._tag === 'talked'"> + <p class="mt-1 text-sm text-muted"> + Ranked by how many separate devs talked about each one. + </p> + <p v-if="rangeWindow" class="data-label mt-1"> + {{ rangeWindow }} + </p> + </template> + <p + v-else-if="section._tag === 'picked' && clusterData.cluster.curatorNote" + class="mt-1 max-w-2xl text-sm leading-relaxed text-muted text-pretty" + > + {{ clusterData.cluster.curatorNote }} + </p> + + <div class="mt-4"> + <BoardRankedList + :rows="section._tag === 'stars' ? section.rows.slice(0, visibleStars) : section.rows" + surface="track-row" + :show-weekly-cta="index === 0 && showWeeklyCta" + :cta-pending="auth._tag === 'pending'" + @avatar-error="onAvatarError" + /> + </div> + + <div + v-if="section._tag === 'stars' && section.rows.length > visibleStars" + class="mt-6 flex justify-center" + > + <UButton + :label="`Show ${Math.min(STARS_STEP, section.rows.length - visibleStars)} more`" + color="neutral" + variant="outline" + trailing-icon="i-lucide-chevron-down" + class="min-h-11" + @click="showMoreStars" + /> + </div> + </section> + + <!-- The sidebar's track list, for narrow screens, which have no sidebar. --> + <section + v-if="otherTracks.length" + class="track-section lg:hidden" + aria-labelledby="track-list-heading" + > + <h2 id="track-list-heading" class="text-xl font-semibold tracking-tight"> + Other tracks + </h2> + <ul class="mt-4 flex list-none flex-wrap gap-2 p-0"> + <li v-for="track in otherTracks" :key="track.slug"> + <UButton + :to="`/skills/${track.slug}`" + :label="track.label" + color="neutral" + variant="outline" + size="sm" + class="min-h-11" + /> + </li> + </ul> + </section> + </BoardShell> </div> </template> <style scoped> -.cluster-section-title { - font-size: clamp(2.25rem, 1.85rem + 1.8vw, 3.5rem); - font-weight: 600; - letter-spacing: -0.04em; - line-height: 1.02; - text-wrap: balance; +.track-section + .track-section { + margin-block-start: 3rem; } -.cluster-sequence-row { - display: grid; - min-height: 8rem; - grid-template-columns: auto auto minmax(0, 1fr) auto; - align-items: start; - gap: 1rem; - padding-block: 1.25rem; - color: var(--ui-text); +/* Wide screens only: narrow screens list the tracks under the board. */ +.track-nav-heading, +.track-nav { + display: none; } -/* Printed in the dot grid and inked rose, the same numerals the homepage - uses for the taste test. One brand, whichever page you land on. */ -.cluster-sequence-number { - padding-top: 0.7rem; - font-family: var(--font-mono); - font-size: 1.125rem; - font-weight: 700; - line-height: 1; - letter-spacing: -0.02em; - font-variant-numeric: tabular-nums; - color: var(--ui-primary); - opacity: 0.85; - mask-image: radial-gradient(circle, #000 1.1px, transparent 1.4px); - mask-size: 3px 3px; -} +@media (min-width: 64rem) { + /* Set like the range heading above it, so the rail reads as one column. */ + .track-nav-heading { + display: flex; + align-items: center; + min-block-size: 1.75rem; + margin-block: 2rem 0.5rem; + font-size: 0.875rem; + font-weight: 600; + line-height: 1.25rem; + color: var(--ui-text); + } + + .track-nav { + display: flex; + flex-direction: column; + gap: 0.125rem; + } + + .track-link { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 2.75rem; + padding: 0.375rem 0.5rem; + border-radius: var(--ui-radius); + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.25rem; + color: var(--ui-text-toned); + transition: background-color 200ms; + } + + @media (hover: hover) { + .track-link:hover { + background: var(--ui-bg-elevated); + } + } -@media (max-width: 39.999rem) { - .cluster-sequence-row { - grid-template-columns: auto minmax(0, 1fr) auto; + /* Marked like the current range: a raised surface, no accent. */ + .track-link--current { + background: var(--ui-bg-elevated); + color: var(--ui-text-highlighted); } - .cluster-sequence-row > img { - display: none; + .track-link__hint { + font-family: var(--font-mono); + font-size: 0.6875rem; + font-weight: 400; + line-height: 1rem; + color: var(--ui-text-muted); } } </style> diff --git a/layers/marketing/app/pages/skills/trending.vue b/layers/marketing/app/pages/skills/trending.vue index f5c4d099..32f54843 100644 --- a/layers/marketing/app/pages/skills/trending.vue +++ b/layers/marketing/app/pages/skills/trending.vue @@ -1,12 +1,13 @@ <script setup lang="ts"> -import type { TrendingFeedResponse, TrendingPostFeedItem } from '~~/server/api/feed/trending.get' +import type { TrendingFeedResponse } from '~~/server/api/feed/trending.get' import type { AdmittedSkillsResponse } from '#layers/registry/server/api/skills/admitted.get' import type { SkillsLeaderboardResponse } from '#layers/registry/server/api/skills/leaderboard.get' -import type { TrendingBoardRow, TrendingPost } from '#shared/trending-range' +import type { TrendingBoardRow } from '#shared/trending-range' import { setResponseHeaders } from 'h3' import { githubAvatarProxyUrl } from '#shared/image-proxy' import { relativeDay } from '#shared/trending-post' import { + boardPost, isEvidenced, leaderboardBoardRows, MIN_INDEXABLE_ROWS, @@ -21,8 +22,9 @@ import { trendingRangeMeta, trendingRangeTitle, } from '#shared/trending-range' -import TrendingBoardItem from '../../components/TrendingBoardItem.vue' -import TrendingWeeklyCta from '../../components/TrendingWeeklyCta.vue' +import BoardRangeSwitch from '../../components/BoardRangeSwitch.vue' +import BoardRankedList from '../../components/BoardRankedList.vue' +import BoardShell from '../../components/BoardShell.vue' /** * Rows the feed board shows at most. @@ -179,7 +181,7 @@ const fallback = computed(() => { * Evidenced rows always sit above star-only rows, never interleaved by score. * Ranking a 200,000-star repository against "two people named it" would let * raw popularity win the page every week, which is what the `all` range is - * already for. Filling the tail with popular skills is honest; letting them + * already for. Filling the tail with starred skills is honest; letting them * outrank the evidence is not. */ const board = computed<TrendingBoardRow[]>(() => { @@ -202,13 +204,13 @@ const board = computed<TrendingBoardRow[]>(() => { // run command never guesses. skill: { owner: s.owner, repo: s.repo, name: s.name }, reason: s.evidence - ? { _tag: 'posts', posts: [s.evidence, ...(s.morePosts ?? [])].map(toPost), mentionsByDay: s.mentionsByDay ?? null } + ? { _tag: 'posts', posts: [s.evidence, ...(s.morePosts ?? [])].map(post => boardPost(post, clock.value)), mentionsByDay: s.mentionsByDay ?? null } : s.starGain !== null ? { _tag: 'surge', gain: s.starGain, when: s.starGainDay ? relativeDay(s.starGainDay, clock.value) : null } : { _tag: 'filler' }, })), - // Filler says so. A popular repository shown because the socials were - // quiet must never pass for one that people posted about. + // Filler says so. A starred repository shown because the socials were + // quiet must never pass for one that devs posted about. ...fallback.value.map((s): TrendingBoardRow => ({ key: s.registryPath, owner: s.owner, @@ -227,20 +229,6 @@ const board = computed<TrendingBoardRow[]>(() => { return rows.filter(row => !missingAvatars.value.has(row.owner)) }) -function toPost(post: TrendingPostFeedItem): TrendingPost { - return { - url: post.url, - text: post.text, - platform: post.platform, - handle: post.authorHandle, - // Optional until every cached feed response carries it. - authorName: post.authorName ?? null, - authorAvatar: post.authorAvatar, - likes: post.favouriteCount, - when: relativeDay(post.postedAt, clock.value), - } -} - const earlierRows = computed(() => { const onBoard = new Set(board.value.map(row => row.to)) return (admitted.value?.items ?? []).filter(item => !onBoard.has(item.registryPath)) @@ -316,8 +304,8 @@ const boardMeta = computed(() => { /** * The page header, which has to hold at fifty rows as well as at eight. * - * The feed board runs evidenced rows first and popular skills after, and the - * second group is most of the page on a quiet week. A header claiming every + * The feed board runs evidenced rows first and star-ranked skills after, and + * the second group is most of the page on a quiet week. A header claiming every * row was named is the same overclaim the "Named by developers" heading made, * one level up, so the filler is acknowledged whenever any is on the board. */ @@ -328,7 +316,7 @@ const headerDescription = computed(() => { // states. Repeating them cost the line its only job, which is the ranking // rule, and "say it once" is the brand's own instruction. return fillerTotal.value - ? 'Ranked by how many separate devs talked about each one. Popular skills fill the rest of the board.' + ? 'Ranked by how many separate devs talked about each one. GitHub stars rank the rest of the board.' : 'Ranked by how many separate devs talked about each one.' }) @@ -386,434 +374,169 @@ function formatDay(timestamp: number | null): string | null { timeZone: 'UTC', }).format(timestamp * 1000) } - -/** - * Rows above the weekly invitation on narrow screens. - * - * Wide screens carry the invitation in the sidebar. Narrow ones have no - * sidebar, and above the board it pushed the first Skill below the fold, so - * it waits until a reader has seen the head of the board. - */ -const HEAD_ROWS = 5 - -interface BoardChunk { - /** One-based rank of the chunk's first row, for the `<ol start>`. */ - start: number - rows: TrendingBoardRow[] -} - -const boardChunks = computed<BoardChunk[]>(() => [ - { start: 1, rows: board.value.slice(0, HEAD_ROWS) }, - { start: HEAD_ROWS + 1, rows: board.value.slice(HEAD_ROWS) }, -].filter(chunk => chunk.rows.length > 0)) </script> <template> - <!-- - The same shell as /skills: the heading lines up with the results column, - a sticky sidebar sits left of the board, and nothing sits right of it, so - the board takes every column the sidebar leaves. - --> - <div class="overflow-clip"> - <div class="w-full px-4 pb-10 pt-10 sm:px-6 lg:px-8 xl:px-10"> - <header aria-labelledby="trending-heading" class="mx-auto mb-7 max-w-7xl lg:ml-56 lg:mr-0"> - <h1 id="trending-heading" class="text-3xl font-semibold tracking-tight text-balance"> - {{ heading }} - </h1> - <p class="mt-2 text-sm text-muted"> - {{ headerDescription }} + <BoardShell + heading-id="trending-heading" + surface="trending" + :show-weekly-cta="showWeeklyCta" + :cta-pending="auth._tag === 'pending'" + > + <template #header> + <h1 id="trending-heading" class="text-3xl font-semibold tracking-tight text-balance"> + {{ heading }} + </h1> + <p class="mt-2 text-sm text-muted"> + {{ headerDescription }} + </p> + </template> + + <template #sidebar> + <BoardRangeSwitch :options="TRENDING_RANGES" :current="range" /> + </template> + + <!-- No heading above the board: the h1 already names it. --> + <p v-if="!isEmpty && !error && boardMeta" class="data-label mb-3 flex min-h-7 items-center"> + {{ boardMeta }} + </p> + + <div v-if="error" class="editorial-state" role="alert"> + <p class="font-medium"> + Couldn't load the board. + </p> + <p class="mt-1 max-w-lg text-base leading-relaxed text-muted"> + The ranking is unavailable right now. Check your connection and try again. + </p> + <UButton + label="Retry" + color="neutral" + variant="outline" + class="mt-4 min-h-11" + @click="() => refresh()" + /> + </div> + + <div v-else-if="isEmpty" class="editorial-state flex flex-col justify-center" role="status"> + <!-- Scattered dots that settle on one rose dot: the board is waiting for its first Skill. --> + <div class="relative mb-4 h-10"> + <TextureConverge /> + </div> + <template v-if="range === 'all'"> + <p class="text-sm text-default"> + No repositories have qualified yet. </p> - <!-- Narrow screens have no sidebar, so the install sits under the heading. --> - <div class="mt-5 lg:hidden"> - <div class="trending-teach"> - <p class="font-mono text-xs text-muted"> - Teach your agent skilld - </p> - <SkilldInstallChip surface="trending-header" /> - </div> - </div> - </header> - - <div class="trending-layout relative grid items-start gap-6 lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-8"> - <div class="trending-sidebar"> - <!-- - Real links, not a JS toggle. Each range is its own indexable - document with its own canonical, so a crawler has to be able to - follow one. Rendered above every state so a reader who lands on an - empty range can switch away from it. A column in the sidebar on - wide screens, a compact row above the board on narrow ones. - --> - <h2 id="range-heading" class="range-heading"> - Time range - </h2> - <nav class="range-switcher" aria-labelledby="range-heading"> - <NuxtLink - v-for="option in TRENDING_RANGES" - :key="option.id" - :to="option.path" - class="range-link" - :class="{ 'range-link--current': option.id === range }" - :aria-current="option.id === range ? 'page' : undefined" - > - <span>{{ option.label }}</span> - <span class="range-link__hint">{{ option.hint }}</span> - </NuxtLink> - </nav> - - <!-- - The server renders this page signed out for every visitor, so the - invitation keeps its space but stays invisible until the browser - knows who is looking; a weekly reader never sees it first. - `invisible` already hides it from assistive tech and the tab order, - so it carries no `aria-hidden`, which HTML validation rejects over - a link. - --> - <div class="trending-sidebar__foot"> - <div - v-if="showWeeklyCta" - :class="{ invisible: auth._tag === 'pending' }" - > - <TrendingWeeklyCta variant="rail" /> - </div> - <!-- The weekly button is the rail's one solid rose element, so the install inks its dot while it shows. --> - <div class="trending-teach"> - <p class="font-mono text-xs text-muted"> - Teach your agent skilld - </p> - <SkilldInstallChip surface="trending-sidebar" :quiet="showWeeklyCta" /> - </div> - </div> - </div> - - <section class="min-w-0 lg:pt-6" aria-labelledby="trending-heading"> - <!-- No heading above the board: the h1 already names it. --> - <p v-if="!isEmpty && !error && boardMeta" class="data-label mb-3 flex min-h-7 items-center"> - {{ boardMeta }} - </p> - - <div v-if="error" class="editorial-state" role="alert"> - <p class="font-medium"> - Couldn't load the board. - </p> - <p class="mt-1 max-w-lg text-base leading-relaxed text-muted"> - The ranking is unavailable right now. Check your connection and try again. - </p> - <UButton - label="Retry" - color="neutral" - variant="outline" - class="mt-4 min-h-11" - @click="() => refresh()" - /> - </div> - - <div v-else-if="isEmpty" class="editorial-state flex flex-col justify-center" role="status"> - <!-- Scattered dots that settle on one rose dot: the board is waiting for its first Skill. --> - <div class="relative mb-4 h-10"> - <TextureConverge /> - </div> - <template v-if="range === 'all'"> - <p class="text-sm text-default"> - No repositories have qualified yet. - </p> - <p class="mt-2 max-w-prose text-sm leading-relaxed text-muted"> - A repository appears here after a reviewer confirms its purpose and its skill inventory. - </p> - </template> - <template v-else> - <p class="text-sm text-default"> - Nothing is trending yet. - </p> - <p class="mt-2 max-w-prose text-sm leading-relaxed text-muted"> - Skilld watches X and Bluesky for posts mentioning a skill, and GitHub for repositories - holding a single skill whose stars surge. Neither has anything to report in this range. - </p> - </template> - <div class="mt-4"> - <UButton to="/skills" label="Browse the directory" color="neutral" variant="outline" class="min-h-11" /> - </div> - </div> - - <template v-else> - <template v-for="(chunk, chunkIndex) in boardChunks" :key="chunk.start"> - <div - v-if="chunkIndex === 1 && showWeeklyCta" - class="trending-cta--inline" - :class="{ invisible: auth._tag === 'pending' }" - > - <TrendingWeeklyCta /> - </div> - <ol class="board-list editorial-ledger list-none p-0" :start="chunk.start"> - <li v-for="(row, offset) in chunk.rows" :key="row.key"> - <TrendingBoardItem - :row="row" - :rank="chunk.start + offset" - @avatar-error="onAvatarError" - /> - </li> - </ol> - </template> - </template> - - <!-- - An index, not a second board. These Skills already left the - ranking, so they sit smaller than it: one line of description, no - rank. Every entry is still a plain link, which is the list's job: a - crawler reaches each Skill the sitemap lists from here. - --> - <section - v-if="earlierRows.length" - class="mt-12 border-t border-default pt-10" - aria-labelledby="earlier-heading" - > - <h2 id="earlier-heading" class="text-xl font-semibold tracking-tight"> - Earlier on this board - </h2> - <p class="data-label mt-1"> - {{ `${admitted?.total ?? earlierRows.length} skills` }} - </p> - <ul class="earlier-index mt-6 list-none p-0"> - <li v-for="item in earlierRows" :key="item.registryPath"> - <NuxtLink :to="item.registryPath" class="earlier-entry"> - <span class="earlier-entry__name"> - <img - :src="githubAvatarProxyUrl(item.owner, 32)" - alt="" - width="16" - height="16" - class="size-4 shrink-0 rounded-full bg-muted" - loading="lazy" - decoding="async" - > - <span class="truncate">{{ item.name }}</span> - </span> - <span class="earlier-entry__meta"> - <span class="truncate">{{ item.owner }}/{{ item.repo }}</span> - <span v-if="item.stars" class="shrink-0 tabular-nums">{{ `${item.stars.toLocaleString()} ★` }}</span> - </span> - <span v-if="item.description" class="earlier-entry__description">{{ item.description }}</span> - </NuxtLink> - </li> - </ul> - <nav - v-if="earlierPages.length > 1" - class="mt-6 flex flex-wrap gap-2" - aria-label="Earlier on this board, pages" - > - <NuxtLink - v-for="number in earlierPages" - :key="number" - :to="earlierPagePath(number)" - class="page-link" - :class="{ 'page-link--current': number === listPage }" - :aria-current="number === listPage ? 'page' : undefined" - > - {{ number }} - </NuxtLink> - </nav> - </section> - - <!-- - The eligibility rule, carried over with the `all` range from - /skills/leaderboard. The range claims its rows were reviewed, and a - reader has no way to check that claim unless the page states the rule. - --> - <section - v-if="range === 'all'" - class="mt-12 grid gap-6 border-t border-default pt-10 md:grid-cols-[minmax(0,0.7fr)_minmax(0,1.3fr)] md:gap-8" - aria-labelledby="method-heading" - > - <h2 id="method-heading" class="text-2xl font-semibold tracking-tight sm:text-3xl"> - A deliberately narrow list. - </h2> - <div class="max-w-2xl space-y-4 text-base leading-relaxed text-muted"> - <p> - A reviewer must confirm that the owner is an individual GitHub user and the repository primarily publishes reusable, generic agent skills. Documentation, assets, scripts, and tests are allowed. - </p> - <p> - Organizations, vendor catalogs, app-specific collections, prompts, bookmarks, and general applications are excluded. Repositories rank by current GitHub stars. - </p> - </div> - </section> - </section> + <p class="mt-2 max-w-prose text-sm leading-relaxed text-muted"> + A repository appears here after a reviewer confirms its purpose and its skill inventory. + </p> + </template> + <template v-else> + <p class="text-sm text-default"> + Nothing is trending yet. + </p> + <p class="mt-2 max-w-prose text-sm leading-relaxed text-muted"> + Skilld watches X and Bluesky for posts mentioning a skill, and GitHub for repositories + holding a single skill whose stars surge. Neither has anything to report in this range. + </p> + </template> + <div class="mt-4"> + <UButton to="/skills" label="Browse the directory" color="neutral" variant="outline" class="min-h-11" /> </div> </div> - </div> + + <BoardRankedList + v-else + :rows="board" + surface="trending-row" + :show-weekly-cta="showWeeklyCta" + :cta-pending="auth._tag === 'pending'" + @avatar-error="onAvatarError" + /> + + <!-- + An index, not a second board. These Skills already left the + ranking, so they sit smaller than it: one line of description, no + rank. Every entry is still a plain link, which is the list's job: a + crawler reaches each Skill the sitemap lists from here. + --> + <section + v-if="earlierRows.length" + class="mt-12 border-t border-default pt-10" + aria-labelledby="earlier-heading" + > + <h2 id="earlier-heading" class="text-xl font-semibold tracking-tight"> + Earlier on this board + </h2> + <p class="data-label mt-1"> + {{ `${admitted?.total ?? earlierRows.length} skills` }} + </p> + <ul class="earlier-index mt-6 list-none p-0"> + <li v-for="item in earlierRows" :key="item.registryPath"> + <NuxtLink :to="item.registryPath" class="earlier-entry"> + <span class="earlier-entry__name"> + <img + :src="githubAvatarProxyUrl(item.owner, 32)" + alt="" + width="16" + height="16" + class="size-4 shrink-0 rounded-full bg-muted" + loading="lazy" + decoding="async" + > + <span class="truncate">{{ item.name }}</span> + </span> + <span class="earlier-entry__meta"> + <span class="truncate">{{ item.owner }}/{{ item.repo }}</span> + <span v-if="item.stars" class="shrink-0 tabular-nums">{{ `${item.stars.toLocaleString()} ★` }}</span> + </span> + <span v-if="item.description" class="earlier-entry__description">{{ item.description }}</span> + </NuxtLink> + </li> + </ul> + <nav + v-if="earlierPages.length > 1" + class="mt-6 flex flex-wrap gap-2" + aria-label="Earlier on this board, pages" + > + <NuxtLink + v-for="number in earlierPages" + :key="number" + :to="earlierPagePath(number)" + class="page-link" + :class="{ 'page-link--current': number === listPage }" + :aria-current="number === listPage ? 'page' : undefined" + > + {{ number }} + </NuxtLink> + </nav> + </section> + + <!-- + The eligibility rule, carried over with the `all` range from + /skills/leaderboard. The range claims its rows were reviewed, and a + reader has no way to check that claim unless the page states the rule. + --> + <section + v-if="range === 'all'" + class="mt-12 grid gap-6 border-t border-default pt-10 md:grid-cols-[minmax(0,0.7fr)_minmax(0,1.3fr)] md:gap-8" + aria-labelledby="method-heading" + > + <h2 id="method-heading" class="text-2xl font-semibold tracking-tight sm:text-3xl"> + A deliberately narrow list. + </h2> + <div class="max-w-2xl space-y-4 text-base leading-relaxed text-muted"> + <p> + A reviewer must confirm that the owner is an individual GitHub user and the repository primarily publishes reusable, generic agent skills. Documentation, assets, scripts, and tests are allowed. + </p> + <p> + Organizations, vendor catalogs, app-specific collections, prompts, bookmarks, and general applications are excluded. Repositories rank by current GitHub stars. + </p> + </div> + </section> + </BoardShell> </template> <style scoped> -/* The full-bleed rule above the sidebar and the board, as on /skills. */ -.trending-layout::before { - position: absolute; - inset-block-start: 0; - inset-inline-start: 50%; - inline-size: 100vw; - transform: translateX(-50%); - border-block-start: 1px solid var(--ui-border); - content: ''; - pointer-events: none; -} - -.trending-sidebar { - min-inline-size: 0; - padding-top: 1.5rem; -} - -/* Narrow screens show the range row alone; the heading names it for assistive tech. */ -.range-heading { - position: absolute; - inline-size: 1px; - block-size: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; -} - -/* - * One control with three positions, rather than three buttons. Mono and - * border-driven, like every other control on the site; the current range is - * marked by a raised surface, so nothing moves and no accent is spent on - * navigation. - */ -.range-switcher { - display: inline-flex; - gap: 0.25rem; - padding: 0.25rem; - border: 1px solid var(--ui-border); - border-radius: calc(var(--ui-radius) + 0.25rem); -} - -.range-link { - display: inline-flex; - align-items: center; - min-height: 2.75rem; - padding-inline: 1rem; - border-radius: var(--ui-radius); - font-family: var(--font-mono); - font-size: 0.75rem; - color: var(--ui-text-muted); - transition: color 200ms, background-color 200ms; -} - -@media (hover: hover) { - .range-link:hover { - color: var(--ui-text); - } -} - -.range-link--current { - background: var(--ui-bg-elevated); - box-shadow: inset 0 0 0 1px var(--ui-border); - color: var(--ui-text-highlighted); -} - -.range-link__hint { - display: none; -} - -.trending-sidebar__foot { - display: none; -} - -.trending-teach { - display: grid; - gap: 0.5rem; - min-inline-size: 0; -} - -.trending-cta--inline { - margin-block: 1.5rem; -} - -/* - * Wide screens: the sidebar column from /skills, sticky under the site header. - * The ranges become a list with the window each covers, and the invitation - * and the install sit under them, where the board's right edge used to be - * spent on them. - */ -@media (min-width: 64rem) { - .trending-sidebar { - position: sticky; - inset-block-start: 4rem; - display: flex; - flex-direction: column; - block-size: calc(100dvh - 4rem); - overflow-y: auto; - padding-block-end: 1.5rem; - padding-inline-end: 1.5rem; - border-inline-end: 1px solid var(--ui-border); - } - - .range-heading { - position: static; - display: flex; - align-items: center; - inline-size: auto; - block-size: auto; - min-block-size: 1.75rem; - margin-block-end: 0.5rem; - overflow: visible; - clip-path: none; - font-size: 0.875rem; - font-weight: 600; - line-height: 1.25rem; - color: var(--ui-text); - } - - .range-switcher { - display: flex; - flex-direction: column; - gap: 0.125rem; - padding: 0; - border: 0; - border-radius: 0; - } - - /* Set like the maintainer rows in the /skills sidebar: the name, then a mono line. */ - .range-link { - flex-direction: column; - align-items: flex-start; - justify-content: center; - min-height: 2.75rem; - padding: 0.375rem 0.5rem; - font-family: inherit; - font-size: 0.8125rem; - font-weight: 500; - line-height: 1.25rem; - color: var(--ui-text-toned); - } - - .range-link--current { - box-shadow: none; - } - - @media (hover: hover) { - .range-link:hover { - background: var(--ui-bg-elevated); - } - } - - .range-link__hint { - display: block; - font-family: var(--font-mono); - font-size: 0.6875rem; - font-weight: 400; - line-height: 1rem; - color: var(--ui-text-muted); - } - - .trending-sidebar__foot { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 1.5rem; - margin-block-start: 2rem; - } - - .trending-cta--inline { - display: none; - } -} - /* * Columns of hairline-divided entries rather than cards: more than six items * read better as rows, and these are an index under the board, not picks. @@ -898,23 +621,4 @@ const boardChunks = computed<BoardChunk[]>(() => [ color: var(--ui-text); border-color: var(--ui-text); } - -.board-list { - margin: 0; -} - -/* - * The two halves of one board. Where nothing shows between them, the second - * drops its top rule rather than doubling the first one's bottom: always when - * no invitation renders, and on wide screens, where it moves to the sidebar. - */ -.board-list + .board-list { - border-top: 0; -} - -@media (min-width: 64rem) { - .board-list ~ .board-list { - border-top: 0; - } -} </style> diff --git a/layers/registry/server/api/clusters/[slug].get.ts b/layers/registry/server/api/clusters/[slug].get.ts index 38674b2a..e8e79066 100644 --- a/layers/registry/server/api/clusters/[slug].get.ts +++ b/layers/registry/server/api/clusters/[slug].get.ts @@ -9,6 +9,7 @@ function clusterMeta(cluster: Cluster) { return { slug: cluster.slug, label: cluster.label, + noun: cluster.noun, icon: cluster.icon, userVoice: cluster.userVoice, seoTitle: cluster.seoTitle, @@ -75,6 +76,7 @@ export default defineCachedEventHandler(async (event) => { .bind(...statements.listParams) .all<SkillRow>() + const pinnedKeys = new Set(cluster.pinnedExamples) const rankedItems = (res.results ?? []).map(s => ({ owner: s.owner, name: s.name, @@ -90,6 +92,10 @@ export default defineCachedEventHandler(async (event) => { name: s.name, repoSkillCount: s.repo_skill_count, }), + /** Skills in the repository. The board prints a run command only at 1. */ + repoSkillCount: s.repo_skill_count, + /** A person pinned it to this track, so the board lists it under the hand-picked heading. */ + pinned: pinnedKeys.has(`${s.owner}/${s.name}`), })) const items = page === 1 ? curateClusterSkills(rankedItems, cluster.pinnedExamples) @@ -109,5 +115,6 @@ export default defineCachedEventHandler(async (event) => { maxAge: 60 * 10, staleMaxAge: 60 * 60, swr: true, - name: 'clusters-detail-origin-v4', + // v5: items carry `repoSkillCount` and `pinned` for the track board. + name: 'clusters-detail-origin-v5', }) diff --git a/layers/registry/server/api/clusters/[slug]/talked.get.ts b/layers/registry/server/api/clusters/[slug]/talked.get.ts new file mode 100644 index 00000000..f661447a --- /dev/null +++ b/layers/registry/server/api/clusters/[slug]/talked.get.ts @@ -0,0 +1,72 @@ +/** + * Skills in one track that devs posted about, scored as `/skills/trending` + * scores them, with the posts that ranked each one. + * + * ADR-0010 admits this order on a track page. Social mentions only: a star + * surge says a repository moved, and the section this feeds is headed "devs + * talked about". The page decides whether the list clears the minimum; this + * route only ranks. + */ + +import type { TrackTalkedResult } from '../../../presenters/track-talked' +import { readThroughCache } from '#shared/server/cache' +import { defineApiHandler } from '#shared/server/handler' +import { loadTrendingSkills } from '#shared/server/trending-skills' +import { MAX_TALKED_ROWS, trackRangeMeta } from '#shared/track-board' +import { CLUSTER_BY_SLUG } from '../../../data/clusters' +import { presentTrackTalked } from '../../../presenters/track-talked' +import { TrackTalkedQuery } from '../../../schemas/track-talked-query' +import { findClusterCandidates } from '../../../utils/cluster-membership' + +/** + * The trending feed's windows: posts arrive every 15 minutes at most, and a + * stale list beats an error page through a D1 blip. + */ +const FRESH_SECONDS = 300 +const STALE_SECONDS = 3600 + +export default defineApiHandler({ + schema: TrackTalkedQuery, + handler: async ({ event, body, platform }): Promise<TrackTalkedResult> => { + const slug = getRouterParam(event, 'slug') ?? '' + const cluster = CLUSTER_BY_SLUG.get(slug) + if (!cluster) + throw createError({ statusCode: 404, statusMessage: 'Unknown track' }) + + const compute = async (): Promise<TrackTalkedResult> => { + const now = Math.floor(Date.now() / 1000) + // No `deprioritizeRepositories`, unlike the trending board. That demotion + // moves the most-starred repositories below every other row, and the line + // under this section says rows rank by how many devs talked about each + // one. Measured on 2026-10-06, it put a Skill one dev posted about above + // `ponytail`, which seven devs posted about. + const skills = await loadTrendingSkills({ + db: platform.db, + now, + windowHours: trackRangeMeta(body.range).windowHours, + limit: MAX_TALKED_ROWS, + scope: { + _tag: 'members', + keep: candidates => findClusterCandidates(platform.db, cluster.categories, cluster.pinnedExamples, candidates), + }, + }) + return { computedAt: now, skills } + } + + // A deployment identifies the answer's shape, so no release reads another + // release's entry. Without that identity the shared store is skipped. + const deployment = platform.env?.CF_VERSION_METADATA?.id + if (typeof deployment !== 'string' || !deployment.trim()) + return compute() + + const result = await readThroughCache( + useStorage('edge-cache'), + `track-talked:v1:${deployment}:${slug}:${body.range}`, + compute, + { ttl: FRESH_SECONDS, staleTtl: STALE_SECONDS }, + ) + setHeader(event, 'Cache-Control', `max-age=${FRESH_SECONDS}`) + return result + }, + presenter: (result, { body }) => presentTrackTalked(result, body.range), +}) diff --git a/layers/registry/server/api/v1/tracks/[slug].get.ts b/layers/registry/server/api/v1/tracks/[slug].get.ts index d4dd962b..dcb7507d 100644 --- a/layers/registry/server/api/v1/tracks/[slug].get.ts +++ b/layers/registry/server/api/v1/tracks/[slug].get.ts @@ -7,8 +7,9 @@ import { findSkillsByKeys, registrySkillKey } from '../../../utils/skills-regist /** * Reads the track page's own route in process, page by page at the page's own - * size, so v1 lists Skills in the order the page shows and shares its cache. - * Page 1 has no query string because the page requests it that way. + * size, so v1 lists the same members in the route's order and shares its + * cache. The page regroups them into a board (ADR-0010); v1 keeps the route + * order. Page 1 has no query string because the page requests it that way. */ export default defineApiOperation({ operation: tracksV1.operations.get, diff --git a/layers/registry/server/data/clusters.ts b/layers/registry/server/data/clusters.ts index 9b892d1c..21265d43 100644 --- a/layers/registry/server/data/clusters.ts +++ b/layers/registry/server/data/clusters.ts @@ -94,6 +94,12 @@ export interface Cluster { icon: string /** Second person, so the grid reads as "pick yourself", not "pick a task". */ userVoice: string + /** + * How the track reads before "skills" in a board heading: "Design skills devs + * talked about this week". Lowercase unless it is an acronym. The label is + * written to stand alone, and "Design and interface work skills" does not. + */ + noun: string /** Keyword-shaped <title>. Editorial voice stays in `label`. */ seoTitle: string seoDescription: string @@ -119,6 +125,7 @@ export const CLUSTERS: Cluster[] = [ { slug: 'design', label: 'Design and interface work', + noun: 'design', icon: 'i-lucide-palette', userVoice: 'You care how the interface looks, moves, and reads.', seoTitle: 'Agent Skills for UI and Design', @@ -148,6 +155,7 @@ export const CLUSTERS: Cluster[] = [ { slug: 'coding', label: 'Everyday coding', + noun: 'coding', icon: 'i-lucide-code', userVoice: 'You want the handful of skills that earn their context on every project.', seoTitle: 'Agent Skills for Coding', @@ -173,6 +181,7 @@ export const CLUSTERS: Cluster[] = [ { slug: 'anti-slop-coding', label: 'Anti-slop coding', + noun: 'anti-slop coding', icon: 'i-lucide-eraser', userVoice: 'You want agent-written code cleaned before it reaches review.', seoTitle: 'Agent Skills for Anti-Slop Coding', @@ -199,6 +208,7 @@ export const CLUSTERS: Cluster[] = [ { slug: 'context-engineering', label: 'Agent workflows', + noun: 'agent workflow', icon: 'i-lucide-zap', userVoice: 'You run agents in parallel, drive a browser, and verify what they hand back.', seoTitle: 'Agent Skills for Context Engineering', @@ -234,6 +244,7 @@ export const CLUSTERS: Cluster[] = [ { slug: 'code-review', label: 'Review and refactoring', + noun: 'review and refactoring', icon: 'i-lucide-eye', userVoice: 'You read other devs\' code and reshape it without breaking it.', seoTitle: 'Agent Skills for Code Review', @@ -257,6 +268,7 @@ export const CLUSTERS: Cluster[] = [ { slug: 'testing', label: 'Testing and debugging', + noun: 'testing and debugging', icon: 'i-lucide-flask-conical', userVoice: 'You want tests that prove the change, and a cause when it breaks.', seoTitle: 'Agent Skills for Testing and Debugging', @@ -284,6 +296,7 @@ export const CLUSTERS: Cluster[] = [ { slug: 'performance', label: 'Performance and web quality', + noun: 'performance', icon: 'i-lucide-gauge', userVoice: 'You profile the slow path before you ship it.', seoTitle: 'Agent Skills for Web Performance', @@ -308,6 +321,7 @@ export const CLUSTERS: Cluster[] = [ { slug: 'backend-data', label: 'Backend, data, and security', + noun: 'backend and security', icon: 'i-lucide-database', userVoice: 'You wire up databases, auth, security, and realtime systems.', seoTitle: 'Agent Skills for Backend, Databases, and Security', @@ -335,6 +349,7 @@ export const CLUSTERS: Cluster[] = [ { slug: 'devops', label: 'Shipping and release', + noun: 'shipping and release', icon: 'i-lucide-git-branch', userVoice: 'You own the commits, branches, and deploys at the end of the work.', seoTitle: 'Agent Skills for Git and DevOps', @@ -356,6 +371,7 @@ export const CLUSTERS: Cluster[] = [ { slug: 'planning', label: 'Planning and specs', + noun: 'planning', icon: 'i-lucide-list-checks', userVoice: 'You turn rough ideas into plans, specs, and scoped work.', seoTitle: 'Agent Skills for Planning and Specs', @@ -379,6 +395,7 @@ export const CLUSTERS: Cluster[] = [ { slug: 'anti-slop', label: 'Anti-slop writing', + noun: 'anti-slop writing', icon: 'i-lucide-eraser', userVoice: 'You want prose that reads like a person wrote it, with the AI tells gone.', seoTitle: 'Agent Skills for Anti-Slop Writing', @@ -411,6 +428,7 @@ export const CLUSTERS: Cluster[] = [ { slug: 'diagrams', label: 'Diagrams and codebase maps', + noun: 'diagram', icon: 'i-lucide-workflow', userVoice: 'You need the system drawn before you can change it.', seoTitle: 'Agent Skills for Diagrams and Architecture Maps', @@ -441,6 +459,7 @@ export const CLUSTERS: Cluster[] = [ { slug: 'research', label: 'Research and web content', + noun: 'research', icon: 'i-lucide-telescope', userVoice: 'You send the agent out to read the web and bring back sources.', seoTitle: 'Agent Skills for Research and Web Scraping', @@ -471,6 +490,7 @@ export const CLUSTERS: Cluster[] = [ { slug: 'seo', label: 'SEO', + noun: 'SEO', icon: 'i-lucide-search', userVoice: 'You want the crawler and the model to read the page the same way you do.', seoTitle: 'Agent Skills for SEO', diff --git a/layers/registry/server/presenters/track-talked.ts b/layers/registry/server/presenters/track-talked.ts new file mode 100644 index 00000000..3c9ca810 --- /dev/null +++ b/layers/registry/server/presenters/track-talked.ts @@ -0,0 +1,89 @@ +import type { TrendingSkill, TrendingSkillEvidence } from '#shared/server/trending-skills' +import type { TrackRange } from '#shared/track-board' +import { clipPostText } from '#shared/server/trending-skills' + +/** One post about a Skill, in the fields a board card renders. */ +export interface TrackTalkedPost { + url: string + authorHandle: string + authorName: string | null + authorAvatar: string | null + text: string + postedAt: number + platform: 'x' | 'bsky' + /** Likes on this post, not the Skill's total. */ + favouriteCount: number +} + +/** A Skill in the track that devs posted about, in rank order. */ +export interface TrackTalkedSkill { + owner: string + repo: string + name: string + canonicalName: string + /** Final public route. Clients must use this value directly. */ + registryPath: string + description: string | null + stars: number | null + /** Separate accounts that posted about it in the window. */ + authorCount: number + /** The quoted post first, then one from each other author. Never empty. */ + posts: TrackTalkedPost[] + /** Mentions per rolling day across the last seven, oldest first. */ + mentionsByDay: number[] | null +} + +export interface TrackTalkedResponse { + range: TrackRange + /** Unix seconds the ranking was computed at. Every post dates against it. */ + computedAt: number + skills: TrackTalkedSkill[] +} + +export interface TrackTalkedResult { + computedAt: number + skills: TrendingSkill[] +} + +function presentPost(post: TrendingSkillEvidence): TrackTalkedPost { + return { + url: post.url, + authorHandle: post.authorHandle, + authorName: post.authorName, + authorAvatar: post.authorAvatar, + text: clipPostText(post.text), + postedAt: post.postedAt, + platform: post.platform, + favouriteCount: post.favouriteCount, + } +} + +/** + * Ranked Skills, each with the posts that ranked it. + * + * A Skill with no post is dropped here rather than shipped bare. A track + * scope ranks by posts alone, so none should arrive, and ADR-0010 forbids a + * socially ranked row without its evidence. + */ +export function presentTrackTalked(result: TrackTalkedResult, range: TrackRange): TrackTalkedResponse { + return { + range, + computedAt: result.computedAt, + skills: result.skills.flatMap((skill) => { + if (!skill.evidence) + return [] + return [{ + owner: skill.owner, + repo: skill.repo, + name: skill.slug, + canonicalName: skill.canonicalName, + registryPath: skill.registryPath, + description: skill.description, + stars: skill.stars, + authorCount: skill.social?.authorCount ?? 0, + posts: [skill.evidence, ...skill.morePosts].map(presentPost), + mentionsByDay: skill.mentionsByDay, + }] + }), + } +} diff --git a/layers/registry/server/schemas/track-talked-query.ts b/layers/registry/server/schemas/track-talked-query.ts new file mode 100644 index 00000000..fe495a05 --- /dev/null +++ b/layers/registry/server/schemas/track-talked-query.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' +import { DEFAULT_TRACK_RANGE } from '#shared/track-board' + +/** `?range=` on a track's talked list. The page parses its own query into one of these. */ +export const TrackTalkedQuery = z.object({ + range: z.enum(['week', 'month']).default(DEFAULT_TRACK_RANGE), +}) + +export type TrackTalkedQuery = z.infer<typeof TrackTalkedQuery> diff --git a/layers/registry/server/utils/cluster-membership.ts b/layers/registry/server/utils/cluster-membership.ts index 4cacbc27..48cf9e82 100644 --- a/layers/registry/server/utils/cluster-membership.ts +++ b/layers/registry/server/utils/cluster-membership.ts @@ -47,6 +47,24 @@ function placeholders(count: number): string { return Array.from({ length: count }).fill('?').join(',') } +/** + * The two ways into a track, as WHERE arms over `skills s`: the classifier + * category, under the abstract-or-indexable gate, or a pin. ORed by the caller. + */ +function membershipArms(categories: string[], pinnedKeys: string[]): { arms: string[], params: string[] } { + const arms: string[] = [] + const params: string[] = [] + if (categories.length) { + arms.push(`(s.abstractness_category IN (${placeholders(categories.length)}) AND (s.is_abstract = 1 OR s.seo_indexable = 1))`) + params.push(...categories) + } + if (pinnedKeys.length) { + arms.push(`(s.owner || '/' || s.name IN (${placeholders(pinnedKeys.length)}))`) + params.push(...pinnedKeys) + } + return { arms, params } +} + /** * Rows for one or more classifier categories, plus any pinned skill, already * filtered to members. Ordering is left to the caller. @@ -59,17 +77,7 @@ export function clusterMembersSql( categories: string[], pinnedKeys: string[], ): ClusterMemberSql { - const arms: string[] = [] - const whereParams: string[] = [] - - if (categories.length) { - arms.push(`(s.abstractness_category IN (${placeholders(categories.length)}) AND (s.is_abstract = 1 OR s.seo_indexable = 1))`) - whereParams.push(...categories) - } - if (pinnedKeys.length) { - arms.push(`(s.owner || '/' || s.name IN (${placeholders(pinnedKeys.length)}))`) - whereParams.push(...pinnedKeys) - } + const { arms, params: whereParams } = membershipArms(categories, pinnedKeys) if (!arms.length) throw new Error('clusterMembersSql needs at least one category or pinned key') @@ -191,3 +199,74 @@ export function clusterPageSql( ], } } + +/** One Skill a track board asks about, keyed as the social half keys it. */ +export interface ClusterCandidate { + owner: string + repo: string + slug: string +} + +export interface ClusterCandidateSql { + sql: string + params: string[] +} + +/** + * Statements that answer which candidates belong to a track, with no rank caps. + * + * The caps above bound a list that stars would otherwise run to a thousand + * rows. A track board's talked section is already bounded by the posts in the + * window, so it admits every Skill the membership arms admit. The caps would + * only drop a Skill devs posted about for having too few stars, which is the + * order this section exists to replace. + * + * Chunked so each statement stays inside D1's bound parameter limit. A track + * with no category and no pin admits nothing, so it sends nothing. + */ +export function clusterCandidateStatements( + categories: string[], + pinnedKeys: string[], + candidates: readonly ClusterCandidate[], +): ClusterCandidateSql[] { + const { arms, params } = membershipArms(categories, pinnedKeys) + if (!arms.length || !candidates.length) + return [] + + const perStatement = Math.floor((D1_BOUND_PARAMETER_LIMIT - params.length) / 3) + if (perStatement < 1) + throw new Error('Track membership terms leave no room for a candidate under the D1 parameter limit') + + const statements: ClusterCandidateSql[] = [] + for (let i = 0; i < candidates.length; i += perStatement) { + const chunk = candidates.slice(i, i + perStatement) + // The VALUES list comes first in the statement text, so its keys bind first. + statements.push({ + sql: `SELECT s.owner, s.repo, s.name + FROM skills s + WHERE (s.owner, s.repo, s.name) IN (VALUES ${chunk.map(() => '(?, ?, ?)').join(', ')}) + AND s.source_resolved = 1 + AND (${arms.join(' OR ')})`, + params: [...chunk.flatMap(candidate => [candidate.owner, candidate.repo, candidate.slug]), ...params], + }) + } + return statements +} + +/** + * The candidates that belong to a track, as `owner/repo/slug` keys. + * + * The shape `TrendingScope.keep` asks for, so a track board can narrow the + * social ranking to its own Skills. + */ +export async function findClusterCandidates( + db: D1Database, + categories: string[], + pinnedKeys: string[], + candidates: readonly ClusterCandidate[], +): Promise<ReadonlySet<string>> { + const statements = clusterCandidateStatements(categories, pinnedKeys, candidates) + const results = await Promise.all(statements.map(statement => + db.prepare(statement.sql).bind(...statement.params).all<{ owner: string, repo: string, name: string }>())) + return new Set(results.flatMap(result => (result.results ?? []).map(row => `${row.owner}/${row.repo}/${row.name}`))) +} diff --git a/server/api/feed/trending.get.ts b/server/api/feed/trending.get.ts index e4150d33..925398fe 100644 --- a/server/api/feed/trending.get.ts +++ b/server/api/feed/trending.get.ts @@ -15,7 +15,7 @@ import { getDB } from '#server/utils/db' import { cachedFeed } from '#server/utils/feed-cache' import { loadStarSeries, starSeriesKey } from '#shared/server/star-series' import { loadTrendingBoard } from '#shared/server/trending-board' -import { DEFAULT_WINDOW_HOURS } from '#shared/server/trending-skills' +import { clipPostText, DEFAULT_WINDOW_HOURS } from '#shared/server/trending-skills' export interface TrendingFeedItem { owner: string @@ -187,22 +187,13 @@ function toSkillItem(entry: TrendingSkill, starSeries: StarPoint[]): TrendingSki } } -/** - * Longest post text the feed ships. - * - * X stores up to a thousand characters. A card shows four lines, about two - * hundred, and a board of thirty Skills with six posts each pays for every - * character in its payload twice: once in the HTML, once for hydration. - */ -const MAX_POST_TEXT = 480 - function toPostItem(post: TrendingSkillEvidence): TrendingPostFeedItem { return { url: post.url, authorHandle: post.authorHandle, authorName: post.authorName, authorAvatar: post.authorAvatar, - text: post.text.length > MAX_POST_TEXT ? `${post.text.slice(0, MAX_POST_TEXT - 1)}…` : post.text, + text: clipPostText(post.text), postedAt: post.postedAt, platform: post.platform, favouriteCount: post.favouriteCount, diff --git a/shared/server/trending-skills.ts b/shared/server/trending-skills.ts index f3c3199e..41284015 100644 --- a/shared/server/trending-skills.ts +++ b/shared/server/trending-skills.ts @@ -74,10 +74,50 @@ export interface LoadTrendingSkillsOptions { minLikes?: number /** Keep these familiar repositories eligible, but place discoveries first. */ deprioritizeRepositories?: ReadonlySet<string> + /** Which Skills may rank. Defaults to the whole trending board. */ + scope?: TrendingScope } +/** One Skill, as the social half keys it. */ +export interface TrendingSkillRef { + owner: string + repo: string + slug: string +} + +/** + * Which Skills may rank. + * + * `board` is `/skills/trending`: every Skill, by posts and by star surges. + * `members` is a track board (ADR-0010): only the Skills `keep` admits, ranked + * by posts alone. A star surge says a repository moved. A track heading that + * reads "devs talked about" cannot stand over a row no dev posted about. + * + * `keep` answers with {@link trendingSkillKey} keys. It narrows the window + * after it loads, never in SQL, because breadth belongs to the post: a post + * naming thirty Skills stays a thirtieth of an author when one of the thirty + * is in the track. + */ +export type TrendingScope + = | { _tag: 'board' } + | { _tag: 'members', keep: (candidates: readonly TrendingSkillRef[]) => Promise<ReadonlySet<string>> } + export const DEFAULT_WINDOW_HOURS = 24 * 7 +/** + * Longest post text a board ships. + * + * X stores up to a thousand characters. A card shows four lines, about two + * hundred, and a board of thirty Skills with six posts each pays for every + * character in its payload twice: once in the HTML, once for hydration. + */ +export const MAX_POST_TEXT = 480 + +/** Post text cut to {@link MAX_POST_TEXT}, with an ellipsis where it was cut. */ +export function clipPostText(text: string): string { + return text.length > MAX_POST_TEXT ? `${text.slice(0, MAX_POST_TEXT - 1)}…` : text +} + /** * Posts a skill carries beyond its quoted one. * @@ -141,10 +181,13 @@ interface SurgeRow { observed_day: number } -function skillKey(row: { owner: string, repo: string, slug: string }): string { +/** `owner/repo/slug`, the key a {@link TrendingScope} answers with. */ +export function trendingSkillKey(row: TrendingSkillRef): string { return `${row.owner}/${row.repo}/${row.slug}` } +const skillKey = trendingSkillKey + /** * Permalink for the post that carried a mention. * @@ -414,10 +457,16 @@ export async function loadTrendingSkills( const limit = options.limit ?? 24 const cutoff = options.now - windowHours * 3600 + const scope: TrendingScope = options.scope ?? { _tag: 'board' } const [social, github] = await Promise.all([ loadSocialEvidence(options, cutoff, minLikes), - loadGithubEvidence(options, cutoff), + scope._tag === 'board' + ? loadGithubEvidence(options, cutoff) + : Promise.resolve(new Map<string, SkillTrendInput>()), ]) + const admitted = scope._tag === 'members' + ? await scope.keep([...social.values()].map(entry => entry.input)) + : null const merged = new Map<string, { input: SkillTrendInput @@ -426,8 +475,10 @@ export async function loadTrendingSkills( mentionsByDay: number[] | null }>() - for (const [key, entry] of social) - merged.set(key, entry) + for (const [key, entry] of social) { + if (!admitted || admitted.has(key)) + merged.set(key, entry) + } for (const [key, input] of github) { const held = merged.get(key) diff --git a/shared/track-board.ts b/shared/track-board.ts new file mode 100644 index 00000000..3269360d --- /dev/null +++ b/shared/track-board.ts @@ -0,0 +1,231 @@ +/** + * The board a track page (`/skills/<slug>`) shows, and what each section may claim. + * + * A track page reuses the trending board's rows. Its order is not trending's, + * so every section carries a heading that says what orders it: + * + * talked Skills in the track that devs posted about in the window, scored + * as `/skills/trending` scores them. Only when at least + * {@link MIN_TALKED_SKILLS} qualify. ADR-0010 admits this order. + * picked The track's pinned Skills, in the order a person set. + * stars Every other member, ranked by GitHub stars. + * + * Pure and kept out of the component, so the section rule and every heading + * are testable without rendering. A heading that overclaims is a defect. + */ + +import type { BoardPostInput, TrendingBoardRow } from './trending-range' +import { boardPost, singleSkill } from './trending-range' + +export type TrackRange = 'week' | 'month' + +export interface TrackRangeMeta { + readonly id: TrackRange + /** Switcher label. */ + readonly label: string + /** The line under the label in the sidebar: what the range covers. */ + readonly hint: string + /** Hours of posts the talked section reads. */ + readonly windowHours: number + /** How a heading names the window. */ + readonly period: string +} + +/** + * The week leads because the talked heading says "this week". The month exists + * for the tracks whose week is too quiet to rank: on 2026-10-06, three of + * fourteen tracks cleared the minimum over seven days and six over thirty. + */ +export const DEFAULT_TRACK_RANGE: TrackRange = 'week' + +export const TRACK_RANGES: readonly TrackRangeMeta[] = [ + { id: 'week', label: 'Week', hint: 'Last 7 days', windowHours: 24 * 7, period: 'this week' }, + { id: 'month', label: 'Month', hint: 'Last 30 days', windowHours: 24 * 30, period: 'this month' }, +] + +const RANGE_BY_ID = new Map(TRACK_RANGES.map(range => [range.id, range])) + +export function trackRangeMeta(range: TrackRange): TrackRangeMeta { + return RANGE_BY_ID.get(range) ?? RANGE_BY_ID.get(DEFAULT_TRACK_RANGE)! +} + +/** + * Parse `?range=` once, at the boundary. Anything unrecognised is the default: + * a stale link is not worth a 404. + */ +export function resolveTrackRange(value: unknown): TrackRange { + const candidate = Array.isArray(value) ? value[0] : value + return typeof candidate === 'string' && RANGE_BY_ID.has(candidate as TrackRange) + ? candidate as TrackRange + : DEFAULT_TRACK_RANGE +} + +/** Where the switcher points. The default range keeps the bare track URL. */ +export function trackRangePath(slug: string, range: TrackRange): string { + return range === DEFAULT_TRACK_RANGE ? `/skills/${slug}` : `/skills/${slug}?range=${range}` +} + +/** + * Skills a talked section needs before it leads the board. + * + * Measured in production on 2026-10-06 over seven days, the mentioned Skills + * per track ran 7, 6, 5, then 3, 2, 1, 1, 1, 1, 1, 1, 1, 0, 0. Five sits in the + * gap: the three tracks above it carry 12, 14 and 4 devs, and the next one + * down holds three Skills from three devs, one each. Five is also where the + * board's head ends and the weekly invitation sits on a narrow screen, so the + * invitation always follows a full head of rows that devs talked about. + */ +export const MIN_TALKED_SKILLS = 5 + +/** Rows the talked section shows at most, the trending board's own cap. */ +export const MAX_TALKED_ROWS = 30 + +/** A track member, as `/api/clusters/<slug>` lists it. */ +export interface TrackMemberInput { + owner: string + repo: string + name: string + displayName: string + description: string | null + stars: number + registryPath: string + /** Skills in the repository. The row prints a run command only at 1. */ + repoSkillCount: number + /** A person pinned it to the track. */ + pinned: boolean +} + +/** A Skill devs posted about, as `/api/clusters/<slug>/talked` lists it, already ranked. */ +export interface TrackTalkedInput { + owner: string + repo: string + name: string + canonicalName: string + registryPath: string + description: string | null + stars: number | null + /** The quoted post first, then one from each other author. Never empty. */ + posts: readonly BoardPostInput[] + mentionsByDay: readonly number[] | null +} + +export type TrackSectionKind = 'talked' | 'picked' | 'stars' + +export interface TrackBoardSection { + readonly _tag: TrackSectionKind + /** Heading element id, unique on the page. */ + readonly id: string + readonly heading: string + readonly rows: readonly TrendingBoardRow[] +} + +export interface TrackBoardInput { + /** How the track reads before "skills", lowercase unless it is an acronym. */ + noun: string + range: TrackRange + /** Ranked, already narrowed to the track. */ + talked: readonly TrackTalkedInput[] + /** Pinned first, in pin order. */ + members: readonly TrackMemberInput[] + /** The talked payload's clock, so posts date the same on server and client. */ + clockSeconds: number +} + +export interface TrackBoard { + sections: TrackBoardSection[] + /** Skills devs talked about in the window, whether or not they cleared the minimum. */ + talkedCount: number +} + +function capitalise(phrase: string): string { + return phrase.charAt(0).toUpperCase() + phrase.slice(1) +} + +function talkedRow(skill: TrackTalkedInput, clockSeconds: number): TrendingBoardRow { + return { + key: skill.registryPath, + owner: skill.owner, + title: skill.canonicalName, + to: skill.registryPath, + subtitle: `${skill.owner}/${skill.repo}`, + description: skill.description, + stars: skill.stars, + starSeries: [], + names: [skill.name, skill.canonicalName], + // A post put this exact Skill here, so the run command never guesses. + skill: { owner: skill.owner, repo: skill.repo, name: skill.name }, + reason: { + _tag: 'posts', + posts: skill.posts.map(post => boardPost(post, clockSeconds)), + mentionsByDay: skill.mentionsByDay, + }, + } +} + +function memberRow(member: TrackMemberInput): TrendingBoardRow { + return { + key: member.registryPath, + owner: member.owner, + title: member.displayName || member.name, + to: member.registryPath, + subtitle: `${member.owner}/${member.repo}`, + description: member.description, + stars: member.stars, + starSeries: [], + names: [member.name, member.displayName], + skill: singleSkill(member.owner, member.repo, member.name, member.repoSkillCount), + reason: { _tag: 'member' }, + } +} + +/** + * The sections, in page order, each with a heading that says what orders it. + * + * A Skill shows once. One devs talked about leaves the lists below, so the + * page never prints two numbers against one Skill. Under the minimum the + * talked Skills stay where the lists put them, and draw no posts: a row only + * draws the signal that ranked it. + */ +export function trackBoard(input: TrackBoardInput): TrackBoard { + const { noun, clockSeconds } = input + const period = trackRangeMeta(input.range).period + const talked = input.talked.filter(skill => skill.posts.length > 0) + const sections: TrackBoardSection[] = [] + + if (talked.length >= MIN_TALKED_SKILLS) { + sections.push({ + _tag: 'talked', + id: 'track-talked-heading', + heading: `${capitalise(noun)} skills devs talked about ${period}`, + rows: talked.slice(0, MAX_TALKED_ROWS).map(skill => talkedRow(skill, clockSeconds)), + }) + } + + const shown = new Set(sections.flatMap(section => section.rows.map(row => row.key))) + const rest = input.members.filter(member => !shown.has(member.registryPath)) + const picked = rest.filter(member => member.pinned) + if (picked.length) { + sections.push({ + _tag: 'picked', + id: 'track-picked-heading', + heading: `Hand-picked ${noun} skills`, + rows: picked.map(memberRow), + }) + } + + const byStars = rest + .filter(member => !member.pinned) + .sort((a, b) => b.stars - a.stars || a.name.localeCompare(b.name)) + if (byStars.length) { + sections.push({ + _tag: 'stars', + id: 'track-stars-heading', + heading: sections.length + ? `More ${noun} skills, ranked by GitHub stars` + : `${capitalise(noun)} skills, ranked by GitHub stars`, + rows: byStars.map(memberRow), + }) + } + + return { sections, talkedCount: talked.length } +} diff --git a/shared/trending-range.ts b/shared/trending-range.ts index 2b267e8f..59c0c144 100644 --- a/shared/trending-range.ts +++ b/shared/trending-range.ts @@ -13,6 +13,8 @@ * defect, and a defect wants a test. */ +import { relativeDay } from './trending-post' + export type TrendingRange = 'week' | 'month' | 'all' /** @@ -286,6 +288,39 @@ export interface TrendingPost { when: string } +/** + * The fields a board card reads off one stored post. + * + * Structural, like {@link LeaderboardRowInput}, so the trending feed and a + * track board map their posts through one function without this module + * importing either server route. + */ +export interface BoardPostInput { + url: string + text: string + platform: 'x' | 'bsky' + authorHandle: string + /** Optional until every cached feed response carries it. */ + authorName?: string | null + authorAvatar: string | null + favouriteCount: number + postedAt: number +} + +/** One stored post, dated against the board's clock rather than the browser's. */ +export function boardPost(post: BoardPostInput, clockSeconds: number): TrendingPost { + return { + url: post.url, + text: post.text, + platform: post.platform, + handle: post.authorHandle, + authorName: post.authorName ?? null, + authorAvatar: post.authorAvatar, + likes: post.favouriteCount, + when: relativeDay(post.postedAt, clockSeconds), + } +} + /** * Why a row is on the board. Exactly one reason per row. * @@ -307,6 +342,11 @@ export type TrendingReason | { _tag: 'surge', gain: number, when: string | null } | { _tag: 'filler' } | { _tag: 'reviewed', skillCount: number, updated: string | null } + /** + * A track member, in a section whose heading states the order. The row + * repeats nothing the heading already says. + */ + | { _tag: 'member' } /** * A board row, in the one shape the template renders. @@ -366,7 +406,7 @@ export function singleSkill(owner: string, repo: string, name: string, repoSkill * indexability is how the catalog got suppressed in June. */ export function isEvidenced(row: TrendingBoardRow): boolean { - return row.reason._tag !== 'filler' + return row.reason._tag !== 'filler' && row.reason._tag !== 'member' } /** diff --git a/test/unit/track-board.test.ts b/test/unit/track-board.test.ts new file mode 100644 index 00000000..8ea9ed70 --- /dev/null +++ b/test/unit/track-board.test.ts @@ -0,0 +1,173 @@ +import type { TrackMemberInput, TrackTalkedInput } from '#shared/track-board' +import { describe, expect, it } from 'vitest' +import { MIN_TALKED_SKILLS, resolveTrackRange, trackBoard, trackRangePath } from '#shared/track-board' + +const NOW = 1_790_000_000 + +function talked(name: string): TrackTalkedInput { + return { + owner: `${name}-owner`, + repo: 'skills', + name, + canonicalName: name, + registryPath: `/gh/${name}-owner/skills/${name}`, + description: `${name} does one thing.`, + stars: 120, + posts: [{ + url: `https://x.com/dev/status/${name}`, + text: `try /${name}`, + platform: 'x', + authorHandle: 'dev', + authorName: null, + authorAvatar: null, + favouriteCount: 4, + postedAt: NOW - 2 * 3600, + }], + mentionsByDay: [0, 0, 0, 0, 0, 0, 1], + } +} + +function member(name: string, stars: number, overrides: Partial<TrackMemberInput> = {}): TrackMemberInput { + return { + owner: `${name}-owner`, + repo: 'skills', + name, + displayName: name, + description: null, + stars, + registryPath: `/gh/${name}-owner/skills/${name}`, + repoSkillCount: 4, + pinned: false, + ...overrides, + } +} + +const talkedSkills = (count: number) => Array.from({ length: count }, (_, i) => talked(`talked-${i + 1}`)) + +describe('a track board leads with what devs talked about only above the minimum', () => { + it('ranks the talked Skills first, each with its posts, at the minimum', () => { + const board = trackBoard({ + noun: 'design', + range: 'week', + talked: talkedSkills(MIN_TALKED_SKILLS), + members: [member('picked', 10, { pinned: true }), member('starred', 900)], + clockSeconds: NOW, + }) + + expect(board.sections.map(section => section.heading)).toEqual([ + 'Design skills devs talked about this week', + 'Hand-picked design skills', + 'More design skills, ranked by GitHub stars', + ]) + const [first] = board.sections + expect(first?.rows.map(row => row.title)).toEqual(['talked-1', 'talked-2', 'talked-3', 'talked-4', 'talked-5']) + expect(first?.rows[0]?.reason).toMatchObject({ _tag: 'posts', posts: [{ handle: 'dev', when: '2h ago' }] }) + }) + + it('leaves the talked Skills in the lists, without posts, one short of the minimum', () => { + const quiet = talkedSkills(MIN_TALKED_SKILLS - 1) + const board = trackBoard({ + noun: 'SEO', + range: 'week', + talked: quiet, + members: [member('talked-1', 50), member('other', 400)], + clockSeconds: NOW, + }) + + expect(board.talkedCount).toBe(MIN_TALKED_SKILLS - 1) + expect(board.sections.map(section => section.heading)).toEqual(['SEO skills, ranked by GitHub stars']) + expect(board.sections[0]?.rows.map(row => [row.title, row.reason._tag])).toEqual([ + ['other', 'member'], + ['talked-1', 'member'], + ]) + }) + + it('names the month in the heading on the month board', () => { + const board = trackBoard({ noun: 'planning', range: 'month', talked: talkedSkills(6), members: [], clockSeconds: NOW }) + + expect(board.sections.map(section => section.heading)).toEqual(['Planning skills devs talked about this month']) + }) + + it('never counts a talked Skill that arrived without a post', () => { + const bare = { ...talked('bare'), posts: [] } + const board = trackBoard({ + noun: 'design', + range: 'week', + talked: [...talkedSkills(MIN_TALKED_SKILLS - 1), bare], + members: [], + clockSeconds: NOW, + }) + + expect(board.talkedCount).toBe(MIN_TALKED_SKILLS - 1) + expect(board.sections).toEqual([]) + }) +}) + +describe('the lists under the talked section', () => { + it('shows a talked Skill once, in the talked section', () => { + const board = trackBoard({ + noun: 'design', + range: 'week', + talked: talkedSkills(MIN_TALKED_SKILLS), + members: [member('talked-2', 5000, { pinned: true }), member('talked-3', 4000), member('kept', 1)], + clockSeconds: NOW, + }) + + const titles = board.sections.flatMap(section => section.rows.map(row => row.title)) + expect(titles.filter(title => title === 'talked-2')).toHaveLength(1) + expect(board.sections.at(-1)?.rows.map(row => row.title)).toEqual(['kept']) + }) + + it('keeps the pin order a person set, and orders the rest by stars', () => { + const board = trackBoard({ + noun: 'coding', + range: 'week', + talked: [], + members: [ + member('second-pick', 5, { pinned: true }), + member('first-pick', 9000, { pinned: true }), + member('small', 10), + member('large', 70_000), + member('medium', 300), + ], + clockSeconds: NOW, + }) + + expect(board.sections.map(section => [section.heading, section.rows.map(row => row.title)])).toEqual([ + ['Hand-picked coding skills', ['second-pick', 'first-pick']], + ['More coding skills, ranked by GitHub stars', ['large', 'medium', 'small']], + ]) + }) + + it('prints a run command only for a repository holding one Skill', () => { + const board = trackBoard({ + noun: 'testing and debugging', + range: 'week', + talked: [], + members: [member('solo', 20, { repoSkillCount: 1 }), member('shared', 10, { repoSkillCount: 6 })], + clockSeconds: NOW, + }) + + expect(board.sections[0]?.rows.map(row => row.skill)).toEqual([ + { owner: 'solo-owner', repo: 'skills', name: 'solo' }, + null, + ]) + }) +}) + +describe('track ranges', () => { + it.each([ + [undefined, 'week'], + ['month', 'month'], + [['month', 'week'], 'month'], + ['all', 'week'], + ['', 'week'], + ])('reads ?range=%j as %s', (value, expected) => { + expect(resolveTrackRange(value)).toBe(expected) + }) + + it('keeps the bare track URL for the week and a query for the month', () => { + expect(trackRangePath('design', 'week')).toBe('/skills/design') + expect(trackRangePath('design', 'month')).toBe('/skills/design?range=month') + }) +}) diff --git a/test/unit/track-talked.test.ts b/test/unit/track-talked.test.ts new file mode 100644 index 00000000..0c7985af --- /dev/null +++ b/test/unit/track-talked.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment node +import type { TrendingScope } from '../../shared/server/trending-skills' +import type { SqliteD1 } from './helpers/d1-sqlite' +import { afterEach, describe, expect, it } from 'vitest' +import { findClusterCandidates } from '../../layers/registry/server/utils/cluster-membership' +import { loadTrendingSkills } from '../../shared/server/trending-skills' +import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite' + +const MIGRATIONS = allMigrations() +const NOW = 1_760_000_000 +const HOUR = 3600 + +let harness: SqliteD1 | null = null +function db() { + harness ??= createSqliteD1(MIGRATIONS) + return harness +} +afterEach(() => { + harness?.close() + harness = null +}) + +interface SkillSeed { + owner: string + repo?: string + slug: string + category?: string | null + abstract?: 0 | 1 | null + indexable?: 0 | 1 +} + +function seedSkill(skill: SkillSeed): { owner: string, repo: string, slug: string } { + const repo = skill.repo ?? 'skills' + db().raw.prepare(`INSERT OR IGNORE INTO repos (owner, repo, stars) VALUES (?, ?, 100)`).run(skill.owner, repo) + db().raw.prepare( + `INSERT OR IGNORE INTO skills + (owner, repo, name, slug, display_name, source_resolved, rendered_skill_path, + abstractness_category, is_abstract, seo_indexable) + VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?)`, + ).run(skill.owner, repo, skill.slug, skill.slug, skill.slug, `${skill.slug}/SKILL.md`, skill.category ?? null, skill.abstract ?? null, skill.indexable ?? 0) + return { owner: skill.owner, repo, slug: skill.slug } +} + +let seq = 0 +/** One post by `handle` that names every listed Skill, as a listicle does. */ +function post(handle: string, skills: { owner: string, repo: string, slug: string }[], likes = 5) { + const id = `p${++seq}` + const postedAt = NOW - HOUR + db().raw.prepare( + `INSERT INTO x_posts ( + post_id, author_id, author_handle, author_name, author_followers, + text_extract, lang, posted_at, first_seen_at, + favourite_count, repost_count, reply_count, quote_count, + bookmark_count, impression_count, metrics_updated_at, + refresh_tier, next_refresh_at, author_avatar + ) VALUES (?, ?, ?, NULL, 0, 'a post', 'en', ?, ?, ?, 0, 0, 0, 0, 0, ?, 'hot', ?, NULL)`, + ).run(id, `a-${handle}`, handle, postedAt, postedAt, likes, postedAt, postedAt + HOUR) + for (const skill of skills) { + db().raw.prepare( + `INSERT INTO x_post_skills (post_id, owner, repo, slug, canonical_name, skill_path, detection, matched_on, verified_at) + VALUES (?, ?, ?, ?, ?, 'SKILL.md', 'slash', 'directory', ?)`, + ).run(id, skill.owner, skill.repo, skill.slug, skill.slug, NOW) + } +} + +/** The scope a design track hands the loader: one category, one pin. */ +function designScope(pins: string[] = []): TrendingScope { + return { + _tag: 'members', + keep: candidates => findClusterCandidates(db().db, ['interface-design'], pins, candidates), + } +} + +describe('a track ranks only its own Skills', () => { + it('admits the category under the abstract-or-indexable gate, and a pin', async () => { + const abstract = seedSkill({ owner: 'a', slug: 'motion', category: 'interface-design', abstract: 1 }) + const indexable = seedSkill({ owner: 'b', slug: 'a11y', category: 'interface-design', abstract: 0, indexable: 1 }) + const ungated = seedSkill({ owner: 'c', slug: 'raw', category: 'interface-design', abstract: 0, indexable: 0 }) + const pinned = seedSkill({ owner: 'd', slug: 'picked', category: null }) + const elsewhere = seedSkill({ owner: 'e', slug: 'tdd', category: 'testing', abstract: 1 }) + for (const [i, skill] of [abstract, indexable, ungated, pinned, elsewhere].entries()) + post(`dev${i}`, [skill]) + + const ranked = await loadTrendingSkills({ db: db().db, now: NOW, scope: designScope(['d/picked']) }) + + expect(ranked.map(skill => skill.slug).sort()).toEqual(['a11y', 'motion', 'picked']) + }) + + it('ranks by posts alone, so a star surge cannot enter a track', async () => { + const surged = seedSkill({ owner: 'solo', repo: 'one', slug: 'one', category: 'interface-design', abstract: 1 }) + db().raw.prepare( + `INSERT INTO repo_star_surges (owner, repo, observed_day, latest_gain, baseline_gain, stars, detected_at) + VALUES (?, ?, ?, 900, 5, 5000, ?)`, + ).run(surged.owner, surged.repo, Math.floor((NOW - 86400) / 86400) * 86400, NOW - HOUR) + + expect(await loadTrendingSkills({ db: db().db, now: NOW, scope: designScope() })).toEqual([]) + // The board scope still counts the surge, so the narrowing is the track's. + expect((await loadTrendingSkills({ db: db().db, now: NOW })).map(skill => skill.slug)).toEqual(['one']) + }) + + it('keeps a listicle diluted when only one of its Skills is in the track', async () => { + const listed = seedSkill({ owner: 'a', slug: 'listed', category: 'interface-design', abstract: 1 }) + const dedicated = seedSkill({ owner: 'b', slug: 'dedicated', category: 'interface-design', abstract: 1 }) + const others = Array.from({ length: 9 }, (_, i) => seedSkill({ owner: `o${i}`, slug: `other-${i}`, category: 'testing', abstract: 1 })) + // Two devs each post a list of ten; one dev posts about one Skill. + post('lister1', [listed, ...others]) + post('lister2', [listed, ...others]) + post('fan', [dedicated]) + + const ranked = await loadTrendingSkills({ db: db().db, now: NOW, scope: designScope() }) + + expect(ranked.map(skill => skill.slug)).toEqual(['dedicated', 'listed']) + expect(ranked[1]?.social?.authorWeight).toBeCloseTo(0.2) + }) + + it('answers for more candidates than one D1 statement can bind', async () => { + const skills = Array.from({ length: 70 }, (_, i) => + seedSkill({ owner: `owner${i}`, slug: `skill-${i}`, category: i % 2 ? 'interface-design' : 'testing', abstract: 1 })) + + const kept = await findClusterCandidates(db().db, ['interface-design'], [], skills) + + expect(kept.size).toBe(35) + expect(kept.has('owner1/skills/skill-1')).toBe(true) + expect(kept.has('owner0/skills/skill-0')).toBe(false) + }) + + it('admits nothing for a track with no category and no pin', async () => { + const skill = seedSkill({ owner: 'a', slug: 'motion', category: 'interface-design', abstract: 1 }) + + expect((await findClusterCandidates(db().db, [], [], [skill])).size).toBe(0) + }) +})