# Blog design system

The ink-wash design language of 無人之境 — one blue hue, foreground-derived greys, and a single reading column.

## Overview

This is the design system for the blog zone (`blog.buxx.me`), a publication
called **無人之境** ("no man's land"). It is deliberately **not** harmonized with
the main `buxx.me` hero/identity system — it is its own publication with its own
voice. Everything is scoped under `.blog-zone`; nothing here may leak onto other
routes.

The aesthetic is ink-wash: a near-monochrome greyscale field, one blue hue for
all accents, and a single 720px reading column. Restraint is the brief — when in
doubt, do less. The reference point was chl.ee's calm, but the visuals were
redesigned in this design language rather than ported.

**Source of truth.** The ink colours live in `blogPalette`
(`src/data/site.ts`) and are emitted as CSS custom properties by
`BlogLayout.astro`. Greys, type, and layout live in `src/styles/blog.css`. This
file mirrors them — change the code, then sync the tokens here.

## Colors

Two layers: **greys derived from the theme foreground**, and a **three-shade
blue ink set**.

**Greys are alpha, not hex.** `{colors.ink}` … `{colors.fill}` are all
`hsl(var(--foreground) / α)`. Because they ride the theme foreground (black in
light, white in dark), a single declaration produces both modes. Use them for
all text and structure; never hardcode a grey.

**The ink set is one hue, graded by depth.** A monochrome publication should
read as a single voice, so the accents are three blues — not blue plus a warm
counter-colour. Each shade owns exactly one job, and the job is fixed by WCAG
contrast against the surface it sits on:

- `{colors.dai}` — **primary**. Links, table-of-contents progress, focus rings,
  hover. The greyed slate-blue shanshui painters dilute to push a ridge into the
  haze. Text-safe in both modes (6.84:1 / 7.99:1).
- `{colors.dian}` — **the mark**. The "Not by AI" pledge and the author byline.
  A deep, concentrated indigo — the firmest blue on the page, because it stands
  for the human behind the work. Text-safe in both modes (10.27:1 / 8.22:1).
- `{colors.ji}` — **highlight, fill only**. The pale "sky after rain" wash for
  emphasis on the reading surface. (Blog search now lives in the site-wide ⌘K
  palette, which uses its own neutral mark, so `ji` is a reserved reading-zone
  fill rather than a search-hit colour.) It measures **3.57:1** on the light
  surface, so it **fails AA as text** — it may only ever be a background wash,
  never a foreground colour. The constraint picks the role.

**Surfaces.** `{colors.surface.dark}` is `#0A0A0A`, not `#000`: pure black
smears on OLED during scroll, and white text on it hits 21:1, which haloes in
long-form reading. The near-black keeps the lights-off feel without the
artifacts.

## Typography

Body copy reads in **Inter** via the global `.reading` contract — content
surfaces opt in and inherit the family without re-declaring it. Code uses the
mono stack. `SiteWordmark.astro` renders the shared publication lockup from a
**2.6 KB WenKai subset** containing 無人之境, sillage, and its separators. The
family is named only by that component, so it does not leak into body text.

Sizes step in a clear hierarchy: `{typography.masthead}` for the wordmark, then
`{typography.article-h1}` → `{typography.article-h2}` → `{typography.article-h3}`
inside a post, `{typography.body}` for prose (a generous 1.85 line-height for
reading), and the muted scale (`{typography.excerpt}`, `{typography.meta}`,
`{typography.eyebrow}`, `{typography.tag}`) for index and chrome. Negative
letter-spacing tightens the large display sizes; body copy gets a hair of
positive tracking for screen legibility.

## Layout

One column, one width. `{layout.measure}` (720px) is the reading measure — every
shell caps here and centres. Shell padding is `{layout.shell-padding}`
(generous top, comfortable sides, deep floor so the last post never kisses the
viewport edge).

Vertical rhythm follows the `spacing` scale: `{spacing.sm}` inside a group,
`{spacing.lg}` between groups, `{spacing.section}`+ between sections. The zone
drops the site's ambient dot-grid background — chl.ee is a flat surface, so this
is too.

## Elevation & Depth

**There is no elevation.** The zone is intentionally flat — no drop shadows, no
raised cards floating over the page. Depth is expressed two ways only:

- **Hairlines** (`{colors.line}`) and **spacing** separate content. Prefer
  whitespace to a divider; reach for `{components.divider}` only when grouping
  genuinely needs a line.
- **Image edges** use a 1px inset hairline (`{layout.image-edge.light}` /
  `{layout.image-edge.dark}`) in pure black/white — NOT the tinted foreground
  neutral, which would pick up the surface beneath and read as dirt on the
  photo.

The one motion affordance is the **sliding hover pill** (`{colors.fill}` at
`{rounded.pill}`) that springs to hug the hovered list row.

## Shapes

Corner radii are a small fixed set: `{rounded.inline}` for inline code,
`{rounded.image}` for media, `{rounded.card}` for cards and panels,
`{rounded.pill}` for tags and round controls. Single-sided or borderless
surfaces use `{rounded.none}` — a rounded corner only belongs on a full border.

## Components

Components compose the tokens above; they never introduce new colour or type.

- `{components.link}` / `{components.post-item}` — text in `{colors.ink}`,
  links and interaction in `{colors.dai}`. On hover a row gets
  `{components.post-item-hover}` (the `{colors.fill}` pill).
- `{components.tag-pill}` — `{colors.dai}` text on a 12% `{colors.dai}` wash. A
  tag may override its own accent via `--tag-accent`; absent that, it falls back
  to `{colors.dai}`.
- `{components.not-by-ai}` — the pledge trigger and byline, in `{colors.dian}`.
  This is the only place the deepest ink appears, by design.
- `{components.search-mark}` — reserved. Blog full-text search (Pagefind) now
  renders inside the site-wide ⌘K palette, which owns its own neutral highlight;
  the blog zone no longer draws its own search mark.
- `{components.callout}` / `{components.inline-code}` — quiet `{colors.fill}`
  surfaces at card / inline radius.

## Do's and Don'ts

- **Do** keep every accent inside the blue ink set. If a new surface needs an
  accent, it is `{colors.dai}` unless it is specifically the human mark
  (`{colors.dian}`) or a highlight wash (`{colors.ji}`).
- **Do** derive greys from `{colors.ink}`'s alpha scale, so both modes stay in
  sync for free.
- **Do** confirm any new text/background pair clears **WCAG AA 4.5:1** in *both*
  modes before shipping it.
- **Don't** use `{colors.ji}` as a text or icon colour — it fails AA on the
  light surface. Fill only.
- **Don't** add a warm or second-hue accent (red, amber, green). The
  publication is monochrome on purpose; a counter-colour breaks the voice.
- **Don't** introduce drop shadows or raised cards. Separate with whitespace and
  hairlines.
- **Don't** use pure `#000` for the dark surface, or full-opacity dividers.
- **Don't** let any blog token leak outside `.blog-zone`, and don't pull the
  main-site identity tokens in.

## Token reference

The normative values. These mirror the live system — `blogPalette` in `src/data/site.ts`
owns the inks, `src/styles/blog.css` owns the greys and layout. Edit the code first,
then sync this block.

```yaml
name: 無人之境 — blog design system
version: alpha
description: >
  The calm, ink-wash visual zone for the blog at blog.buxx.me. A single blue
  hue graded by depth, near-black greys derived from the theme foreground, and a
  720px reading column. Blog styling stays under .blog-zone; only the shared
  publication lockup is also rendered in the homepage Writing doorway.

colors:
  # Surfaces — the page floor in each mode. Dark is near-black, never #000.
  surface.light: '#FFFFFF'
  surface.dark: '#0A0A0A'

  # Greys — NOT fixed hex. Expressed as the theme foreground at alpha, so one
  # declaration yields the calm light palette and a tuned dark variant for free.
  ink: 'hsl(var(--foreground))'           # 100% — titles, strong text
  body: 'hsl(var(--foreground) / 0.78)'   # article body copy
  muted: 'hsl(var(--foreground) / 0.58)'  # list excerpts
  faint: 'hsl(var(--foreground) / 0.42)'  # dates, meta, year ticks
  line: 'hsl(var(--foreground) / 0.10)'   # hairline dividers
  fill: 'hsl(var(--foreground) / 0.045)'  # sliding hover pill

  # Ink set — one hue, three depths. Source of truth: blogPalette in site.ts.
  # The mode-agnostic token is the live CSS var components consume; the
  # .light/.dark entries below document the concrete value resolved in each mode.
  dai: 'var(--blog-dai)'
  dian: 'var(--blog-dian)'
  ji: 'var(--blog-ji)'
  dai.light: '#3C5D80'    # Distant-mountain ink — primary. WCAG 6.84:1 on surface.light
  dai.dark: '#7FA8D6'     #            WCAG 7.99:1 on surface.dark
  dian.light: '#27406E'   # Indigo ink — the mark. WCAG 10.27:1 on surface.light
  dian.dark: '#6FA8FF'    #            WCAG 8.22:1 on surface.dark
  ji.light: '#3E8BD8'     # Clear-sky blue — highlight FILL only. 3.57:1 — never text
  ji.dark: '#6FB2F2'      #            WCAG 8.79:1 on surface.dark

typography:
  # Families. Body reads in Inter (the .reading contract); code in the mono
  # stack; the publication lockup uses a tiny WenKai subset for its Chinese sillage.
  family.sans: "'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"
  family.mono: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
  family.display: "'WenKai Lockup', 'Songti SC', 'Noto Serif CJK SC', serif"

  masthead:     { fontFamily: '{typography.family.display}', cjkSize: '50px', latinSize: '20px', lineHeight: '1', cjkLetterSpacing: '0.10em', fontWeight: 400 }
  article-h1:   { fontFamily: '{typography.family.sans}', fontSize: 'clamp(27px, 4.4vw, 34px)', lineHeight: '1.3', letterSpacing: '-0.02em', fontWeight: 600 }
  article-h2:   { fontFamily: '{typography.family.sans}', fontSize: '22px', lineHeight: '1.4', letterSpacing: '-0.01em', fontWeight: 600 }
  article-h3:   { fontFamily: '{typography.family.sans}', fontSize: '19px', lineHeight: '1.45', fontWeight: 600 }
  body:         { fontFamily: '{typography.family.sans}', fontSize: '17.5px', lineHeight: '1.85', letterSpacing: '0.003em', fontWeight: 400 }
  post-title:   { fontFamily: '{typography.family.sans}', fontSize: '19px', lineHeight: '1.45', letterSpacing: '-0.012em', fontWeight: 500 }
  excerpt:      { fontFamily: '{typography.family.sans}', fontSize: '15px', lineHeight: '1.55', fontWeight: 400 }
  meta:         { fontFamily: '{typography.family.sans}', fontSize: '13px', lineHeight: '1.4', fontWeight: 400 }
  eyebrow:      { fontFamily: '{typography.family.sans}', fontSize: '13px', letterSpacing: '0.02em', fontWeight: 500 }
  tag:          { fontFamily: '{typography.family.sans}', fontSize: '11.5px', lineHeight: '1', fontWeight: 500 }
  inline-code:  { fontFamily: '{typography.family.mono}', fontSize: '0.875em', fontWeight: 400 }

spacing:
  # 2px base, used as a calm 4px-ish rhythm. The named steps below are the ones
  # the zone actually reaches for.
  xs: '4px'
  sm: '8px'
  md: '12px'
  lg: '16px'
  xl: '24px'
  section: '40px'
  floor: '96px'

rounded:
  none: '0'        # single-sided / borderless surfaces (mobile list rows)
  inline: '6px'    # inline code, kbd, small chips
  image: '10px'    # media corners
  card: '12px'     # cards, callouts, share row, subscribe panel
  pill: '999px'    # tag pills, sliding hover pill, round controls

layout:
  measure: '720px'           # the reading column — every shell maxes here
  shell-padding: '40px 24px 96px'
  image-edge.light: 'inset 0 0 0 1px rgba(0, 0, 0, 0.1)'
  image-edge.dark: 'inset 0 0 0 1px rgba(255, 255, 255, 0.1)'

components:
  post-item:
    typography: '{typography.post-title}'
    textColor: '{colors.ink}'
    backgroundColor: 'transparent'
  post-item-hover:
    backgroundColor: '{colors.fill}'
    rounded: '{rounded.pill}'
  link:
    textColor: '{colors.dai}'
  tag-pill:
    typography: '{typography.tag}'
    textColor: '{colors.dai}'
    backgroundColor: 'color-mix(in srgb, {colors.dai} 12%, transparent)'
    rounded: '{rounded.pill}'
    padding: '4px 9px'
  search-mark:
    backgroundColor: 'color-mix(in srgb, {colors.ji} 26%, transparent)'
    textColor: 'inherit'
    rounded: '3px'
  not-by-ai:
    typography: '{typography.meta}'
    textColor: '{colors.dian}'
  callout:
    typography: '{typography.excerpt}'
    textColor: '{colors.body}'
    backgroundColor: '{colors.fill}'
    rounded: '{rounded.card}'
    padding: '16px 18px'
  inline-code:
    typography: '{typography.inline-code}'
    backgroundColor: '{colors.fill}'
    rounded: '{rounded.inline}'
    padding: '0.15em 0.4em'
  divider:
    backgroundColor: '{colors.line}'
    height: '1px'
```
