Skip to content

Commit 133e3c1

Browse files
authored
feat(trending): use the /skills shell with a range sidebar (#433)
* feat(trending): use the /skills shell with a range sidebar Harlan asked for the trending board to run full width like /skills, with the week, month, and all time switch in a left sidebar and no right aside. The sidebar carries the range links, the weekly CTA, and the skilld install on wide screens. Narrow screens keep the range row above the board and the CTA after the fifth row. Rows now switch on their own width, so the posts sit beside the Skill only when both fit, and the carousel shows two posts where it has room. "Earlier on this board" stays under the board. It lists Skills that left the ranking, not earlier weeks, and it is the crawl path to every Skill the trending sitemap lists. * style(trending): indent the reason comment inside the row wrapper
1 parent e7292cc commit 133e3c1

6 files changed

Lines changed: 485 additions & 365 deletions

File tree

‎DESIGN.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,3 +458,4 @@ These are editorial sets, not necessarily database collections yet. If a set has
458458
- **The lockup dot is an OG card's one rose element**: the 4px rose top bar, the rose quote rails and the rose avatar ring went stone. On the Splash card, the braille field radiates from the lockup's dot, so the texture spends no second rose. Confirmed 2026-10-06.
459459
- **The braille spark replaces the trending flame**: seven braille bars for seven days of mentions, today in rose. The emoji exception for Trending is retired. The spark keeps the sourced mention count beside it, which the flame never carried. Confirmed 2026-10-06.
460460
- **Run and install read as different materials**: run is a dashed border with a hollow dot, because nothing stays; install is a solid border with a filled dot, because files land. Run is preselected. Confirmed 2026-10-06.
461+
- **Trending shares the /skills shell**: `/skills/trending` uses the full-width layout from `/skills`. Its heading replaces `CompactPageHeader` and lines up with the results column. A sticky 14rem sidebar holds the range links, then the weekly CTA and the skilld install. Nothing sits right of the board, so the board takes every column the sidebar leaves. Rows switch on their own width, and the post carousel shows two cards where it has room. On narrow screens the ranges are a compact row above the board, and the CTA stays after the fifth row. Harlan asked for this on 2026-10-06. It supersedes the 2026-10-01 call that dropped the 9/3 sidebar for taking a quarter of the board's width: that sidebar sat right of a 64rem board, and this one sits left of a board that runs the full page width.

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

Lines changed: 80 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -43,73 +43,84 @@ const mentions = computed(() => {
4343
</script>
4444

4545
<template>
46-
<div class="board-row" :class="{ 'board-row--with-posts': posts.length > 0 }">
47-
<!-- The list is an `<ol>`, so a screen reader already announces the position. -->
48-
<span
49-
class="board-row__rank"
50-
:class="{ 'board-row__rank--lead': rank <= 3 }"
51-
aria-hidden="true"
52-
>{{ String(rank).padStart(2, '0') }}</span>
53-
54-
<div class="board-row__skill">
55-
<NuxtLink :to="row.to" class="board-row__name">
56-
{{ row.title }}
57-
</NuxtLink>
58-
<p class="board-row__meta">
59-
<img
60-
:src="githubAvatarProxyUrl(row.owner, 40)"
61-
alt=""
62-
width="18"
63-
height="18"
64-
class="size-[1.125rem] shrink-0 rounded-full border border-default bg-muted"
65-
loading="lazy"
66-
decoding="async"
67-
@error="emit('avatarError', row.owner)"
68-
>
69-
<span v-if="row.subtitle" class="truncate">{{ row.subtitle }}</span>
70-
<span v-if="row.stars" class="shrink-0 tabular-nums">{{ `${row.stars.toLocaleString()} ★` }}</span>
71-
<BrailleSpark v-if="mentions" :counts="mentions" unit="mentions in 7 days" />
72-
<TrendingStarSpark v-else-if="!isSocial" :points="row.starSeries" />
73-
<template v-if="row.reason._tag === 'reviewed'">
74-
<span class="shrink-0">{{ `${row.reason.skillCount.toLocaleString()} ${row.reason.skillCount === 1 ? 'skill' : 'skills'}` }}</span>
75-
<span v-if="row.reason.updated" class="shrink-0">Updated {{ row.reason.updated }}</span>
76-
</template>
77-
<!--
78-
The two reasons with no post say why the row is here in words, since
79-
nothing beside them does. A surge has only its stars; filler has to
80-
say it is filler, or a popular repository passes for a trending one.
81-
-->
82-
<span v-else-if="row.reason._tag === 'surge'" class="shrink-0 text-default">
83-
{{ `Star surge: +${row.reason.gain.toLocaleString()} stars in a day` }}<template v-if="row.reason.when">, {{ row.reason.when }}</template>
84-
</span>
85-
<span v-else-if="row.reason._tag === 'filler'" class="shrink-0">Popular on GitHub</span>
86-
</p>
87-
<p v-if="row.description" class="board-row__description">
88-
{{ row.description }}
89-
</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"
46+
<div class="board-item">
47+
<div class="board-row" :class="{ 'board-row--with-posts': posts.length > 0 }">
48+
<!-- The list is an `<ol>`, so a screen reader already announces the position. -->
49+
<span
50+
class="board-row__rank"
51+
:class="{ 'board-row__rank--lead': rank <= 3 }"
52+
aria-hidden="true"
53+
>{{ String(rank).padStart(2, '0') }}</span>
54+
55+
<div class="board-row__skill">
56+
<NuxtLink :to="row.to" class="board-row__name">
57+
{{ row.title }}
58+
</NuxtLink>
59+
<p class="board-row__meta">
60+
<img
61+
:src="githubAvatarProxyUrl(row.owner, 40)"
62+
alt=""
63+
width="18"
64+
height="18"
65+
class="size-[1.125rem] shrink-0 rounded-full border border-default bg-muted"
66+
loading="lazy"
67+
decoding="async"
68+
@error="emit('avatarError', row.owner)"
69+
>
70+
<span v-if="row.subtitle" class="truncate">{{ row.subtitle }}</span>
71+
<span v-if="row.stars" class="shrink-0 tabular-nums">{{ `${row.stars.toLocaleString()} ★` }}</span>
72+
<BrailleSpark v-if="mentions" :counts="mentions" unit="mentions in 7 days" />
73+
<TrendingStarSpark v-else-if="!isSocial" :points="row.starSeries" />
74+
<template v-if="row.reason._tag === 'reviewed'">
75+
<span class="shrink-0">{{ `${row.reason.skillCount.toLocaleString()} ${row.reason.skillCount === 1 ? 'skill' : 'skills'}` }}</span>
76+
<span v-if="row.reason.updated" class="shrink-0">Updated {{ row.reason.updated }}</span>
77+
</template>
78+
<!--
79+
The two reasons with no post say why the row is here in words, since
80+
nothing beside them does. A surge has only its stars; filler has to
81+
say it is filler, or a popular repository passes for a trending one.
82+
-->
83+
<span v-else-if="row.reason._tag === 'surge'" class="shrink-0 text-default">
84+
{{ `Star surge: +${row.reason.gain.toLocaleString()} stars in a day` }}<template v-if="row.reason.when">, {{ row.reason.when }}</template>
85+
</span>
86+
<span v-else-if="row.reason._tag === 'filler'" class="shrink-0">Popular on GitHub</span>
87+
</p>
88+
<p v-if="row.description" class="board-row__description">
89+
{{ row.description }}
90+
</p>
91+
<!-- Only where the row stands for exactly one Skill; see `singleSkill`. -->
92+
<RunChip
93+
v-if="row.skill"
94+
class="board-row__run"
95+
:owner="row.skill.owner"
96+
:repo="row.skill.repo"
97+
:skill="row.skill.name"
98+
surface="trending-row"
99+
variant="compact"
100+
/>
101+
</div>
102+
103+
<TrendingPostCarousel
104+
v-if="posts.length"
105+
class="board-row__posts"
106+
:posts="posts"
107+
:names="row.names"
108+
:label="`Posts about ${row.title}`"
99109
/>
100110
</div>
101-
102-
<TrendingPostCarousel
103-
v-if="posts.length"
104-
class="board-row__posts"
105-
:posts="posts"
106-
:names="row.names"
107-
:label="`Posts about ${row.title}`"
108-
/>
109111
</div>
110112
</template>
111113

112114
<style scoped>
115+
/*
116+
* The row answers to the width it gets, not the viewport's. The board shares
117+
* the page with a sidebar on wide screens, so a viewport breakpoint put the
118+
* posts beside the Skill in a column too narrow for either.
119+
*/
120+
.board-item {
121+
container: board-item / inline-size;
122+
}
123+
113124
/*
114125
* Narrow first: the rank sits in a gutter and the posts stack under the
115126
* Skill, so the Skill name, never a post, is the first thing under each rank.
@@ -186,8 +197,10 @@ const mentions = computed(() => {
186197
margin-top: 0.875rem;
187198
}
188199
200+
/* Capped for rows with no posts, which otherwise run the whole board wide. */
189201
.board-row__description {
190202
display: -webkit-box;
203+
max-inline-size: 75ch;
191204
margin-top: 0.625rem;
192205
overflow: hidden;
193206
font-size: 0.9375rem;
@@ -198,14 +211,18 @@ const mentions = computed(() => {
198211
line-clamp: 2;
199212
}
200213
201-
@media (min-width: 64rem) {
214+
/*
215+
* Beside the Skill once the row is wide enough for both. The posts take what
216+
* the board has to spare, and the Skill column stays the wider one.
217+
*/
218+
@container board-item (min-width: 52rem) {
202219
.board-row {
203220
grid-template-columns: 3rem minmax(0, 1fr);
204221
column-gap: 2rem;
205222
}
206223
207224
.board-row--with-posts {
208-
grid-template-columns: 3rem minmax(0, 1fr) minmax(0, 21rem);
225+
grid-template-columns: 3rem minmax(0, 1.25fr) minmax(21rem, 1fr);
209226
}
210227
211228
.board-row__rank {

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import TrendingPostCard from './TrendingPostCard.vue'
1313
* it, and focus on a card scrolls that card into view.
1414
*
1515
* The next card peeks in from the edge, which is what says there is more.
16+
* Where the carousel is wide enough, two cards show at once.
1617
*/
1718
const { posts, names, label } = defineProps<{
1819
posts: readonly TrendingPost[]
@@ -23,7 +24,7 @@ const { posts, names, label } = defineProps<{
2324
}>()
2425
2526
const track = useTemplateRef<HTMLElement>('track')
26-
const { x } = useScroll(track)
27+
const { x, arrivedState } = useScroll(track)
2728
const motion = usePreferredReducedMotion()
2829
2930
/** Distance from one card to the next: its width plus the gap. */
@@ -80,7 +81,7 @@ function move(by: number) {
8081
square
8182
aria-label="Next post"
8283
class="post-carousel__button"
83-
:disabled="current >= posts.length - 1"
84+
:disabled="current >= posts.length - 1 || arrivedState.right"
8485
@click="() => move(1)"
8586
/>
8687
</div>
@@ -89,6 +90,7 @@ function move(by: number) {
8990

9091
<style scoped>
9192
.post-carousel {
93+
container-type: inline-size;
9294
min-inline-size: 0;
9395
}
9496
@@ -139,6 +141,22 @@ function move(by: number) {
139141
color: var(--ui-text-muted);
140142
}
141143
144+
/*
145+
* Two cards at a time once a card would otherwise stretch past a readable
146+
* line. They grow to fill the track when only one or two exist, and a third
147+
* still peeks in from the edge. Two posts side by side leave nothing to step
148+
* through, so they show no controls.
149+
*/
150+
@container (min-width: 36rem) {
151+
.post-carousel__slide {
152+
flex: 1 0 46%;
153+
}
154+
155+
.post-carousel:has(.post-carousel__slide:nth-child(2):last-child) .post-carousel__nav {
156+
display: none;
157+
}
158+
}
159+
142160
/* A touch screen swipes the track, so only a pointer gets the buttons. */
143161
@media (hover: none) {
144162
.post-carousel__button {

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

Lines changed: 23 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,10 @@ import { useElementVisibility } from '@vueuse/core'
88
* The preview stays inert because it is supporting artwork. The CTA beside it
99
* is the only promised action.
1010
*
11-
* A plain block, not an `<aside>`: the board renders it twice, beside the page
12-
* heading and between the board's halves, and one of them is always hidden.
13-
* Two complementary landmarks with the same name failed HTML validation.
11+
* A plain block, not an `<aside>`: the board renders it twice, in the sidebar
12+
* and between the board's halves, and one of them is always hidden. Two
13+
* complementary landmarks with the same name failed HTML validation.
1414
*/
15-
const { layout = 'stacked' } = defineProps<{
16-
/** `row` sets the preview beside the copy, for the page heading. */
17-
layout?: 'stacked' | 'row'
18-
}>()
1915
2016
/**
2117
* The email loads in the browser, and only for the copy a reader can see.
@@ -54,7 +50,7 @@ const previewDocument = computed(() => card.value
5450
</script>
5551

5652
<template>
57-
<div class="trending-weekly-cta" :class="`trending-weekly-cta--${layout}`">
53+
<div class="trending-weekly-cta">
5854
<div
5955
v-if="keepPreviewBox"
6056
ref="preview"
@@ -94,6 +90,7 @@ const previewDocument = computed(() => card.value
9490

9591
<style scoped>
9692
.trending-weekly-cta {
93+
container-type: inline-size;
9794
min-inline-size: 0;
9895
overflow: clip;
9996
border: 1px solid var(--ui-border);
@@ -133,44 +130,28 @@ const previewDocument = computed(() => card.value
133130
}
134131
135132
/*
136-
* Beside the page heading: the email as a thumbnail, the copy beside it, and
137-
* no taller than the heading it sits next to. Rendered at full email width and
138-
* scaled to a quarter, since the picture only has to read as an inbox.
133+
* The sidebar column is narrower than any phone. The copy tightens there so
134+
* the button keeps its whole label on one line rather than truncating it.
139135
*/
140-
.trending-weekly-cta--row {
141-
display: grid;
142-
grid-template-columns: 8.5rem minmax(0, 1fr);
143-
}
144-
145-
/* Out of flow, so the copy alone sets the height; a transform never shrinks layout. */
146-
.trending-weekly-cta--row .trending-weekly-preview {
147-
position: relative;
148-
block-size: auto;
149-
border-block-end: 0;
150-
border-inline-end: 1px solid var(--ui-border);
151-
}
152-
153-
.trending-weekly-cta--row .trending-weekly-frame {
154-
position: absolute;
155-
inset-block-start: 0;
156-
inset-inline-start: 0;
157-
inline-size: 400%;
158-
block-size: 44rem;
159-
transform: scale(0.25);
160-
}
136+
@container (max-width: 16rem) {
137+
.trending-weekly-copy {
138+
padding: 0.75rem;
139+
}
161140
162-
.trending-weekly-cta--row .trending-weekly-copy {
163-
display: flex;
164-
flex-direction: column;
165-
justify-content: space-between;
166-
}
141+
.trending-weekly-heading {
142+
font-size: 0.9375rem;
143+
}
167144
168-
.trending-weekly-cta--row .trending-weekly-heading {
169-
font-size: 0.9375rem;
170-
}
145+
.trending-weekly-action {
146+
gap: 0.375rem;
147+
padding-inline: 0.5rem;
148+
font-size: 0.75rem;
149+
}
171150
172-
.trending-weekly-cta--row .trending-weekly-action {
173-
margin-top: 0.75rem;
151+
.trending-weekly-action :deep(.iconify) {
152+
inline-size: 1rem;
153+
block-size: 1rem;
154+
}
174155
}
175156
176157
@media (forced-colors: active) {

0 commit comments

Comments
 (0)