Handoff: Yu Wu Hsien — Personal Blog Theme (for VitePress)
Overview
A warm, text-first personal-blog design in the spirit of stephango.com: a quiet cream/charcoal palette, a single narrow reading column, system-font typography, and a sage-green accent that appears almost exclusively as left-to-right underline animations on links. No cards, no shadows, no gradients — the restraint is the design.
The goal of this handoff is to rebuild this design as a custom VitePress theme (Vue 3 + VitePress's theming API), so the user can author posts in Markdown and get this exact look.
About the Design Files
The files in reference/ are a design reference built in HTML — a working prototype that shows the intended look, typography, spacing, and interactions. They are not production code to copy verbatim. The prototype is a single-page React app (loaded via in-browser Babel) only because that was the fastest way to mock a multi-page site; none of that React/Babel machinery should survive into the VitePress theme.
Your task: recreate these designs as a VitePress custom theme using VitePress's own patterns — Layout.vue, frontmatter, content loaders, Vue SFCs, and scoped CSS. Author-facing content (posts, About, Now) becomes Markdown; the chrome (masthead, footer, writing list, topic pages) becomes Vue components driven by frontmatter and a build-time posts loader.
What to ignore from the reference
- The Tweaks panel (
tweaks-panel.jsx, theuseTweaks/applyTweakslogic, thedata-*attributes on<html>). This was a prototyping affordance for exploring options live. It is not part of the theme. Where a value was "tweakable," I've flagged it intheme-tokens.cssas a documented flexible axis — ship the default, don't build the panel. - The client-side router /
nav.go()— VitePress provides routing. Use real routes/links. dangerouslySetInnerHTMLfor article bodies — in VitePress the Markdown compiler renders the body; you just style.prose-equivalent content (VitePress calls it.vp-doc).- The hard-coded
data.jsxposts array — replace with frontmatter + aposts.data.tsloader.
Fidelity
High-fidelity. Colors, typography, spacing, and interaction details are final. Recreate them pixel-faithfully. Exact values are in theme-tokens.css and the per-component specs below. reference/assets/styles.css is the source of truth for any measurement not spelled out here.
Target: VitePress theme structure
Suggested layout for the custom theme. Adapt to the user's repo conventions if they differ.
docs/
├─ .vitepress/
│ ├─ config.ts # site config, nav, no default sidebar
│ └─ theme/
│ ├─ index.ts # registers Layout + imports tokens.css + style.css
│ ├─ Layout.vue # root: switches on frontmatter.layout
│ ├─ tokens.css # ← provided here (theme-tokens.css)
│ ├─ style.css # component styles ported from reference/assets/styles.css
│ ├─ components/
│ │ ├─ Masthead.vue
│ │ ├─ SiteFooter.vue
│ │ ├─ WritingList.vue
│ │ ├─ PostRow.vue
│ │ ├─ TopicChips.vue
│ │ ├─ PostMeta.vue
│ │ ├─ BackLink.vue
│ │ └─ Arrow.vue
│ └─ layouts/
│ ├─ HomeLayout.vue # frontmatter: layout: home
│ ├─ ArticleLayout.vue # default for posts/*.md
│ ├─ PageLayout.vue # About, Now (prose-only pages)
│ └─ TopicLayout.vue # topic archive
│ └─ posts.data.ts # build-time loader: all posts' frontmatter
├─ index.md # home (layout: home)
├─ about.md # layout: page
├─ now.md # layout: page
├─ topics/[topic].md (or paths plugin) # dynamic topic archives
└─ posts/
├─ view-source-is-a-love-letter.md
├─ bcmath-object-api-in-php-84.md
└─ …Key VitePress mechanics to use:
Layout.vuereadsuseData().frontmatterand renders the right layout component. For the Markdown article body, render VitePress's<Content />inside your.prosewrapper.posts.data.ts(data loader) globsposts/*.md, reads each file's frontmatter (title,date,read,topics,excerpt), and returns a sorted array. The home Writing list, Topic chips + counts, Latest feature, and Related writing all derive from this one dataset.- Topic archives: use a dynamic route
topics/[topic].md+ a[topic].paths.tsthat enumerates every topic from the loader. - Dark mode: VitePress ships a dark-mode toggle by default and adds
.darkon<html>. A complete warm dark palette is now provided intheme-tokens.css(underhtml[data-theme="dark"]). Wire it to VitePress's toggle — either aliashtml.dark { … }to the same variables, or setdata-themefrom the appearance state. See Dark mode & Shiki below.
Screens / Views
There are 5 page types. All share the same shell: a centered column max-width: var(--maxw) (648px), margin: 0 auto, padding: 0 28px 120px. Every page starts with the Masthead and ends with the SiteFooter.
Entrance animation: each page's content wrapper (
.view) animatestransform: translateY(8px)→0over 0.5scubic-bezier(0.2,0.7,0.2,1), gated behind@media (prefers-reduced-motion: no-preference). Critically, opacity is NOT animated and the resting state isopacity:1, so static/print/SSR renders never appear blank. Preserve this discipline.
1. Home (index.md, layout: home)
Purpose: Landing — surface the latest essay, let people browse by topic, list all writing. Layout (top→bottom), all within the 648px column:
- Masthead
- Latest feature (
.feature): eyebrowLATEST→ large title (links to the post) → excerpt → meta line → "Keep reading →" button. <hr class="rule">- Topics section: eyebrow
TOPICS→ row of topic chips (each links to its archive). <hr class="rule">- Writing section: eyebrow
WRITING→ full writing list (all posts, newest first). - SiteFooter
2. Article (posts/*.md, default/layout: article)
Purpose: Read one essay. Layout:
- Masthead with crumb
/ Writing - BackLink "← Writing" (returns to home)
<article>:.article-head(h1 title + meta line) then.prosebody (rendered Markdown)- Related writing (
eyebrow+ writing list of up to 3 posts sharing ≥1 topic), only if any - SiteFooter
3. About (about.md, layout: page)
Purpose: Who/what. Layout: Masthead → .about-head (h1 "About") → portrait placeholder (16:7 striped box — this is a deliberate placeholder for the user's real photo) → .prose content → SiteFooter.
4. Now (now.md, layout: page)
Purpose: A now page — what the author is up to lately. Layout: Masthead → .about-head (eyebrow UPDATED <month year> + h1 "What I'm doing now") → .prose content (with an internal <hr class="rule"> between "now" and "Previously") → SiteFooter.
5. Topic archive (topics/[topic].md, layout: topic)
Purpose: All posts under one topic. Layout: Masthead with crumb / Writing → BackLink "← Writing" → .topic-head (kicker TOPIC + h1 #<topic> where # is accent-colored + count "N essays") → writing list (filtered to that topic) → SiteFooter.
Components (exact specs)
All rem values are relative to --base (18px). e.g. 0.92rem ≈ 16.6px.
Masthead .masthead
- Flex row,
justify-content: space-between,align-items: center,gap: 20px,padding: 44px 0 64px(mobile ≤560px:28px 0 26px). - Left — brand (
.brand, links home): wordmark.name= author name,font-weight: 600,font-size: 0.96rem,letter-spacing: -0.01em. Optional.crumbafter it (/ Writing), color--faint, weight 400. Hover: an accent underline grows L→R under the name only (::after,height: 1.5px, animatesright: 100%→0over 0.22s). - Right — nav (
.nav): flex,gap: 22px. Links: About, Now, then a theme toggle icon button (sun/moon) at the far right. Each linkfont-size: 0.92rem, color--muted. Hover: color→--ink+ accent underline grows L→R (same::aftermechanism). Active page: color--ink+ underline shown in--accent. - Theme toggle (
.theme-toggle): a 32×32 icon button, color--muted,border-radius: 8px, no underline pseudo. Shows a moon in light mode and a sun in dark mode (click flips the theme). Hover: color→--ink, background--line-soft. In VitePress, map this to the built-in appearance toggle (or replace VitePress's default switch position with this one in the nav).
Link underline system (THE signature interaction — reuse everywhere)
A link sits at rest with no underline; on hover a 1.5px accent bar grows from left to right. Two implementations exist in the reference; use whichever fits:
::afterabsolute bar (used by nav/brand):position:absolute; left:0; right:100%; bottom:-3px; height:1.5px; background:var(--accent)→ hover setsright:0; transitionright 0.22s cubic-bezier(0.2,0.7,0.2,1).background-sizegradient (used by prose links, footer links, post-row titles):background-image: linear-gradient(var(--accent),var(--accent)); background-repeat:no-repeat; background-position:0 100%; background-size:0% 1.5px; padding-bottom:2px→ hover setsbackground-size:100% 1.5px; transitionbackground-size 0.25s cubic-bezier(0.2,0.7,0.2,1).background-position:0 100%+padding-bottom:2pxis important — it seats the underline below descenders (an earlier1.1emvalue cut through letters like "g/p/y").
PostMeta .meta (date · read · #topics)
- Flex row,
align-items:center,gap:12px,font-family:var(--font-token),font-size:0.74rem,letter-spacing:0.04em,text-transform:uppercase, color--faint. - Separators are
.dot: a 3×3px--faintcircle. - Date format
YYYY.MM.DD(e.g.2026.05.04). - Topics render as
.tag-linksbuttons: lowercase, each prefixed with a#via::before(opacity:0.5). Hover: whole tag →--accent. Each links to the topic archive.
"Keep reading" button .more
- Inline-flex,
gap:6px,margin-top:22px, color--accent,font-size:0.95rem, weight 500. - Contains an Arrow (right). Hover: color→
--accent-ink; arrow translates+4pxin X over 0.22s.
Arrow glyph Arrow
- Inline SVG, 14×14,
viewBox="0 0 16 16",fill:none,display:block. - Right:
path d="M6.5 3.5 11 8l-4.5 4.5". Left:d="M9.5 3.5 5 8l4.5 4.5". stroke="currentColor",stroke-width:1.6, round caps/joins.- Must be square (14×14) — the original 13×16 against a square viewBox squashed it and broke vertical alignment with adjacent text.
Latest feature .feature
- Title
.f-title:font-family:var(--font-display),font-size:1.85rem,line-height:1.18, weight 600,letter-spacing:-0.02em(mobile: 1.5rem). It's a link → hover color--accent. - Excerpt
.f-excerpt: color--muted,font-size:1.06rem,line-height:1.6,max-width:52ch.
Topic chips .topics / .chip
- Container: flex,
flex-wrap:wrap,gap:9px. - Chip:
font-family:var(--font-token),font-size:0.82rem, color--muted,background:--surface,border:1px solid --line,border-radius:999px,padding:5px 13px,line-height:1.4. - Count
.ctafter the label:opacity:0.55,margin-left:6px. - Hover: text→
--surface, background→--accent, border→--accent(fills with green). - There is no "all" chip and no selected/active state — clicking a chip navigates to that topic's archive page.
Writing list WritingList / PostRow .post-row
- A flat, newest-first list (no year grouping). Each row is a full-width link,
padding:var(--row-pad) 0. .r-top: flex,align-items:baseline,gap:14px..r-date:font-family:var(--font-token),font-size:0.74rem, color--faint,width:6.6em(fixed gutter),white-space:nowrap,padding-top:2px..r-title:font-size:1.06rem, weight 500,letter-spacing:-0.01em,line-height:1.35, with the background-size underline (grows on row hover; title also turns--accent).
.r-excerpt(optional):margin-top:5px,padding-left:calc(6.6em + 14px)(aligns under title), color--muted,font-size:0.94rem,max-width:56ch. Hidden by default (excerpts off) and hidden in compact density. On mobile ≤560px the row stacks (.r-topbecomes column, date gutter collapses, excerpt padding removed).
Article prose .prose (→ map to VitePress .vp-doc)
margin-top:40px. Paragraphsline-height:1.72,margin-bottom:1.35em.h2:font-family:var(--font-display),1.32rem, weight 600,letter-spacing:-0.015em,margin:2.1em 0 0.7em,line-height:1.25.h3:1.08rem, weight 600,margin:1.8em 0 0.6em.ul/ol:padding-left:1.3em,margin-bottom:1.35em;liline-height:1.65; markers--faint.blockquote:border-left:2px solid var(--accent),padding:4px 0 4px 22px, italic,1.08rem.- Inline
code:font-family:var(--font-mono),0.86em,background:--surface,border:1px solid --line,border-radius:5px,padding:1px 6px. pre:background:--surface,border:1px solid --line,border-radius:12px,padding:18px 20px,overflow-x:auto,margin:1.6em 0,line-height:1.6.pre code: no bg/border,0.84rem,--ink. (For VitePress, reconcile with its Shiki code-block styling — keep the cream surface + 12px radius.)hr:border-top:1px solid var(--line-soft),margin:2.2em 0.- Prose links use the background-size underline, color
--accent, hover--accent-ink. - Article
h1(.article-head h1):2.1rem, weight 600,letter-spacing:-0.025em,line-height:1.14,text-wrap:balance(mobile 1.6rem).
Topic head .topic-head
- Kicker
.t-kicker: token font,0.74rem,letter-spacing:0.12em, uppercase,--faint. h1:2rem, weight 600,letter-spacing:-0.02em; the leading#(.hash) is--accent..t-count: color--muted,0.95rem.
SiteFooter .foot
margin-top:var(--section-gap),padding-top:var(--section-gap),border-top:1px solid var(--line-soft), color--muted,font-size:0.92rem..sub-title"Receive my updates": weight 600, color--ink,1.02rem.- A short paragraph, then
.foot-links(flex,gap:18px): Email · RSS · Mastodon · GitHub — each is.flink(0.88rem) using the background-size underline (hover → accent + underline). .foot-sign(margin-top:44px): a 44×44 circular avatar only (object-fit:cover,box-shadow:0 0 0 1px var(--line)). No copyright text — the avatar is the sign-off. In the reference the avatar is a Gravatar URL; in the theme make it a configurable theme/site option.
BackLink .backlink
- Inline-flex,
gap:7px,font-size:0.9rem, color--muted,margin-bottom:36px. Left Arrow + label. Hover: color→--ink; arrow translates-3pxin X.
Eyebrow / section label .eyebrow
- Token font,
0.72rem,letter-spacing:0.14em, uppercase, color--faint, weight 500,margin-bottom:18px. Used for LATEST / TOPICS / WRITING / RELATED WRITING.
Interactions & Behavior
- Navigation: standard links/routes (VitePress). Clicking the brand → home. Topic chips and meta
#tags→ that topic's archive. Post titles/rows → the article. - Link hover: L→R accent underline grow (0.22–0.25s,
cubic-bezier(0.2,0.7,0.2,1)). Universal. - "Keep reading" / BackLink: arrow nudges in its travel direction on hover.
- Topic chip hover: fills with accent (text inverts to
--surface). - Page entrance:
translateY(8px)→0, 0.5s, reduced-motion-safe, opacity never animated. - Scroll on navigate: reset to top (VitePress handles this for route changes).
- Responsive: single breakpoint at 560px — see mobile overrides above (tighter padding, smaller headings, stacked post rows,
--section-gap:48px).
State / Data
- No client state beyond routing. Everything is build-time data:
posts.data.tsreturns{ slug, url, title, date, read, topics[], excerpt }[], sorted newest-first. Source = frontmatter of eachposts/*.md.- Latest =
posts[0]. Topic counts = tallytopicsacross posts. Related = same-topic posts (≠ current), sliced to 3. Topic archive = filter bytopics.includes(topic).
Design Tokens
See theme-tokens.css (provided, drop-in). Summary:
- Colors: bg
#f5f1e8, surface#fbf9f3, ink#2b2722, muted#7b7264, faint#a39a8b, linergba(43,39,34,.10), line-softrgba(43,39,34,.06), accent#5f7a52, accent-ink#4c6342, selectionrgba(192,95,56,.18). - Type: system sans; mono =
ui-monospace…; base 18px; line-height 1.65 body / 1.72 prose. - Layout: column 648px; gutter padding 28px (mobile 20px); section gap 64px (mobile 48px).
- Radii: chips/pills 999px; code/inline-code 5px; pre 12px; portrait 14px; avatar 50%.
- Borders: hairlines 1px
--line; dividers 1px--line-soft; blockquote/active rule 2px accent. - No shadows anywhere except the avatar's
0 0 0 1pxring. No gradients (the "gradient" is only the underline trick). Keep it flat.
Assets
- Avatar (footer sign-off): Gravatar
https://gravatar.com/avatar/3075c9103f3dddee28d255b45df7bfc4c58459e182f8c600a07930856e97dc39?s=96. Make this a configurable theme option (e.g.themeConfig.avatar). - About portrait: intentionally a placeholder (striped 16:7 box reading "[ portrait / studio photo ]"). The user supplies a real image; keep the placeholder as the empty state.
- Fonts: none required for the default (system stack). The prototype optionally loaded Hanken Grotesk / IBM Plex Sans / Schibsted Grotesk / Zilla Slab / IBM Plex Mono from Google Fonts for its tweak options — not needed unless the user wants a non-default face.
- Icons: none — the only glyph is the inline SVG arrow (spec above).
Files (in reference/)
Yu Wu Hsien - Blog.html— entry; shows page structure,<head>, font links, script order.assets/styles.css— source of truth for all CSS (every measurement above came from here).assets/components.jsx— Masthead, Meta, PostRow, WritingList, TopicChips, SiteFooter, BackLink, Arrow,dotDate()helper. Direct analogues to the Vue components to build.assets/app.jsx— page components (Home, Article, About, Now, TopicArchive) + routing + the tweak-application logic (ignore the tweak parts). Shows page composition.assets/data.jsx— sample posts with the frontmatter shape you'll mirror (slug,title,year,month,date,read,topics[],excerpt,bodyHTML). One post is a fully-written essay.
Dark mode & Shiki code highlighting
Dark palette
A full warm dark theme lives in theme-tokens.css under html[data-theme="dark"]. It keeps the same warm hue family — espresso ground #1b1916, lifted surface #24211c, paper-white text #ece6da — never pure black/white. Because every component reads --bg/--surface/--ink/--muted/ --faint/--line rather than hard-coded colors, the entire site re-themes for free once the dark vars are active.
Wiring to VitePress: VitePress toggles dark mode by adding .dark to <html>. Two options:
- Duplicate the selector:
html.dark { /* same vars as html[data-theme="dark"] */ }, or - Mirror VitePress's appearance into
data-themeand keep the provided selector.
Accent in dark (don't skip this): the accent must be lightened, not reused. Sage #5f7a52 is too dark on the espresso ground. Lighten the chosen accent ~20% toward white for --accent, and ~42% for --accent-ink so hover goes brighter, not darker (the opposite of light mode). For the shipped sage that's --accent ≈ #7f9575, --accent-ink ≈ #a2b29b. The reference computes this with a lighten(hex, amt) helper in app.jsx — port the same logic if you keep a color switcher, otherwise just hard-code the dark accent values.
Shiki, harmonized with the cream/dark pre
The design's code blocks are a cream card (--surface, 1px --line border, 12px radius, 18px 20px padding, overflow-x:auto) — NOT a typical near-black editor block. VitePress uses Shiki, whose default themes ship their own dark backgrounds and saturated token colors that would clash. To reconcile:
Neutralize Shiki's background so the cream/dark card shows through. In your
style.css:css.vp-doc div[class*="language-"] { /* the code-block wrapper */ background: var(--surface); border: 1px solid var(--line); border-radius: 12px; } .vp-doc div[class*="language-"] pre, .vp-doc div[class*="language-"] code { background: transparent; }Use CSS-variable token colors instead of a baked Shiki theme. The cleanest path is Shiki's CSS-variables theme (
css-variables) or VitePress's twoslash/!themeswith a custom theme, then map token scopes to the--syn-*variables already defined intheme-tokens.css(light) and the dark block. The palette is deliberately small and drawn from the brand accents:Token Light Dark Role / scopes (approx) comment #a39a8b#766d5fcomments — italic keyword #a85432#d98a63use class function return new fn …string #5f7a52#9cbf8cstring literals function #50708f#82a6c4function/method calls type / class #50708f#82a6c4Capitalized identifiers, class names variable #8a5a6f#c498ad$phpvars, identifiersnumber #a96b34#d2a06bnumeric & constant literals (function and type share a color on purpose — keeps the block from turning into a rainbow.)
Demonstration: the HTML reference now renders this exact palette on the real PHP code blocks via a tiny tokenizer in
app.jsx(highlightCode+SYNTAX_RE) and.tok-*rules instyles.css. That tokenizer is just a visual reference — in VitePress let Shiki do the real tokenizing and only borrow the colors. The.tok-*→ scope mapping in the table tells you which Shiki scopes to point at each variable.
Net effect: code blocks sit as warm cream cards in light mode and warm espresso cards in dark mode, with syntax colors that belong to the same family as the rest of the site.
Suggested build order
- Scaffold VitePress; add
tokens.css+ portstyles.css→style.css; wiretheme/index.ts. - Build
Arrow,Masthead,SiteFooter,BackLink(shared chrome) +Layout.vueswitch. posts.data.tsloader; convert sample posts toposts/*.mdwith frontmatter.PostMeta,PostRow,WritingList,TopicChips.- Layouts: Article (with
<Content/>in.prose/.vp-doc), Home, Page (About/Now), Topic (+ dynamic[topic].paths.ts). - Reconcile VitePress Shiki code blocks with the cream
prestyle. Decide on dark mode with the user. - Verify against the reference at 648px desktop and ≤560px mobile.