Yu Wu Hsien - Profile Picture
YU WU HSIEN

Just simple folk, with HTML, trying to make a living.

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, the useTweaks/applyTweaks logic, the data-* 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 in theme-tokens.css as 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.
  • dangerouslySetInnerHTML for 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.jsx posts array — replace with frontmatter + a posts.data.ts loader.

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.vue reads useData().frontmatter and renders the right layout component. For the Markdown article body, render VitePress's <Content /> inside your .prose wrapper.
  • posts.data.ts (data loader) globs posts/*.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 routetopics/[topic].md + a [topic].paths.ts that enumerates every topic from the loader.
  • Dark mode: VitePress ships a dark-mode toggle by default and adds .dark on <html>. A complete warm dark palette is now provided in theme-tokens.css (under html[data-theme="dark"]). Wire it to VitePress's toggle — either alias html.dark { … } to the same variables, or set data-theme from 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) animates transform: translateY(8px)→0 over 0.5s cubic-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 is opacity: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:

  1. Masthead
  2. Latest feature (.feature): eyebrow LATEST → large title (links to the post) → excerpt → meta line → "Keep reading →" button.
  3. <hr class="rule">
  4. Topics section: eyebrow TOPICS → row of topic chips (each links to its archive).
  5. <hr class="rule">
  6. Writing section: eyebrow WRITING → full writing list (all posts, newest first).
  7. SiteFooter

2. Article (posts/*.md, default/layout: article)

Purpose: Read one essay. Layout:

  1. Masthead with crumb / Writing
  2. BackLink "← Writing" (returns to home)
  3. <article>: .article-head (h1 title + meta line) then .prose body (rendered Markdown)
  4. Related writing (eyebrow + writing list of up to 3 posts sharing ≥1 topic), only if any
  5. 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 .crumb after it (/ Writing), color --faint, weight 400. Hover: an accent underline grows L→R under the name only (::after, height: 1.5px, animates right: 100%→0 over 0.22s).
  • Right — nav (.nav): flex, gap: 22px. Links: About, Now, then a theme toggle icon button (sun/moon) at the far right. Each link font-size: 0.92rem, color --muted. Hover: color→--ink + accent underline grows L→R (same ::after mechanism). 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).

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:

  • ::after absolute bar (used by nav/brand): position:absolute; left:0; right:100%; bottom:-3px; height:1.5px; background:var(--accent) → hover sets right:0; transition right 0.22s cubic-bezier(0.2,0.7,0.2,1).
  • background-size gradient (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 sets background-size:100% 1.5px; transition background-size 0.25s cubic-bezier(0.2,0.7,0.2,1). background-position:0 100% + padding-bottom:2px is important — it seats the underline below descenders (an earlier 1.1em value 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 --faint circle.
  • Date format YYYY.MM.DD (e.g. 2026.05.04).
  • Topics render as .tag-links buttons: 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 +4px in 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 .ct after 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-top becomes column, date gutter collapses, excerpt padding removed).

Article prose .prose (→ map to VitePress .vp-doc)

  • margin-top:40px. Paragraphs line-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; li line-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.
  • 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.
  • Inline-flex, gap:7px, font-size:0.9rem, color --muted, margin-bottom:36px. Left Arrow + label. Hover: color→--ink; arrow translates -3px in 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.ts returns { slug, url, title, date, read, topics[], excerpt }[], sorted newest-first. Source = frontmatter of each posts/*.md.
    • Latest = posts[0]. Topic counts = tally topics across posts. Related = same-topic posts (≠ current), sliced to 3. Topic archive = filter by topics.includes(topic).

Design Tokens

See theme-tokens.css (provided, drop-in). Summary:

  • Colors: bg #f5f1e8, surface #fbf9f3, ink #2b2722, muted #7b7264, faint #a39a8b, line rgba(43,39,34,.10), line-soft rgba(43,39,34,.06), accent #5f7a52, accent-ink #4c6342, selection rgba(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 1px ring. 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.csssource 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, body HTML). 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:

  1. Duplicate the selector: html.dark { /* same vars as html[data-theme="dark"] */ }, or
  2. Mirror VitePress's appearance into data-theme and 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:

  1. 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; }
  2. 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/!themes with a custom theme, then map token scopes to the --syn-* variables already defined in theme-tokens.css (light) and the dark block. The palette is deliberately small and drawn from the brand accents:

    TokenLightDarkRole / 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$php vars, identifiers
    number#a96b34#d2a06bnumeric & constant literals

    (function and type share a color on purpose — keeps the block from turning into a rainbow.)

  3. 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 in styles.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

  1. Scaffold VitePress; add tokens.css + port styles.cssstyle.css; wire theme/index.ts.
  2. Build Arrow, Masthead, SiteFooter, BackLink (shared chrome) + Layout.vue switch.
  3. posts.data.ts loader; convert sample posts to posts/*.md with frontmatter.
  4. PostMeta, PostRow, WritingList, TopicChips.
  5. Layouts: Article (with <Content/> in .prose/.vp-doc), Home, Page (About/Now), Topic (+ dynamic [topic].paths.ts).
  6. Reconcile VitePress Shiki code blocks with the cream pre style. Decide on dark mode with the user.
  7. Verify against the reference at 648px desktop and ≤560px mobile.