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
4 changes: 3 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ Motifs carry data. Each answers one pitch line from `COPY.md` and spends one ros

**Run chip.** It pairs with "No more skill bloat". Run and install sit side by side, and run is preselected. Run has a dashed border and a hollow rose dot, because nothing stays. Install has a solid border and a filled dot, because files land. A consequence line under the chip says what each choice leaves on disk.

**Braille spark.** It pairs with "Stay hyped". Seven braille bars (`β£€β£€β£Άβ£Ώ`) show seven days of social mentions, with today in rose. The mention count sits beside it, with its noun from `sparkCount`, so one mention reads "1 mention". On the site, a day with no mentions draws the lowest bar at 40% opacity as a track, so a lone bar still reads as one day of seven. Plain text keeps the blank cell. The spark is made of characters, so the same line works on the site, in the weekly email, in Discord and in a terminal. It replaces the trending flame emoji.
**Braille spark.** It pairs with "Stay hyped". Seven columns of braille dots show seven days of social mentions, filled from the bottom, with today in rose. The mention count sits beside it, with its noun from `sparkCount`, so one mention reads "1 mention". On the site, `SparkDots` draws each day as one SVG column of up to four dots, about 20px for the week. A day with no mentions draws one dot at 40% opacity as a track, so a lone column still reads as one day of seven. Plain text prints the same levels as braille bars (`β£€β£€β£Άβ£Ώ`) and keeps the blank cell, so the spark works in the weekly email, in Discord and in a terminal. The trending mark is four rising columns of the same dots. The spark replaces the trending flame emoji.

**Change grid.** It pairs with "Keep updated". A 13-week grid of days, today at the bottom right. Each change is a stone dot, and the newest change is rose. A ring around a dot shows the size of the version bump, only where a version is known.

Expand Down Expand Up @@ -468,6 +468,8 @@ These are editorial sets, not necessarily database collections yet. If a set has
- **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.
- **Trending rows scan in one pass**: the rank is a plain mono number, and the first three are a step darker. Each metadata line runs source, stars, then the signal that ranked the row, and an empty slot renders nothing. Descriptions stop at two lines, the run chip is as wide as its command, and the post carousel shows whole cards only. The sidebar invitation drops the email preview and the card. Its button is the rail's one solid rose element, so the install chip beside it inks its dot. Harlan asked for this polish on 2026-10-06.
- **Trending rows take two lines of words**: the first line says what the Skill does, and the second says why it ranked. A row ranked by posts shows at most three poster circles, the third counting the rest, then the first post as one quote line. The circles open the post carousel, and the quote line steps aside while the carousel shows. The braille spark sits beside the Skill name, where the shorter name line has room. Every post stays in the server HTML. A row dropped from 292px to 78px on desktop and from 377px to 163px on a phone. Harlan picked this from a lab of five takes on 2026-10-06. It supersedes the two-line descriptions and the carousel under every row.
- **The spark draws dots, not braille text, on the site**: as text, each bar was a two-column braille cell in a fallback font, so the week measured 70px in headless Chrome on Linux and its width changed per system. One SVG column of dots per day draws the same levels in 20px. The trending mark is four rising columns. Plain text keeps the braille bars. Harlan asked for a more compact trending mark on 2026-10-06.
- **Trending rows hold a fixed name column**: the identity column stops at 17rem and truncates the name, and the words take the rest. As a share of the row it left a wide gap on wide screens. The quote line sits one size under the description: its handle is muted, and only the Skill name in it lifts to toned ink. Quotes drop emoji and Unicode letter styles. Automated accounts, such as GitHub trending auto-posters and Grok, never lead a row's posts while a person posted. The homepage shows the top five rows. Harlan asked for this on 2026-10-06.
- **A three-step lifecycle band replaces the four tiles**: under the hero, one band maps the product in its real order: find Skills, run, fork or install, keep up to date. Each step is one link: a mono step name, then one short sans line. No markers, pitch lines or commands. A line of stone dots joins the steps. Run, the default, carries the band's one rose dot, so no step lights rose on hover. From 1024px, three centered columns sit under the centered hero; below that, a vertical list with the dots down the left. The hero and the band must fit a 1440Γ—900 viewport. Harlan cut it from six steps on 2026-10-06, because six columns of mixed type read as too busy.
- **A Developers menu groups the build surfaces**: the desktop header keeps Trending Skills and Find Skills as links, and folds CLI, MCP server, SDK and Make a skill into one Developers menu, as nuxtseo.com does. The trigger is a button, so a keyboard opens the panel without leaving the page; the panel's last link reaches `/developers`. Harlan asked for this on 2026-10-06.
- **The Developers menu is a bento**: the three ways to connect stack as compact rows, each a lucide icon tile, a name and one line. Make a skill fills the second column as the one large card, with an ember atmosphere and the File minimap in its corner. It names both routes: the guides and Skillgen. `/make-skill` is the one entry for authors and routes to either, so Skillgen has no card of its own. The mobile menu lists the same four items as a plain group. Harlan chose this over five uniform cards on 2026-10-06.
Expand Down
43 changes: 9 additions & 34 deletions app/components/BrailleSpark.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
<script setup lang="ts">
import type { SparkUnit } from '#shared/braille-spark'
import { SPARK_BARS, SPARK_MENTIONS, sparkCount, sparkLevels, sparkWeek } from '#shared/braille-spark'
import { SPARK_MENTIONS, sparkCount, sparkLevels, sparkWeek } from '#shared/braille-spark'

/**
* Seven days of counts as seven braille bars, today in rose, with the count
* beside them. The bars are text from `shared/braille-spark.ts`, so the weekly
* email and any plain-text surface print the same spark.
* Seven days of counts as seven columns of dots, today in rose, with the count
* beside them. The levels come from `shared/braille-spark.ts`, so the weekly
* email and any plain-text surface print the same spark as braille text.
*
* A day with no count draws the lowest bar faintly, as a track. Plain text
* leaves that day blank, and on the page a week with one post became one bar
* floating in a gap, with nothing to say it was a week.
* A day with no count draws one faint dot, as a track. Plain text leaves that
* day blank, and on the page a week with one post became one bar floating in
* a gap, with nothing to say it was a week.
*/
const {
counts,
Expand Down Expand Up @@ -38,10 +38,7 @@ const {
}>()

const week = computed(() => sparkWeek(counts))
const cells = computed(() => sparkLevels(counts).map(level => ({
glyph: SPARK_BARS[level === 0 ? 1 : level],
empty: level === 0,
})))
const levels = computed(() => sparkLevels(counts))
const shown = computed(() => total ?? week.value.reduce((sum, count) => sum + count, 0))
const counted = computed(() => [sparkCount(shown.value, unit), period].filter(Boolean).join(' '))
const totalText = computed(() => showUnit ? counted.value : shown.value.toLocaleString('en-US'))
Expand All @@ -50,7 +47,7 @@ const label = computed(() => `${counted.value}. Per day, oldest first: ${week.va

<template>
<span class="braille-spark" role="img" :aria-label="label">
<span class="braille-spark__bars" aria-hidden="true"><span v-for="(cell, index) in cells" :key="index" :class="cell.empty ? 'braille-spark__track' : index === cells.length - 1 && 'braille-spark__today'">{{ cell.glyph }}</span></span>
<SparkDots :levels />
<span v-if="showTotal" class="braille-spark__total" aria-hidden="true">{{ totalText }}</span>
</span>
</template>
Expand All @@ -66,26 +63,4 @@ const label = computed(() => `${counted.value}. Per day, oldest first: ${week.va
font-variant-numeric: tabular-nums;
white-space: nowrap;
}

/* IBM Plex Mono has no braille, so the bars fall through to a face that does. */
.braille-spark__bars {
font-family: 'IBM Plex Mono', 'DejaVu Sans Mono', 'Apple Braille', 'Segoe UI Symbol', ui-monospace, monospace;
font-size: 0.875rem;
line-height: 1;
/* Muted, not dimmed: the bars are a graphic and need 3:1 against the surface. */
color: var(--ui-text-muted);
}

/*
* The track is not data: it only shows where the week runs, and the label
* carries the count. A fraction of the bar colour, not the dimmed token: in
* dark mode dimmed sits within a step of muted, and a quiet day read as none.
*/
.braille-spark__track {
opacity: 0.4;
}

.braille-spark__today {
color: var(--brand-dot);
}
</style>
13 changes: 8 additions & 5 deletions app/components/SkillCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ const metricWidth = computed(() => view.value.metricKind === 'none' ? '0rem' : v
<div v-else-if="view.layout === 'row'" class="skill-card skill-card--row-shell">
<div class="skill-card--row" :class="{ 'skill-card--ranked': rankText }" :style="{ '--skill-card-metric': metricWidth }">
<span v-if="rankText" class="skill-card__rank skill-card__row-rank" :class="{ 'skill-card__rank--lead': rankLead }" aria-hidden="true">{{ rankText }}</span>
<SkillCardIdentity :view :size="36" :accent="markAccent" wrap class="skill-card__row-id" @avatar-error="emit('avatarError', view.owner)">
<SkillCardIdentity :view :size="36" :accent="markAccent" class="skill-card__row-id" @avatar-error="emit('avatarError', view.owner)">
<template v-if="$slots.flag" #flag>
<slot name="flag" />
</template>
Expand Down Expand Up @@ -481,12 +481,15 @@ const metricWidth = computed(() => view.value.metricKind === 'none' ? '0rem' : v
}

/*
* Wide: three zones, who, what and the facts. The facts column has fixed
* parts, so every row in a list lines up.
* Wide: three zones, who, what and the facts. The who column stops at a
* fixed width and truncates the name: as a fraction of the row it grew with
* the screen, and on a wide board most of it was empty space between a short
* name and the words. The facts column has fixed parts, so every row in a
* list lines up.
*/
@container skill-card-row (min-width: 40rem) {
.skill-card--row {
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.5fr) auto;
grid-template-columns: minmax(0, 17rem) minmax(12rem, 1fr) auto;
grid-template-areas:
'id body end'
'id meta end'
Expand All @@ -497,7 +500,7 @@ const metricWidth = computed(() => view.value.metricKind === 'none' ? '0rem' : v
}

.skill-card--ranked {
grid-template-columns: 1.25rem minmax(0, 1.1fr) minmax(0, 1.5fr) auto;
grid-template-columns: 1.25rem minmax(0, 17rem) minmax(12rem, 1fr) auto;
grid-template-areas:
'rank id body end'
'rank id meta end'
Expand Down
89 changes: 89 additions & 0 deletions app/components/SparkDots.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<script setup lang="ts">
/**
* Columns of braille dots, one column per value, filled from the bottom.
*
* The braille spark and the trending mark draw with it. As braille text, each
* bar was a full two-column cell in a fallback font, with a gap inside every
* bar. Seven days measured 70px in headless Chrome on Linux, and the width
* changed per system. One column of dots per day draws them in 20px.
* Plain-text surfaces keep the braille characters; see `braille-spark.ts`.
*/
const { levels, accent = true } = defineProps<{
/** One level per column, from 0 to 4. Level 0 draws one faint track dot. */
levels: readonly number[]
/** Ink the last column rose. Off where the surface already spends its rose. */
accent?: boolean
}>()

const ROWS = 4
const DOT = 2
const PITCH = 3

const width = computed(() => levels.length * PITCH - (PITCH - DOT))
const height = ROWS * PITCH - (PITCH - DOT)

const dots = computed(() => levels.flatMap((level, column) => {
const kind = level === 0 ? 'track' : accent && column === levels.length - 1 ? 'today' : 'bar'
return Array.from({ length: Math.min(Math.max(level, 1), ROWS) }, (_, row) => ({
key: `${column}-${row}`,
x: column * PITCH,
y: height - DOT - row * PITCH,
kind,
}))
}))
</script>

<template>
<svg
class="spark-dots"
:viewBox="`0 0 ${width} ${height}`"
:width
:height
aria-hidden="true"
>
<rect
v-for="dot in dots"
:key="dot.key"
:x="dot.x"
:y="dot.y"
:width="DOT"
:height="DOT"
rx="1"
:class="`spark-dots__${dot.kind}`"
/>
</svg>
</template>

<style scoped>
/* Muted, not dimmed: the dots are a graphic and need 3:1 against the surface. */
.spark-dots {
display: inline-block;
flex: none;
overflow: visible;
color: var(--ui-text-muted);
}

.spark-dots rect {
fill: currentColor;
}

/*
* The track is not data: it only shows where the week runs. A fraction of the
* dot colour, not the dimmed token: in dark mode dimmed sits within a step of
* muted, and a quiet day read as none.
*/
.spark-dots .spark-dots__track {
opacity: 0.4;
}

.spark-dots .spark-dots__today {
fill: var(--brand-dot);
}

@media (forced-colors: active) {
.spark-dots rect,
.spark-dots .spark-dots__today {
fill: CanvasText;
}
}
</style>
38 changes: 8 additions & 30 deletions app/components/TrendingMark.vue
Original file line number Diff line number Diff line change
@@ -1,42 +1,20 @@
<script setup lang="ts">
import { SPARK_BARS } from '#shared/braille-spark'

/**
* A rising braille spark that marks "trending" beside a label.
* A rising column of dots 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.
* The levels are fixed: the mark says "trending", not how much. The newest
* column 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. */
/** Ink the newest column rose. Off in the header, where the logo dot holds the rose. */
accent?: boolean
}>()

const bars = SPARK_BARS.slice(1)
const LEVELS = [1, 2, 3, 4] as const
</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>
<SparkDots class="trending-mark" :levels="LEVELS" :accent />
</template>

<style scoped>
/* IBM Plex Mono has no braille, so the bars fall through to a face that does.
Muted, not dimmed: `--ui-text-dimmed` is stone 400 in light and stone 500
in dark, and both fail text contrast on the page surface. Muted is the
lightest stone token that passes AA in both modes. */
.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-muted);
}

.trending-mark__today {
color: var(--brand-dot);
}
</style>
2 changes: 1 addition & 1 deletion app/pages/brand-kit/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ const legend = [
Braille spark
</h2>
<p class="data-label mt-2">
Seven days as seven braille bars. Today is rose.
Seven days, one column of dots each. Today is rose.
</p>
<div class="mt-6 grid gap-6 md:grid-cols-2">
<div>
Expand Down
7 changes: 5 additions & 2 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ defineOgImage('Page.takumi', {
}, { alt: 'skilld, agent skills for you and your agent' })

/** Skills the trending section shows: the head of the week board. */
const HOME_BOARD_ROWS = 3
const HOME_BOARD_ROWS = 5

/** Fewest Skills the section shows at all. */
const HOME_BOARD_MIN_ROWS = 3

const serverTimingHeader = useResponseHeader('Server-Timing')
const homeDataStartedAt = performance.now()
Expand Down Expand Up @@ -124,7 +127,7 @@ const trendingBoard = computed(() =>
* the homepage costs more attention than it returns. Counted from what the
* server sent, so a dead avatar never hides the section after it rendered.
*/
const showTrending = computed(() => (trendingData.value?.board.length ?? 0) >= HOME_BOARD_ROWS)
const showTrending = computed(() => (trendingData.value?.board.length ?? 0) >= HOME_BOARD_MIN_ROWS)

const weekBoardPath = trendingRangeMeta('week').path

Expand Down
6 changes: 4 additions & 2 deletions layers/marketing/app/components/TrendingPostCard.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { TrendingPost } from '#shared/trending-range'
import { avatarProxyUrl } from '#shared/image-proxy'
import { postExcerpt } from '#shared/trending-post'
import { postExcerpt, unstyled } from '#shared/trending-post'

/**
* One post, laid out the way people already read posts: who said it, when,
Expand All @@ -28,6 +28,8 @@ const LINES = 4
* mention past this moves the excerpt to it; see `postExcerpt`.
*/
const segments = computed(() => postExcerpt({ text: post.text, names, budget: LINES * 42 }))
/** The name in the site's type, like the post under it. A name of emoji alone shows the handle only. */
const authorName = computed(() => (post.authorName ? unstyled(post.authorName).trim() || null : null))
const network = computed(() => (post.platform === 'bsky' ? 'Bluesky' : 'X'))

function likesLabel(count: number): string {
Expand All @@ -54,7 +56,7 @@ function likesLabel(count: number): string {
decoding="async"
>
<span class="trending-post__byline">
<span v-if="post.authorName" class="trending-post__name">{{ post.authorName }}</span>
<span v-if="authorName" class="trending-post__name">{{ authorName }}</span>
<span class="trending-post__handle">@{{ post.handle }}</span>
</span>
<span class="trending-post__meta">{{ post.when }}</span>
Expand Down
19 changes: 13 additions & 6 deletions layers/marketing/app/components/TrendingPostQuote.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,31 @@ const segments = computed(() => postExcerpt({ text: post.text, names, budget: BU
</template>

<style scoped>
/* Basis zero, so in a wrapping line it shrinks beside the faces rather than dropping below them. */
/*
* Basis zero, so in a wrapping line it shrinks beside the faces rather than
* dropping below them.
*
* One step under the description above it: a size smaller, and nothing in
* it brighter than the muted ink but the Skill's own name. The handle in body
* ink and the mention in bold were the loudest things in the row, so the eye
* met a stranger's handle before it met what the Skill does.
*/
.post-quote {
display: flex;
flex: 1 1 0;
min-inline-size: 0;
align-items: baseline;
gap: 0.5rem;
font-family: var(--font-sans);
font-size: 0.8125rem;
font-size: 0.75rem;
line-height: 1.25rem;
color: var(--ui-text-muted);
}

.post-quote__by {
flex: none;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ui-text);
font-size: 0.6875rem;
}

.post-quote__text {
Expand All @@ -68,8 +75,8 @@ const segments = computed(() => postExcerpt({ text: post.text, names, budget: BU

.post-quote__mention {
background: none;
font-weight: 600;
color: var(--ui-text);
font-weight: 500;
color: var(--ui-text-toned);
}

@media (prefers-reduced-motion: reduce) {
Expand Down
Loading