Skip to content

Commit b17db34

Browse files
authored
feat(trending): put braille sparks and run chips on the board (#431)
* feat(trending): put braille sparks and run chips on the board The new brand puts the dot motifs where the registry's evidence lives. Trending is the board readers copy from, so each row now shows its signal and its command in the brand shapes. - Each row with posts draws seven days of counted mentions as a braille spark. The loader already reads every post in the window, so the counts come from the same rows that rank the Skill, in rolling 24-hour days. - Surge and filler rows keep the star line, because stars ranked them. The surge reason now says it was a star surge, as VISION principle 4 asks of every trending number. - A compact run chip appears only where the row stands for exactly one Skill. Filler and the all-time range pick a Skill out of a repository, so a multi-Skill repository gets no command. Copies still report. - The skilld install chip sits in the header aside at every width. - The nav and the directory table drop the flame emoji for a braille mark, and the flame animation goes with it. - Ranks print in stone dots so the rose stays with the motifs. * fix(trending): keep the header rose on the logo dot DESIGN.md keeps motifs out of navigation and gives each surface one rose element. The header's rose is the logo dot, so the nav mark draws in stone. A month board also ranks posts older than the spark's seven days, and seven blank cells beside a zero read as a broken row, so those rows show no spark and their posts carry the dates.
1 parent 45d0171 commit b17db34

14 files changed

Lines changed: 308 additions & 70 deletions

File tree

‎app/app.vue‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,11 @@ watch(() => route.fullPath, () => {
131131
<div class="flex items-center justify-end gap-1.5 lg:flex-1">
132132
<SkillSearchTrigger v-if="$route.path !== '/'" />
133133
<!--
134-
The flame is a deliberate exception to the "no emoji" rule in
135-
DESIGN.md, asked for so Trending outweighs its siblings.
134+
The braille mark makes Trending outweigh its siblings without an
135+
emoji. Stone only: the logo dot is the header's one rose element.
136136
It is decorative and hidden from screen readers; the label carries
137137
the meaning. `gap-1.5` because the button's own layout does not
138-
space sibling spans, which rendered "🔥Trending" run together.
138+
space sibling spans.
139139
-->
140140
<UButton
141141
to="/skills/trending"
@@ -144,7 +144,7 @@ watch(() => route.fullPath, () => {
144144
size="sm"
145145
class="hidden gap-1.5 whitespace-nowrap lg:inline-flex"
146146
>
147-
<span class="trending-fire" aria-hidden="true">🔥</span>
147+
<TrendingMark :accent="false" />
148148
<span>Trending Skills</span>
149149
</UButton>
150150
<UButton
@@ -204,7 +204,7 @@ watch(() => route.fullPath, () => {
204204
block
205205
class="min-h-11 justify-start gap-1.5 whitespace-nowrap"
206206
>
207-
<span class="trending-fire" aria-hidden="true">🔥</span>
207+
<TrendingMark :accent="false" />
208208
<span>Trending Skills</span>
209209
</UButton>
210210
<UButton

‎app/assets/css/main.css‎

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -217,43 +217,6 @@
217217
);
218218
}
219219

220-
/* Keep the trending signal lively without competing with surrounding chrome. */
221-
.trending-fire {
222-
display: inline-block;
223-
transform-origin: center 70%;
224-
animation: trending-fire-alive 1.8s ease-in-out infinite;
225-
}
226-
227-
@keyframes trending-fire-alive {
228-
0%, 100% {
229-
opacity: 0.72;
230-
transform: translateY(1px) rotate(-2deg) scale(0.96);
231-
}
232-
233-
24% {
234-
opacity: 1;
235-
transform: translateY(-1px) rotate(1deg) scale(1.06);
236-
}
237-
238-
48% {
239-
opacity: 0.82;
240-
transform: translateY(0) rotate(-1deg) scale(1);
241-
}
242-
243-
72% {
244-
opacity: 0.96;
245-
transform: translateY(-1px) rotate(2deg) scale(1.04);
246-
}
247-
}
248-
249-
@media (prefers-reduced-motion: reduce) {
250-
.trending-fire {
251-
animation: none;
252-
opacity: 1;
253-
transform: none;
254-
}
255-
}
256-
257220
/* Editorial atmosphere: low-chroma light at section edges, quiet at the center.
258221
Each band selects palette, geometry, and intensity. The gradients are static,
259222
paint-contained, and never use filter blur. */

‎app/components/SkillTable.vue‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,12 @@ function updatedLabel(skill: SkillRow): string {
102102
<span class="skill-table__skill">
103103
<span class="truncate font-mono text-sm">/{{ skill.name }}</span>
104104
<!--
105-
The flame is decorative, so the meaning goes in text a screen
105+
The spark is decorative, so the meaning goes in text a screen
106106
reader can reach. Without it the row says nothing about why this
107107
skill differs from the one above it.
108108
-->
109109
<span v-if="isTrending(skill)" class="shrink-0">
110-
<span class="trending-fire" aria-hidden="true">🔥</span>
110+
<TrendingMark />
111111
<span class="sr-only">Trending</span>
112112
</span>
113113
</span>

‎app/components/TrendingMark.vue‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<script setup lang="ts">
2+
import { SPARK_BARS } from '#shared/braille-spark'
3+
4+
/**
5+
* A rising braille spark that marks "trending" beside a label.
6+
*
7+
* The bars are fixed: the mark says "trending", not how much. The newest bar is
8+
* rose, as on every braille spark, unless the surface already spends its rose
9+
* elsewhere: the header's rose is the logo dot. Decorative, so the label
10+
* beside it carries the meaning for a screen reader.
11+
*/
12+
const { accent = true } = defineProps<{
13+
/** Ink the newest bar rose. Off in the header, where the logo dot holds the rose. */
14+
accent?: boolean
15+
}>()
16+
17+
const bars = SPARK_BARS.slice(1)
18+
</script>
19+
20+
<template>
21+
<span class="trending-mark" aria-hidden="true">{{ bars.slice(0, -1).join('') }}<span :class="accent && 'trending-mark__today'">{{ bars.at(-1) }}</span></span>
22+
</template>
23+
24+
<style scoped>
25+
/* IBM Plex Mono has no braille, so the bars fall through to a face that does. */
26+
.trending-mark {
27+
display: inline-block;
28+
flex: none;
29+
font-family: 'IBM Plex Mono', 'DejaVu Sans Mono', 'Apple Braille', 'Segoe UI Symbol', ui-monospace, monospace;
30+
font-size: 0.875rem;
31+
line-height: 1;
32+
letter-spacing: 0.04em;
33+
color: var(--ui-text-dimmed);
34+
}
35+
36+
.trending-mark__today {
37+
color: var(--brand-dot);
38+
}
39+
</style>

‎layers/marketing/app/components/TrendingBoardItem.vue‎

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ const emit = defineEmits<{
2424
}>()
2525
2626
const posts = computed(() => (row.reason._tag === 'posts' ? row.reason.posts : []))
27+
28+
/**
29+
* Each row draws the signal that ranked it, and only that one. Mentions ranked
30+
* a row with posts, so it gets the braille spark. Stars ranked a surge or a
31+
* filler row, so those keep the star line. Two charts on one line would ask the
32+
* reader which one put the row here.
33+
*
34+
* A month board ranks posts older than the spark's seven days. A week of
35+
* blank cells draws nothing beside a bare zero, so that row shows no spark and
36+
* its posts carry their own dates.
37+
*/
38+
const isSocial = computed(() => row.reason._tag === 'posts')
39+
const mentions = computed(() => {
40+
const days = row.reason._tag === 'posts' ? row.reason.mentionsByDay : null
41+
return days?.some(count => count > 0) ? days : null
42+
})
2743
</script>
2844

2945
<template>
@@ -52,7 +68,8 @@ const posts = computed(() => (row.reason._tag === 'posts' ? row.reason.posts : [
5268
>
5369
<span v-if="row.subtitle" class="truncate">{{ row.subtitle }}</span>
5470
<span v-if="row.stars" class="shrink-0 tabular-nums">{{ `${row.stars.toLocaleString()} ★` }}</span>
55-
<TrendingStarSpark :points="row.starSeries" />
71+
<BrailleSpark v-if="mentions" :counts="mentions" unit="mentions in 7 days" />
72+
<TrendingStarSpark v-else-if="!isSocial" :points="row.starSeries" />
5673
<template v-if="row.reason._tag === 'reviewed'">
5774
<span class="shrink-0">{{ `${row.reason.skillCount.toLocaleString()} ${row.reason.skillCount === 1 ? 'skill' : 'skills'}` }}</span>
5875
<span v-if="row.reason.updated" class="shrink-0">Updated {{ row.reason.updated }}</span>
@@ -63,13 +80,23 @@ const posts = computed(() => (row.reason._tag === 'posts' ? row.reason.posts : [
6380
say it is filler, or a popular repository passes for a trending one.
6481
-->
6582
<span v-else-if="row.reason._tag === 'surge'" class="shrink-0 text-default">
66-
{{ `+${row.reason.gain.toLocaleString()} stars this week` }}<template v-if="row.reason.when">, {{ row.reason.when }}</template>
83+
{{ `Star surge: +${row.reason.gain.toLocaleString()} stars in a day` }}<template v-if="row.reason.when">, {{ row.reason.when }}</template>
6784
</span>
6885
<span v-else-if="row.reason._tag === 'filler'" class="shrink-0">Popular on GitHub</span>
6986
</p>
7087
<p v-if="row.description" class="board-row__description">
7188
{{ row.description }}
7289
</p>
90+
<!-- Only where the row stands for exactly one Skill; see `singleSkill`. -->
91+
<RunChip
92+
v-if="row.skill"
93+
class="board-row__run"
94+
:owner="row.skill.owner"
95+
:repo="row.skill.repo"
96+
:skill="row.skill.name"
97+
surface="trending-row"
98+
variant="compact"
99+
/>
73100
</div>
74101

75102
<TrendingPostCarousel
@@ -103,9 +130,9 @@ const posts = computed(() => (row.reason._tag === 'posts' ? row.reason.posts : [
103130
}
104131
105132
/*
106-
* Printed in the dot grid and inked rose, the brand numerals, at a size where
107-
* the dots read as a number. At 18px and half opacity the old rank read as
108-
* texture, on a page whose whole structure is an order.
133+
* Printed in the dot grid, at a size where the dots read as a number. Stone,
134+
* not rose: stone dots are the noise, and the rose belongs to the motifs
135+
* beside it, one dot each.
109136
*/
110137
.board-row__rank {
111138
grid-row: 1 / span 2;
@@ -116,8 +143,8 @@ const posts = computed(() => (row.reason._tag === 'posts' ? row.reason.posts : [
116143
line-height: 1;
117144
letter-spacing: -0.02em;
118145
font-variant-numeric: tabular-nums;
119-
color: var(--ui-primary);
120-
opacity: 0.55;
146+
color: var(--ui-text);
147+
opacity: 0.45;
121148
mask-image: radial-gradient(circle, #000 1.1px, transparent 1.4px);
122149
mask-size: 3px 3px;
123150
}
@@ -154,6 +181,11 @@ const posts = computed(() => (row.reason._tag === 'posts' ? row.reason.posts : [
154181
color: var(--ui-text-muted);
155182
}
156183
184+
.board-row__run {
185+
max-inline-size: 30rem;
186+
margin-top: 0.875rem;
187+
}
188+
157189
.board-row__description {
158190
display: -webkit-box;
159191
margin-top: 0.625rem;

‎layers/marketing/app/pages/skills/trending.vue‎

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
monthStamp,
1414
resolveTrendingPage,
1515
resolveTrendingRange,
16+
singleSkill,
1617
TRENDING_BOARD_LIMIT,
1718
TRENDING_RANGES,
1819
trendingRangeDescription,
@@ -197,8 +198,11 @@ const board = computed<TrendingBoardRow[]>(() => {
197198
// existed, for the five minutes one can outlive a deploy.
198199
starSeries: s.starSeries ?? [],
199200
names: [s.name, s.canonicalName],
201+
// A post or a single-Skill surge put this exact Skill here, so the
202+
// run command never guesses.
203+
skill: { owner: s.owner, repo: s.repo, name: s.name },
200204
reason: s.evidence
201-
? { _tag: 'posts', posts: [s.evidence, ...(s.morePosts ?? [])].map(toPost) }
205+
? { _tag: 'posts', posts: [s.evidence, ...(s.morePosts ?? [])].map(toPost), mentionsByDay: s.mentionsByDay ?? null }
202206
: s.starGain !== null
203207
? { _tag: 'surge', gain: s.starGain, when: s.starGainDay ? relativeDay(s.starGainDay, clock.value) : null }
204208
: { _tag: 'filler' },
@@ -215,6 +219,7 @@ const board = computed<TrendingBoardRow[]>(() => {
215219
stars: s.stars,
216220
starSeries: s.starSeries ?? [],
217221
names: [s.name, s.canonicalName],
222+
skill: singleSkill(s.owner, s.repo, s.name, s.repoSkillCount ?? 0),
218223
reason: { _tag: 'filler' },
219224
})),
220225
].slice(0, BOARD_LIMIT)
@@ -419,12 +424,21 @@ const boardChunks = computed<BoardChunk[]>(() => [
419424
`invisible` already hides it from assistive tech and the tab order, so
420425
it carries no `aria-hidden`, which HTML validation rejects over a link.
421426
-->
422-
<template v-if="showWeeklyCta" #aside>
423-
<div
424-
class="trending-cta trending-cta--aside"
425-
:class="{ invisible: auth._tag === 'pending' }"
426-
>
427-
<TrendingWeeklyCta layout="row" />
427+
<template #aside>
428+
<div class="trending-aside">
429+
<div
430+
v-if="showWeeklyCta"
431+
class="trending-cta trending-cta--aside"
432+
:class="{ invisible: auth._tag === 'pending' }"
433+
>
434+
<TrendingWeeklyCta layout="row" />
435+
</div>
436+
<div class="trending-teach">
437+
<p class="font-mono text-xs text-muted">
438+
Teach your agent skilld
439+
</p>
440+
<SkilldInstallChip surface="trending-header" />
441+
</div>
428442
</div>
429443
</template>
430444
</CompactPageHeader>
@@ -475,6 +489,10 @@ const boardChunks = computed<BoardChunk[]>(() => [
475489
</div>
476490

477491
<div v-else-if="isEmpty" class="editorial-state mt-6 flex flex-col justify-center" role="status">
492+
<!-- Scattered dots that settle on one rose dot: the board is waiting for its first Skill. -->
493+
<div class="relative mb-4 h-10">
494+
<TextureConverge />
495+
</div>
478496
<template v-if="range === 'all'">
479497
<p class="text-sm text-default">
480498
No repositories have qualified yet.
@@ -773,18 +791,26 @@ const boardChunks = computed<BoardChunk[]>(() => [
773791
}
774792
775793
/*
776-
* Below 64rem the invitation sits after row five, so the header's aside is
777-
* empty. Its wrapper still took a grid gap, 24px of nothing above the fold.
794+
* The skilld install sits under the invitation on wide screens and alone under
795+
* the heading on narrow ones, where the invitation waits until row five.
778796
*/
779-
@media (max-width: 63.999rem) {
780-
.compact-page-header :deep(.compact-page-header__aside) {
781-
display: none;
782-
}
797+
.trending-aside {
798+
display: grid;
799+
gap: 1rem;
800+
}
801+
802+
.trending-teach {
803+
display: grid;
804+
gap: 0.5rem;
805+
min-inline-size: 0;
783806
}
784807
785808
@media (min-width: 64rem) {
786809
.trending-cta--aside {
787810
display: block;
811+
}
812+
813+
.trending-aside {
788814
inline-size: 26rem;
789815
}
790816

‎server/api/feed/trending.get.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export interface FallbackFeedItem {
6060
description: string | null
6161
stars: number
6262
starsGained: number | null
63+
/** Skills in the repository. The board prints a run command only when it is 1, because the pick is arbitrary otherwise. */
64+
repoSkillCount: number
6365
/** Daily star totals across the window, oldest first, for the sparkline. */
6466
starSeries: StarPoint[]
6567
}
@@ -123,6 +125,12 @@ export interface TrendingSkillFeedItem {
123125
* first. The board scrolls through them after the quoted one.
124126
*/
125127
morePosts: TrendingPostFeedItem[]
128+
/**
129+
* Counted mentions per rolling 24-hour day across the last seven, oldest
130+
* first, for the braille spark. Null on a star-only skill, which has no
131+
* mentions to draw.
132+
*/
133+
mentionsByDay: number[] | null
126134
/** Daily star totals across the window, oldest first, for the sparkline. */
127135
starSeries: StarPoint[]
128136
}
@@ -174,6 +182,7 @@ function toSkillItem(entry: TrendingSkill, starSeries: StarPoint[]): TrendingSki
174182
starGainDay: entry.github?.observedDay ?? null,
175183
evidence: entry.evidence ? toPostItem(entry.evidence) : null,
176184
morePosts: entry.morePosts.map(toPostItem),
185+
mentionsByDay: entry.mentionsByDay,
177186
starSeries,
178187
}
179188
}
@@ -210,6 +219,7 @@ function toFallbackItem(entry: FallbackSkill, starSeries: StarPoint[]): Fallback
210219
description: entry.description,
211220
stars: entry.stars,
212221
starsGained: entry.starsGained,
222+
repoSkillCount: entry.repoSkillCount,
213223
starSeries,
214224
}
215225
}

‎shared/mention-days.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Mentions per day for the braille spark on a trending row.
3+
*
4+
* Days are rolling 24-hour windows that end at the board's clock, so the last
5+
* bar is the past 24 hours. Calendar days would make the last bar a partial
6+
* day, and every Skill would seem to fade each morning in UTC.
7+
*
8+
* Imports nothing, so the weekly email and a script can load it too.
9+
*/
10+
11+
const DAY = 86_400
12+
13+
/** Days in one spark, oldest first. Matches `SPARK_DAYS` in `braille-spark.ts`. */
14+
export const MENTION_DAYS = 7
15+
16+
/**
17+
* Count timestamps into seven rolling days, oldest first, the last 24 hours last.
18+
*
19+
* A timestamp after `now` counts as the last day: a post cannot come from the
20+
* future, so it is clock skew between the ingest and the board. A timestamp
21+
* older than seven days counts nowhere.
22+
*/
23+
export function mentionsByDay(postedAt: readonly number[], now: number): number[] {
24+
const days = Array.from<number>({ length: MENTION_DAYS }).fill(0)
25+
for (const at of postedAt) {
26+
const ago = Math.max(0, Math.floor((now - at) / DAY))
27+
if (ago < MENTION_DAYS)
28+
days[MENTION_DAYS - 1 - ago]! += 1
29+
}
30+
return days
31+
}

0 commit comments

Comments
 (0)