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 @@
+
+
+
+
+ The ranking is unavailable right now. Check your connection and try again.
+
+ refresh()"
+ />
+
+
+
+
+
+
+
+
+
+ No repositories have qualified yet.
-
-
-
-
- Teach your agent skilld
-
-
-
-
-
-
-
-
-
-
- Time range
-
-
-
-
-
-
-
-
-
-
-
- Teach your agent skilld
-
-
-
-
-
-
-
-
-
- {{ boardMeta }}
-
-
-
-
- Couldn't load the board.
-
-
- The ranking is unavailable right now. Check your connection and try again.
-
- refresh()"
- />
-
-
-
-
-
-
-
-
-
- No repositories have qualified yet.
-
-
- A repository appears here after a reviewer confirms its purpose and its skill inventory.
-
-
-
-
- Nothing is trending yet.
-
-
- 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.
-
- 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.
-
-
- Organizations, vendor catalogs, app-specific collections, prompts, bookmarks, and general applications are excluded. Repositories rank by current GitHub stars.
-
-
-
-
+
+ A repository appears here after a reviewer confirms its purpose and its skill inventory.
+
+
+
+
+ Nothing is trending yet.
+
+
+ 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.
+
+ 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.
+
+
+ Organizations, vendor catalogs, app-specific collections, prompts, bookmarks, and general applications are excluded. Repositories rank by current GitHub stars.
+
+
+
+
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()
+ 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 => {
+ 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 => {
+ 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 . 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
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> {
+ 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
+ /** 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> }
+
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()),
])
+ const admitted = scope._tag === 'members'
+ ? await scope.keep([...social.values()].map(entry => entry.input))
+ : null
const merged = new Map()
- 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/`) 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/` 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//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 {
+ 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)
+ })
+})