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
1 change: 1 addition & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,3 +458,4 @@ These are editorial sets, not necessarily database collections yet. If a set has
- **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.
- **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.
- **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.
- **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.
143 changes: 80 additions & 63 deletions layers/marketing/app/components/TrendingBoardItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,73 +43,84 @@ const mentions = computed(() => {
</script>

<template>
<div class="board-row" :class="{ 'board-row--with-posts': posts.length > 0 }">
<!-- The list is an `<ol>`, so a screen reader already announces the position. -->
<span
class="board-row__rank"
:class="{ 'board-row__rank--lead': rank <= 3 }"
aria-hidden="true"
>{{ String(rank).padStart(2, '0') }}</span>

<div class="board-row__skill">
<NuxtLink :to="row.to" class="board-row__name">
{{ row.title }}
</NuxtLink>
<p class="board-row__meta">
<img
:src="githubAvatarProxyUrl(row.owner, 40)"
alt=""
width="18"
height="18"
class="size-[1.125rem] shrink-0 rounded-full border border-default bg-muted"
loading="lazy"
decoding="async"
@error="emit('avatarError', row.owner)"
>
<span v-if="row.subtitle" class="truncate">{{ row.subtitle }}</span>
<span v-if="row.stars" class="shrink-0 tabular-nums">{{ `${row.stars.toLocaleString()} β˜…` }}</span>
<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>
</template>
<!--
The two reasons with no post say why the row is here in words, since
nothing beside them does. A surge has only its stars; filler has to
say it is filler, or a popular repository passes for a trending one.
-->
<span v-else-if="row.reason._tag === 'surge'" class="shrink-0 text-default">
{{ `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 class="board-item">
<div class="board-row" :class="{ 'board-row--with-posts': posts.length > 0 }">
<!-- The list is an `<ol>`, so a screen reader already announces the position. -->
<span
class="board-row__rank"
:class="{ 'board-row__rank--lead': rank <= 3 }"
aria-hidden="true"
>{{ String(rank).padStart(2, '0') }}</span>

<div class="board-row__skill">
<NuxtLink :to="row.to" class="board-row__name">
{{ row.title }}
</NuxtLink>
<p class="board-row__meta">
<img
:src="githubAvatarProxyUrl(row.owner, 40)"
alt=""
width="18"
height="18"
class="size-[1.125rem] shrink-0 rounded-full border border-default bg-muted"
loading="lazy"
decoding="async"
@error="emit('avatarError', row.owner)"
>
<span v-if="row.subtitle" class="truncate">{{ row.subtitle }}</span>
<span v-if="row.stars" class="shrink-0 tabular-nums">{{ `${row.stars.toLocaleString()} β˜…` }}</span>
<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>
</template>
<!--
The two reasons with no post say why the row is here in words, since
nothing beside them does. A surge has only its stars; filler has to
say it is filler, or a popular repository passes for a trending one.
-->
<span v-else-if="row.reason._tag === 'surge'" class="shrink-0 text-default">
{{ `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
v-if="posts.length"
class="board-row__posts"
:posts="posts"
:names="row.names"
:label="`Posts about ${row.title}`"
/>
</div>

<TrendingPostCarousel
v-if="posts.length"
class="board-row__posts"
:posts="posts"
:names="row.names"
:label="`Posts about ${row.title}`"
/>
</div>
</template>

<style scoped>
/*
* The row answers to the width it gets, not the viewport's. The board shares
* the page with a sidebar on wide screens, so a viewport breakpoint put the
* posts beside the Skill in a column too narrow for either.
*/
.board-item {
container: board-item / inline-size;
}

/*
* Narrow first: the rank sits in a gutter and the posts stack under the
* Skill, so the Skill name, never a post, is the first thing under each rank.
Expand Down Expand Up @@ -186,8 +197,10 @@ const mentions = computed(() => {
margin-top: 0.875rem;
}

/* Capped for rows with no posts, which otherwise run the whole board wide. */
.board-row__description {
display: -webkit-box;
max-inline-size: 75ch;
margin-top: 0.625rem;
overflow: hidden;
font-size: 0.9375rem;
Expand All @@ -198,14 +211,18 @@ const mentions = computed(() => {
line-clamp: 2;
}

@media (min-width: 64rem) {
/*
* Beside the Skill once the row is wide enough for both. The posts take what
* the board has to spare, and the Skill column stays the wider one.
*/
@container board-item (min-width: 52rem) {
.board-row {
grid-template-columns: 3rem minmax(0, 1fr);
column-gap: 2rem;
}

.board-row--with-posts {
grid-template-columns: 3rem minmax(0, 1fr) minmax(0, 21rem);
grid-template-columns: 3rem minmax(0, 1.25fr) minmax(21rem, 1fr);
}

.board-row__rank {
Expand Down
22 changes: 20 additions & 2 deletions layers/marketing/app/components/TrendingPostCarousel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import TrendingPostCard from './TrendingPostCard.vue'
* it, and focus on a card scrolls that card into view.
*
* The next card peeks in from the edge, which is what says there is more.
* Where the carousel is wide enough, two cards show at once.
*/
const { posts, names, label } = defineProps<{
posts: readonly TrendingPost[]
Expand All @@ -23,7 +24,7 @@ const { posts, names, label } = defineProps<{
}>()

const track = useTemplateRef<HTMLElement>('track')
const { x } = useScroll(track)
const { x, arrivedState } = useScroll(track)
const motion = usePreferredReducedMotion()

/** Distance from one card to the next: its width plus the gap. */
Expand Down Expand Up @@ -80,7 +81,7 @@ function move(by: number) {
square
aria-label="Next post"
class="post-carousel__button"
:disabled="current >= posts.length - 1"
:disabled="current >= posts.length - 1 || arrivedState.right"
@click="() => move(1)"
/>
</div>
Expand All @@ -89,6 +90,7 @@ function move(by: number) {

<style scoped>
.post-carousel {
container-type: inline-size;
min-inline-size: 0;
}

Expand Down Expand Up @@ -139,6 +141,22 @@ function move(by: number) {
color: var(--ui-text-muted);
}

/*
* Two cards at a time once a card would otherwise stretch past a readable
* line. They grow to fill the track when only one or two exist, and a third
* still peeks in from the edge. Two posts side by side leave nothing to step
* through, so they show no controls.
*/
@container (min-width: 36rem) {
.post-carousel__slide {
flex: 1 0 46%;
}

.post-carousel:has(.post-carousel__slide:nth-child(2):last-child) .post-carousel__nav {
display: none;
}
}

/* A touch screen swipes the track, so only a pointer gets the buttons. */
@media (hover: none) {
.post-carousel__button {
Expand Down
65 changes: 23 additions & 42 deletions layers/marketing/app/components/TrendingWeeklyCta.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,10 @@ import { useElementVisibility } from '@vueuse/core'
* The preview stays inert because it is supporting artwork. The CTA beside it
* is the only promised action.
*
* A plain block, not an `<aside>`: the board renders it twice, beside the page
* heading and between the board's halves, and one of them is always hidden.
* Two complementary landmarks with the same name failed HTML validation.
* A plain block, not an `<aside>`: the board renders it twice, in the sidebar
* and between the board's halves, and one of them is always hidden. Two
* complementary landmarks with the same name failed HTML validation.
*/
const { layout = 'stacked' } = defineProps<{
/** `row` sets the preview beside the copy, for the page heading. */
layout?: 'stacked' | 'row'
}>()

/**
* The email loads in the browser, and only for the copy a reader can see.
Expand Down Expand Up @@ -54,7 +50,7 @@ const previewDocument = computed(() => card.value
</script>

<template>
<div class="trending-weekly-cta" :class="`trending-weekly-cta--${layout}`">
<div class="trending-weekly-cta">
<div
v-if="keepPreviewBox"
ref="preview"
Expand Down Expand Up @@ -94,6 +90,7 @@ const previewDocument = computed(() => card.value

<style scoped>
.trending-weekly-cta {
container-type: inline-size;
min-inline-size: 0;
overflow: clip;
border: 1px solid var(--ui-border);
Expand Down Expand Up @@ -133,44 +130,28 @@ const previewDocument = computed(() => card.value
}

/*
* Beside the page heading: the email as a thumbnail, the copy beside it, and
* no taller than the heading it sits next to. Rendered at full email width and
* scaled to a quarter, since the picture only has to read as an inbox.
* The sidebar column is narrower than any phone. The copy tightens there so
* the button keeps its whole label on one line rather than truncating it.
*/
.trending-weekly-cta--row {
display: grid;
grid-template-columns: 8.5rem minmax(0, 1fr);
}

/* Out of flow, so the copy alone sets the height; a transform never shrinks layout. */
.trending-weekly-cta--row .trending-weekly-preview {
position: relative;
block-size: auto;
border-block-end: 0;
border-inline-end: 1px solid var(--ui-border);
}

.trending-weekly-cta--row .trending-weekly-frame {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
inline-size: 400%;
block-size: 44rem;
transform: scale(0.25);
}
@container (max-width: 16rem) {
.trending-weekly-copy {
padding: 0.75rem;
}

.trending-weekly-cta--row .trending-weekly-copy {
display: flex;
flex-direction: column;
justify-content: space-between;
}
.trending-weekly-heading {
font-size: 0.9375rem;
}

.trending-weekly-cta--row .trending-weekly-heading {
font-size: 0.9375rem;
}
.trending-weekly-action {
gap: 0.375rem;
padding-inline: 0.5rem;
font-size: 0.75rem;
}

.trending-weekly-cta--row .trending-weekly-action {
margin-top: 0.75rem;
.trending-weekly-action :deep(.iconify) {
inline-size: 1rem;
block-size: 1rem;
}
}

@media (forced-colors: active) {
Expand Down
Loading