Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,11 @@ watch(() => route.fullPath, () => {
<div class="flex items-center justify-end gap-1.5 lg:flex-1">
<SkillSearchTrigger v-if="$route.path !== '/'" />
<!--
The flame is a deliberate exception to the "no emoji" rule in
DESIGN.md, asked for so Trending outweighs its siblings.
The braille mark makes Trending outweigh its siblings without an
emoji. Stone only: the logo dot is the header's one rose element.
It is decorative and hidden from screen readers; the label carries
the meaning. `gap-1.5` because the button's own layout does not
space sibling spans, which rendered "πŸ”₯Trending" run together.
space sibling spans.
-->
<UButton
to="/skills/trending"
Expand All @@ -144,7 +144,7 @@ watch(() => route.fullPath, () => {
size="sm"
class="hidden gap-1.5 whitespace-nowrap lg:inline-flex"
>
<span class="trending-fire" aria-hidden="true">πŸ”₯</span>
<TrendingMark :accent="false" />
<span>Trending Skills</span>
</UButton>
<UButton
Expand Down Expand Up @@ -204,7 +204,7 @@ watch(() => route.fullPath, () => {
block
class="min-h-11 justify-start gap-1.5 whitespace-nowrap"
>
<span class="trending-fire" aria-hidden="true">πŸ”₯</span>
<TrendingMark :accent="false" />
<span>Trending Skills</span>
</UButton>
<UButton
Expand Down
37 changes: 0 additions & 37 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -217,43 +217,6 @@
);
}

/* Keep the trending signal lively without competing with surrounding chrome. */
.trending-fire {
display: inline-block;
transform-origin: center 70%;
animation: trending-fire-alive 1.8s ease-in-out infinite;
}

@keyframes trending-fire-alive {
0%, 100% {
opacity: 0.72;
transform: translateY(1px) rotate(-2deg) scale(0.96);
}

24% {
opacity: 1;
transform: translateY(-1px) rotate(1deg) scale(1.06);
}

48% {
opacity: 0.82;
transform: translateY(0) rotate(-1deg) scale(1);
}

72% {
opacity: 0.96;
transform: translateY(-1px) rotate(2deg) scale(1.04);
}
}

@media (prefers-reduced-motion: reduce) {
.trending-fire {
animation: none;
opacity: 1;
transform: none;
}
}

/* Editorial atmosphere: low-chroma light at section edges, quiet at the center.
Each band selects palette, geometry, and intensity. The gradients are static,
paint-contained, and never use filter blur. */
Expand Down
4 changes: 2 additions & 2 deletions app/components/SkillTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,12 @@ function updatedLabel(skill: SkillRow): string {
<span class="skill-table__skill">
<span class="truncate font-mono text-sm">/{{ skill.name }}</span>
<!--
The flame is decorative, so the meaning goes in text a screen
The spark is decorative, so the meaning goes in text a screen
reader can reach. Without it the row says nothing about why this
skill differs from the one above it.
-->
<span v-if="isTrending(skill)" class="shrink-0">
<span class="trending-fire" aria-hidden="true">πŸ”₯</span>
<TrendingMark />
<span class="sr-only">Trending</span>
</span>
</span>
Expand Down
39 changes: 39 additions & 0 deletions app/components/TrendingMark.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { SPARK_BARS } from '#shared/braille-spark'

/**
* A rising braille spark that marks "trending" beside a label.
*
* The bars are fixed: the mark says "trending", not how much. The newest bar is
* rose, as on every braille spark, unless the surface already spends its rose
* elsewhere: the header's rose is the logo dot. Decorative, so the label
* beside it carries the meaning for a screen reader.
*/
const { accent = true } = defineProps<{
/** Ink the newest bar rose. Off in the header, where the logo dot holds the rose. */
accent?: boolean
}>()

const bars = SPARK_BARS.slice(1)
</script>

<template>
<span class="trending-mark" aria-hidden="true">{{ bars.slice(0, -1).join('') }}<span :class="accent && 'trending-mark__today'">{{ bars.at(-1) }}</span></span>
</template>

<style scoped>
/* IBM Plex Mono has no braille, so the bars fall through to a face that does. */
.trending-mark {
display: inline-block;
flex: none;
font-family: 'IBM Plex Mono', 'DejaVu Sans Mono', 'Apple Braille', 'Segoe UI Symbol', ui-monospace, monospace;
font-size: 0.875rem;
line-height: 1;
letter-spacing: 0.04em;
color: var(--ui-text-dimmed);
}

.trending-mark__today {
color: var(--brand-dot);
}
</style>
46 changes: 39 additions & 7 deletions layers/marketing/app/components/TrendingBoardItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ const emit = defineEmits<{
}>()

const posts = computed(() => (row.reason._tag === 'posts' ? row.reason.posts : []))

/**
* Each row draws the signal that ranked it, and only that one. Mentions ranked
* a row with posts, so it gets the braille spark. Stars ranked a surge or a
* filler row, so those keep the star line. Two charts on one line would ask the
* reader which one put the row here.
*
* A month board ranks posts older than the spark's seven days. A week of
* blank cells draws nothing beside a bare zero, so that row shows no spark and
* its posts carry their own dates.
*/
const isSocial = computed(() => row.reason._tag === 'posts')
const mentions = computed(() => {
const days = row.reason._tag === 'posts' ? row.reason.mentionsByDay : null
return days?.some(count => count > 0) ? days : null
})
</script>

<template>
Expand Down Expand Up @@ -52,7 +68,8 @@ const posts = computed(() => (row.reason._tag === 'posts' ? row.reason.posts : [
>
<span v-if="row.subtitle" class="truncate">{{ row.subtitle }}</span>
<span v-if="row.stars" class="shrink-0 tabular-nums">{{ `${row.stars.toLocaleString()} β˜…` }}</span>
<TrendingStarSpark :points="row.starSeries" />
<BrailleSpark v-if="mentions" :counts="mentions" unit="mentions in 7 days" />
<TrendingStarSpark v-else-if="!isSocial" :points="row.starSeries" />
<template v-if="row.reason._tag === 'reviewed'">
<span class="shrink-0">{{ `${row.reason.skillCount.toLocaleString()} ${row.reason.skillCount === 1 ? 'skill' : 'skills'}` }}</span>
<span v-if="row.reason.updated" class="shrink-0">Updated {{ row.reason.updated }}</span>
Expand All @@ -63,13 +80,23 @@ const posts = computed(() => (row.reason._tag === 'posts' ? row.reason.posts : [
say it is filler, or a popular repository passes for a trending one.
-->
<span v-else-if="row.reason._tag === 'surge'" class="shrink-0 text-default">
{{ `+${row.reason.gain.toLocaleString()} stars this week` }}<template v-if="row.reason.when">, {{ row.reason.when }}</template>
{{ `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'" class="shrink-0">Popular on GitHub</span>
</p>
<p v-if="row.description" class="board-row__description">
{{ row.description }}
</p>
<!-- Only where the row stands for exactly one Skill; see `singleSkill`. -->
<RunChip
v-if="row.skill"
class="board-row__run"
:owner="row.skill.owner"
:repo="row.skill.repo"
:skill="row.skill.name"
surface="trending-row"
variant="compact"
/>
</div>

<TrendingPostCarousel
Expand Down Expand Up @@ -103,9 +130,9 @@ const posts = computed(() => (row.reason._tag === 'posts' ? row.reason.posts : [
}

/*
* Printed in the dot grid and inked rose, the brand numerals, at a size where
* the dots read as a number. At 18px and half opacity the old rank read as
* texture, on a page whose whole structure is an order.
* Printed in the dot grid, at a size where the dots read as a number. Stone,
* not rose: stone dots are the noise, and the rose belongs to the motifs
* beside it, one dot each.
*/
.board-row__rank {
grid-row: 1 / span 2;
Expand All @@ -116,8 +143,8 @@ const posts = computed(() => (row.reason._tag === 'posts' ? row.reason.posts : [
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
color: var(--ui-primary);
opacity: 0.55;
color: var(--ui-text);
opacity: 0.45;
mask-image: radial-gradient(circle, #000 1.1px, transparent 1.4px);
mask-size: 3px 3px;
}
Expand Down Expand Up @@ -154,6 +181,11 @@ const posts = computed(() => (row.reason._tag === 'posts' ? row.reason.posts : [
color: var(--ui-text-muted);
}

.board-row__run {
max-inline-size: 30rem;
margin-top: 0.875rem;
}

.board-row__description {
display: -webkit-box;
margin-top: 0.625rem;
Expand Down
52 changes: 39 additions & 13 deletions layers/marketing/app/pages/skills/trending.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
monthStamp,
resolveTrendingPage,
resolveTrendingRange,
singleSkill,
TRENDING_BOARD_LIMIT,
TRENDING_RANGES,
trendingRangeDescription,
Expand Down Expand Up @@ -197,8 +198,11 @@ const board = computed<TrendingBoardRow[]>(() => {
// existed, for the five minutes one can outlive a deploy.
starSeries: s.starSeries ?? [],
names: [s.name, s.canonicalName],
// A post or a single-Skill surge put this exact Skill here, so the
// 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) }
? { _tag: 'posts', posts: [s.evidence, ...(s.morePosts ?? [])].map(toPost), mentionsByDay: s.mentionsByDay ?? null }
: s.starGain !== null
? { _tag: 'surge', gain: s.starGain, when: s.starGainDay ? relativeDay(s.starGainDay, clock.value) : null }
: { _tag: 'filler' },
Expand All @@ -215,6 +219,7 @@ const board = computed<TrendingBoardRow[]>(() => {
stars: s.stars,
starSeries: s.starSeries ?? [],
names: [s.name, s.canonicalName],
skill: singleSkill(s.owner, s.repo, s.name, s.repoSkillCount ?? 0),
reason: { _tag: 'filler' },
})),
].slice(0, BOARD_LIMIT)
Expand Down Expand Up @@ -419,12 +424,21 @@ const boardChunks = computed<BoardChunk[]>(() => [
`invisible` already hides it from assistive tech and the tab order, so
it carries no `aria-hidden`, which HTML validation rejects over a link.
-->
<template v-if="showWeeklyCta" #aside>
<div
class="trending-cta trending-cta--aside"
:class="{ invisible: auth._tag === 'pending' }"
>
<TrendingWeeklyCta layout="row" />
<template #aside>
<div class="trending-aside">
<div
v-if="showWeeklyCta"
class="trending-cta trending-cta--aside"
:class="{ invisible: auth._tag === 'pending' }"
>
<TrendingWeeklyCta layout="row" />
</div>
<div class="trending-teach">
<p class="font-mono text-xs text-muted">
Teach your agent skilld
</p>
<SkilldInstallChip surface="trending-header" />
</div>
</div>
</template>
</CompactPageHeader>
Expand Down Expand Up @@ -475,6 +489,10 @@ const boardChunks = computed<BoardChunk[]>(() => [
</div>

<div v-else-if="isEmpty" class="editorial-state mt-6 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.
Expand Down Expand Up @@ -773,18 +791,26 @@ const boardChunks = computed<BoardChunk[]>(() => [
}

/*
* Below 64rem the invitation sits after row five, so the header's aside is
* empty. Its wrapper still took a grid gap, 24px of nothing above the fold.
* The skilld install sits under the invitation on wide screens and alone under
* the heading on narrow ones, where the invitation waits until row five.
*/
@media (max-width: 63.999rem) {
.compact-page-header :deep(.compact-page-header__aside) {
display: none;
}
.trending-aside {
display: grid;
gap: 1rem;
}

.trending-teach {
display: grid;
gap: 0.5rem;
min-inline-size: 0;
}

@media (min-width: 64rem) {
.trending-cta--aside {
display: block;
}

.trending-aside {
inline-size: 26rem;
}

Expand Down
10 changes: 10 additions & 0 deletions server/api/feed/trending.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export interface FallbackFeedItem {
description: string | null
stars: number
starsGained: number | null
/** Skills in the repository. The board prints a run command only when it is 1, because the pick is arbitrary otherwise. */
repoSkillCount: number
/** Daily star totals across the window, oldest first, for the sparkline. */
starSeries: StarPoint[]
}
Expand Down Expand Up @@ -123,6 +125,12 @@ export interface TrendingSkillFeedItem {
* first. The board scrolls through them after the quoted one.
*/
morePosts: TrendingPostFeedItem[]
/**
* Counted mentions per rolling 24-hour day across the last seven, oldest
* first, for the braille spark. Null on a star-only skill, which has no
* mentions to draw.
*/
mentionsByDay: number[] | null
/** Daily star totals across the window, oldest first, for the sparkline. */
starSeries: StarPoint[]
}
Expand Down Expand Up @@ -174,6 +182,7 @@ function toSkillItem(entry: TrendingSkill, starSeries: StarPoint[]): TrendingSki
starGainDay: entry.github?.observedDay ?? null,
evidence: entry.evidence ? toPostItem(entry.evidence) : null,
morePosts: entry.morePosts.map(toPostItem),
mentionsByDay: entry.mentionsByDay,
starSeries,
}
}
Expand Down Expand Up @@ -210,6 +219,7 @@ function toFallbackItem(entry: FallbackSkill, starSeries: StarPoint[]): Fallback
description: entry.description,
stars: entry.stars,
starsGained: entry.starsGained,
repoSkillCount: entry.repoSkillCount,
starSeries,
}
}
Expand Down
31 changes: 31 additions & 0 deletions shared/mention-days.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Mentions per day for the braille spark on a trending row.
*
* Days are rolling 24-hour windows that end at the board's clock, so the last
* bar is the past 24 hours. Calendar days would make the last bar a partial
* day, and every Skill would seem to fade each morning in UTC.
*
* Imports nothing, so the weekly email and a script can load it too.
*/

const DAY = 86_400

/** Days in one spark, oldest first. Matches `SPARK_DAYS` in `braille-spark.ts`. */
export const MENTION_DAYS = 7

/**
* Count timestamps into seven rolling days, oldest first, the last 24 hours last.
*
* A timestamp after `now` counts as the last day: a post cannot come from the
* future, so it is clock skew between the ingest and the board. A timestamp
* older than seven days counts nowhere.
*/
export function mentionsByDay(postedAt: readonly number[], now: number): number[] {
const days = Array.from<number>({ length: MENTION_DAYS }).fill(0)
for (const at of postedAt) {
const ago = Math.max(0, Math.floor((now - at) / DAY))
if (ago < MENTION_DAYS)
days[MENTION_DAYS - 1 - ago]! += 1
}
return days
}
Loading