diff --git a/.gitignore b/.gitignore
index f9318ae2..606ac357 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,9 @@ node_modules/
tmp/
mise.local.toml
**/skills/npm-*
+src/public/uno.css
+.playwright-mcp/
+/*.png
coverage/**
!coverage/.gitkeep
diff --git a/AGENTS.md b/AGENTS.md
index 4f7d28ae..f4225826 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -126,6 +126,7 @@ Key architectural components
| *src/federation/index.ts* | Federation setup with inbox listeners |
| *src/oauth/middleware.ts* | Authentication middleware |
| *src/entities/status.ts* | Status entity serialization |
+| *DESIGN.md* | Design system and front-end conventions |
Technology stack
@@ -187,6 +188,24 @@ export function MyComponent({ name }: { name: string }) {
import React from 'react'; // Don't do this
~~~~
+### Design system and front-end conventions
+
+When working on any user-facing page (admin dashboard, profile, post,
+auth, OAuth screens, etc.), read *DESIGN.md* first. It defines:
+
+ - the visual design principles (simplicity, modernness, content first,
+ lightweight SSR, accessibility),
+ - the color system (achromatic neutrals plus per-account theme color
+ via CSS custom properties on ``),
+ - typography, spacing, iconography, and component recipes,
+ - the UnoCSS toolchain conventions (preset choices, prose application
+ areas, theme token injection, variant groups).
+
+Treat *DESIGN.md* as the single source of truth for front-end decisions
+that aren't directly answered by the source code. Never introduce ad-hoc
+CSS or inline styling that contradicts it; if the document is missing
+guidance on a real case, update *DESIGN.md* in the same change.
+
### Database guidelines
- *Migrations*: Always generate migrations for schema changes
diff --git a/CHANGES.md b/CHANGES.md
index b84bbba2..06a246bb 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -49,9 +49,53 @@ To be released.
post. If the quoted post is unavailable, the fallback link remains
visible so the quoted URL is not lost.
+ - Posts on the public profile and hashtag pages now render Open Graph link
+ previews—a thumbnail (when `og:image` is set), the host name, the page
+ title, and a short description—for each post that has a stored
+ `preview_card`. Posts with attached media or shown as quoted posts hide
+ the preview to avoid visual clutter. [[#458]]
+
+ - Refreshed the entire server-rendered front-end. Hollo replaces Pico CSS
+ with a new design system documented in `DESIGN.md` and styled through
+ UnoCSS (Wind4, Icons, Typography, and Web Fonts presets). [[#458]]
+
+ - The design language is achromatic by default; each account owner's
+ *theme color* tints the profile, post, hashtag, and owner-specific
+ dashboard pages through `--theme-50` through `--theme-950` CSS
+ variables injected on ``.
+ - Web Fonts are loaded from bunny.net: Inter for Latin, Noto Sans
+ KR/JP/SC for CJK, and JetBrains Mono for code. Lucide icons
+ replace ad-hoc iconography.
+ - Every public and dashboard page was rebuilt: login, setup, OTP, the
+ public home, account profiles, single post permalinks, the hashtag
+ stream, the account list and editor, custom emojis, federation,
+ thumbnail cleanup, the OAuth consent screen, and the dashboard
+ auth (2FA) panel.
+ - Posts render their bodies as `prose` markdown with brand-colored
+ links, attached media as a two-column grid, polls as brand-tinted
+ bars, quoted posts as an inset card, and link previews as a
+ media-object card. The single-post permalink page additionally
+ enlarges the focal post.
+ - Forms share a small set of primitives (`Field`, `TextField`,
+ `TextareaField`, `SelectField`, `CheckboxField`, `FieldSection`,
+ and `SubmitButton`) that the auth, account, emoji, federation,
+ thumbnail cleanup, and migrate forms all use. The theme color
+ picker on the account form is a 20-swatch grid; the username
+ field is bracketed by `@` and `@host` chips so the resulting
+ fediverse handle is obvious.
+ - Dark mode follows `prefers-color-scheme: dark` automatically, with
+ primary buttons stepping from `brand-600` to `brand-700` so they
+ don't dominate dark surfaces. Text selection adopts the active
+ brand color.
+ - Hollo's logos are self-hosted from `/public/` instead of being
+ fetched from jsDelivr. The 22 Pico-generated `.min.css` files
+ are removed; UnoCSS emits a single `src/public/uno.css` whose
+ URL is cache-busted by file mtime.
+
- Upgraded Fedify to 2.2.0.
[#457]: https://github.com/fedify-dev/hollo/pull/457
+[#458]: https://github.com/fedify-dev/hollo/pull/458
Version 0.8.1
@@ -223,6 +267,7 @@ Released on April 27, 2026.
- Upgraded Fedify to 2.1.10.
+[Fedify debugger]: https://fedify.dev/manual/debug
[#173]: https://github.com/fedify-dev/hollo/issues/173
[#348]: https://github.com/fedify-dev/hollo/issues/348
[#350]: https://github.com/fedify-dev/hollo/issues/350
@@ -238,7 +283,6 @@ Released on April 27, 2026.
[#445]: https://github.com/fedify-dev/hollo/issues/445
[#447]: https://github.com/fedify-dev/hollo/pull/447
[#448]: https://github.com/fedify-dev/hollo/pull/448
-[Fedify debugger]: https://fedify.dev/manual/debug
Version 0.7.13
@@ -331,8 +375,8 @@ Version 0.7.7
Released on March 13, 2026.
- Fixed video thumbnail generation failing for some MP4/MOV files by writing
- the video data to a temporary file instead of piping it via stdin (`pipe:0`),
- which does not support seeking. [[#397], [#398] by NTSK]
+ the video data to a temporary file instead of piping it via stdin
+ (`pipe:0`), which does not support seeking. [[#397], [#398] by NTSK]
[#397]: https://github.com/fedify-dev/hollo/issues/397
[#398]: https://github.com/fedify-dev/hollo/pull/398
@@ -418,6 +462,8 @@ Released on February 10, 2026.
request to the outbox could previously retrieve all posts regardless of
their visibility setting. [[CVE-2026-25808]]
+[CVE-2026-25808]: https://github.com/fedify-dev/hollo/security/advisories/GHSA-6r2w-3pcj-v4v5
+
Version 0.7.1
-------------
@@ -483,7 +529,8 @@ Released on January 24, 2026.
- `GET /api/v2/notifications`: Get paginated grouped notifications with
deduplicated accounts and statuses
- - `GET /api/v2/notifications/:group_key`: Get a specific notification group
+ - `GET /api/v2/notifications/:group_key`: Get a specific notification
+ group
- `GET /api/v2/notifications/:group_key/accounts`: Get all accounts in a
notification group
- `POST /api/v2/notifications/:group_key/dismiss`: Dismiss a notification
@@ -544,24 +591,24 @@ Released on January 24, 2026.
`text/plain`.
- Implemented Mastodon 4.5.0 quote notification types (`quote` and
- `quoted_update`) for improved quote post interaction tracking.
- Users now receive notifications when their posts are quoted by others
- and when posts they've quoted are edited by the original authors.
- Key features include:
-
- - Added `quote` notification type that triggers when someone quotes
- your post, with the notification showing the quote post itself.
- - Added `quoted_update` notification type that triggers when a post
- you quoted is edited, with the notification showing your quote post
- to provide context.
- - Both notification types are non-groupable, meaning each quote or edit
- generates an individual notification for better visibility.
- - Self-quotes (quoting your own posts) do not generate notifications
- to avoid unnecessary noise.
- - Existing quote posts are automatically backfilled with notifications
- during migration to ensure consistent notification history.
- - Added database index on `posts.quote_target_id` for improved query
- performance when looking up quote relationships.
+ `quoted_update`) for improved quote post interaction tracking.
+ Users now receive notifications when their posts are quoted by others
+ and when posts they've quoted are edited by the original authors.
+ Key features include:
+
+ - Added `quote` notification type that triggers when someone quotes
+ your post, with the notification showing the quote post itself.
+ - Added `quoted_update` notification type that triggers when a post
+ you quoted is edited, with the notification showing your quote post
+ to provide context.
+ - Both notification types are non-groupable, meaning each quote or edit
+ generates an individual notification for better visibility.
+ - Self-quotes (quoting your own posts) do not generate notifications
+ to avoid unnecessary noise.
+ - Existing quote posts are automatically backfilled with notifications
+ during migration to ensure consistent notification history.
+ - Added database index on `posts.quote_target_id` for improved query
+ performance when looking up quote relationships.
- Removed dependency on deprecated *fluent-ffmpeg* package and now invoke
ffmpeg binary directly for video screenshot generation. This change
@@ -599,15 +646,15 @@ Released on January 24, 2026.
consistent WebFinger handle validation across v1 and v2 APIs.
[#94]: https://github.com/fedify-dev/hollo/issues/94
-[#210]: https://github.com/fedify-dev/hollo/issues/210
-[#312]: https://github.com/fedify-dev/hollo/issues/312
[#170]: https://github.com/fedify-dev/hollo/issues/170
[#171]: https://github.com/fedify-dev/hollo/pull/171
[#174]: https://github.com/fedify-dev/hollo/pull/174
[#177]: https://github.com/fedify-dev/hollo/issues/177
[#179]: https://github.com/fedify-dev/hollo/pull/179
+[#210]: https://github.com/fedify-dev/hollo/issues/210
[#295]: https://github.com/fedify-dev/hollo/pull/295
[#296]: https://github.com/fedify-dev/hollo/pull/296
+[#312]: https://github.com/fedify-dev/hollo/issues/312
[#333]: https://github.com/fedify-dev/hollo/pull/333
[#334]: https://github.com/fedify-dev/hollo/pull/334
@@ -623,8 +670,6 @@ Released on February 10, 2026.
request to the outbox could previously retrieve all posts regardless of
their visibility setting. [[CVE-2026-25808]]
-[CVE-2026-25808]: https://github.com/fedify-dev/hollo/security/advisories/GHSA-6r2w-3pcj-v4v5
-
Version 0.6.19
--------------
@@ -781,8 +826,8 @@ Version 0.6.8
Released on August 21, 2025.
- Fixed a critical bug introduced in 0.6.7 where the search query would return
- too many results, causing out-of-memory errors and query timeouts. The issue
- was caused by incorrect logical operator precedence when filtering
+ too many results, causing out-of-memory errors and query timeouts. The
+ issue was caused by incorrect logical operator precedence when filtering
future-dated posts. [[#207], [#208] by aliceif]
[#207]: https://github.com/fedify-dev/hollo/issues/207
@@ -812,6 +857,8 @@ Released on August 8, 2025.
fix [CVE-2025-54888] that addresses an authentication bypass
vulnerability allowing actor impersonation. [[CVE-2025-54888]]
+[CVE-2025-54888]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-6jcc-xgcr-q3h4
+
Version 0.6.5
-------------
@@ -834,10 +881,10 @@ Version 0.6.4
Released on July 7, 2025.
- - Fixed a regression bug where follower-only posts were returning `404 Not
- Found` errors when accessed through conversation threads. This was caused
- by improper OAuth scope checking that only accepted `read:statuses` scope
- but tokens contain `read` scope: [[#169], [#172]]
+ - Fixed a regression bug where follower-only posts were returning
+ `404 Not Found` errors when accessed through conversation threads. This was
+ caused by improper OAuth scope checking that only accepted `read:statuses`
+ scope but tokens contain `read` scope: [[#169], [#172]]
- `GET /api/v1/statuses/:id`
- `GET /api/v1/statuses/:id/context`
@@ -900,9 +947,9 @@ Released on June 5, 2025.
- Deprecated `FS_ASSET_PATH` in favor of `FS_STORAGE_PATH`.
- Deprecated `ASSET_URL_BASE` in favor of `STORAGE_URL_BASE`.
- - Implemented OAuth 2.0 Authorization Code flow with support for access grants.
- This improves the security of the OAuth authorization process by separating
- the authorization code from the access token issuance.
+ - Implemented OAuth 2.0 Authorization Code flow with support for access
+ grants. This improves the security of the OAuth authorization process by
+ separating the authorization code from the access token issuance.
[[#130] by Emelia Smith]
- Hollo now requires the `SECRET_KEY` environment variable to be at least 44
@@ -948,10 +995,11 @@ Released on June 5, 2025.
authorization code interception attacks in the OAuth authorization flow.
[[#155] by Emelia Smith]
- - Added support for the `profile` OAuth scope for enhanced user authentication.
- This allows applications to request limited profile information using the
- new `/oauth/userinfo` endpoint and enables the `profile` scope to be used
- with the `GET /api/v1/accounts/verify_credentials` endpoint.
+ - Added support for the `profile` OAuth scope for enhanced user
+ authentication. This allows applications to request limited profile
+ information using the new `/oauth/userinfo` endpoint and enables the
+ `profile` scope to be used with the
+ `GET /api/v1/accounts/verify_credentials` endpoint.
[[#45], [#156] by Emelia Smith]
- Made few Mastodon API endpoints publicly accessible without
@@ -969,6 +1017,7 @@ Released on June 5, 2025.
[complete list of supported languages]: https://shiki.style/languages
[#45]: https://github.com/fedify-dev/hollo/issues/45
[#50]: https://github.com/fedify-dev/hollo/issues/50
+[#99]: https://github.com/fedify-dev/hollo/issues/99
[#110]: https://github.com/fedify-dev/hollo/pull/110
[#111]: https://github.com/fedify-dev/hollo/issues/111
[#114]: https://github.com/fedify-dev/hollo/pull/114
@@ -1049,7 +1098,7 @@ Version 0.5.2
Released on February 20, 2025.
-- Fixed a bug where the `follows.follower_id` column had not referenced the
+ - Fixed a bug where the `follows.follower_id` column had not referenced the
`accounts.id` column. [[#112]]
- Fixed a bug where `GET /api/v1/notifications` had returned server errors
@@ -1060,6 +1109,9 @@ Released on February 20, 2025.
- Upgrade Fedify to 1.4.2.
+[#112]: https://github.com/fedify-dev/hollo/issues/112
+[#113]: https://github.com/fedify-dev/hollo/issues/113
+
Version 0.5.1
-------------
@@ -1069,6 +1121,8 @@ Released on February 14, 2025.
- Fixed a bug where `GET /api/v1/accounts/:id/statuses` had tried to fetch
remote posts for local accounts. [[#107]]
+[#107]: https://github.com/fedify-dev/hollo/issues/107
+
Version 0.5.0
-------------
@@ -1113,16 +1167,15 @@ Released on February 12, 2025.
- The `S3_REGION` environment variable became required if `DRIVE_DISK` is set
to `s3`. [[#95]]
+[`GET /api/v1/mutes`]: https://docs.joinmastodon.org/methods/mutes/#get
+[`GET /api/v1/blocks`]: https://docs.joinmastodon.org/methods/blocks/#get
[#95]: https://github.com/fedify-dev/hollo/issues/95
-[#99]: https://github.com/fedify-dev/hollo/issues/99
[#100]: https://github.com/fedify-dev/hollo/pull/100
[#101]: https://github.com/fedify-dev/hollo/issues/101
[#103]: https://github.com/fedify-dev/hollo/issues/103
[#104]: https://github.com/fedify-dev/hollo/issues/104
[#105]: https://github.com/fedify-dev/hollo/pull/105
[#106]: https://github.com/fedify-dev/hollo/pull/106
-[`GET /api/v1/mutes`]: https://docs.joinmastodon.org/methods/mutes/#get
-[`GET /api/v1/blocks`]: https://docs.joinmastodon.org/methods/blocks/#get
Version 0.4.12
@@ -1186,9 +1239,6 @@ Released on February 20, 2025.
- Upgrade Fedify to 1.3.9.
-[#112]: https://github.com/fedify-dev/hollo/issues/112
-[#113]: https://github.com/fedify-dev/hollo/issues/113
-
Version 0.4.7
-------------
@@ -1240,8 +1290,11 @@ Version 0.4.4
Released on January 21, 2025.
- - Upgrade Fedify to 1.3.4, which includes [security
- fixes][@fedify-dev/fedify#200]. [[CVE-2025-23221]]
+ - Upgrade Fedify to 1.3.4, which includes
+ [security fixes][@fedify-dev/fedify#200]. [[CVE-2025-23221]]
+
+[@fedify-dev/fedify#200]: https://github.com/fedify-dev/fedify/discussions/200
+[CVE-2025-23221]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-c59p-wq67-24wx
Version 0.4.3
@@ -1254,8 +1307,8 @@ Released on January 11, 2025.
- Fixed a bug where importing follows from CSV generated by Iceshrimp had
failed. [[#85]]
-[#92]: https://github.com/fedify-dev/hollo/issues/92
[#85]: https://github.com/fedify-dev/hollo/issues/85
+[#92]: https://github.com/fedify-dev/hollo/issues/92
Version 0.4.2
@@ -1328,8 +1381,6 @@ Released on August 8, 2025.
fix [CVE-2025-54888] that addresses an authentication bypass
vulnerability allowing actor impersonation. [[CVE-2025-54888]]
-[CVE-2025-54888]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-6jcc-xgcr-q3h4
-
Version 0.3.10
--------------
@@ -1375,19 +1426,14 @@ Released on February 14, 2025.
remote posts for local accounts. [[#107]]
- Upgrade Fedify to 1.3.8.
-[#107]: https://github.com/fedify-dev/hollo/issues/107
-
Version 0.3.6
-------------
Released on January 21, 2025.
- - Upgrade Fedify to 1.3.4, which includes [security
- fixes][@fedify-dev/fedify#200]. [[CVE-2025-23221]]
-
-[@fedify-dev/fedify#200]: https://github.com/fedify-dev/fedify/discussions/200
-[CVE-2025-23221]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-c59p-wq67-24wx
+ - Upgrade Fedify to 1.3.4, which includes
+ [security fixes][@fedify-dev/fedify#200]. [[CVE-2025-23221]]
Version 0.3.5
@@ -1421,6 +1467,8 @@ Released on December 19, 2024.
- Fixed a bug where generated thumbnails had been cropped incorrectly
if the original image had not the EXIF orientation metadata. [[#76]]
+[#76]: https://github.com/fedify-dev/hollo/issues/76
+
Version 0.3.2
-------------
@@ -1435,7 +1483,6 @@ Released on December 18, 2024.
- Upgrade Fedify to 1.3.2.
-[#76]: https://github.com/fedify-dev/hollo/issues/76
[#78]: https://github.com/fedify-dev/hollo/issues/78
@@ -1532,6 +1579,8 @@ Released on November 4, 2024.
Sharkey, Akkoma) had empty `url` fields, causing them to be displayed
incorrectly in client apps. [[#58]]
+[#58]: https://github.com/fedify-dev/hollo/issues/58
+
Version 0.2.0
-------------
@@ -1590,8 +1639,6 @@ Released on November 4, 2024.
Sharkey, Akkoma) had empty `url` fields, causing them to be displayed
incorrectly in client apps. [[#58]]
-[#58]: https://github.com/fedify-dev/hollo/issues/58
-
Version 0.1.6
-------------
@@ -1600,8 +1647,8 @@ Released on October 30, 2024.
- Fixed a bug where followers-only posts from accounts that had had set
their follower lists to private had been recognized as direct messages.
- Even after upgrading to this version, such accounts need to be force-refreshed
- from the administration dashboard to fix the issue.
+ Even after upgrading to this version, such accounts need to be
+ force-refreshed from the administration dashboard to fix the issue.
- Fixed the federated (public) timeline showing the shared posts from
the blocked or muted accounts.
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 00000000..6be50152
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,354 @@
+Hollo design system
+===================
+
+This document defines the visual design language and front-end conventions
+of Hollo's web pages. Hollo is primarily a headless ActivityPub server, but
+it ships a small surface of server-rendered HTML pages — the admin
+dashboard, account profiles, individual posts, the OAuth consent screen,
+and a handful of utility screens. This document specifies how those pages
+should look, feel, and be implemented.
+
+The design language values *simplicity* and *modernness* over decoration.
+Surfaces are achromatic by default; color only enters the page through
+the account owner's chosen *theme color*, which tints the profile and
+post pages of that account. The visual center of every page is the
+content the user came for, not the chrome around it.
+
+
+Brand identity
+--------------
+
+The name *Hollo* is always written with a capital “H” and lowercase
+“ollo”. The mark is a circular badge with the wordmark “Hollo” inside,
+distributed as two SVGs at the project root:
+
+ - *logo-black.svg* — for use on light backgrounds.
+ - *logo-white.svg* — for use on dark backgrounds.
+
+Both files are served from */public/* and embedded inline only when an
+icon-sized rendering is needed. Don't recolor the mark; switch between
+black and white based on the surrounding surface.
+
+The voice of the UI is short and matter-of-fact. Prefer plain English
+sentences over imperative shouts; trust the reader.
+
+
+Design principles
+-----------------
+
+ - *Simplicity*: prefer fewer controls, fewer borders, fewer surfaces.
+ Visual hierarchy comes from typography and spacing, not from boxes.
+ - *Modernness*: use modern CSS features (logical properties, OKLCH
+ color, container queries when needed) but never at the cost of
+ progressive degradation.
+ - *Content first*: text columns stay at a comfortable measure. Media
+ expands only when it earns the room.
+ - *Lightweight SSR*: pages are server-rendered with Hono JSX and ship
+ zero client-side JavaScript by default. Never reach for a runtime
+ framework to solve a styling problem that CSS can answer.
+ - *Accessibility*: every interactive element is keyboard-reachable, has
+ a visible focus state, and meets WCAG AA contrast in both light and
+ dark color schemes.
+
+
+Color system
+------------
+
+### Neutral palette
+
+The default surface is achromatic. Hollo uses UnoCSS's *Wind4* neutral
+scale (`neutral-50` through `neutral-950`) for backgrounds, borders,
+surfaces, and text. No saturation enters a page until a theme color is
+applied.
+
+| Role | Light scheme | Dark scheme |
+| --------------- | ------------- | ------------- |
+| Page background | `neutral-50` | `neutral-950` |
+| Surface | `white` | `neutral-900` |
+| Subtle border | `neutral-200` | `neutral-800` |
+| Body text | `neutral-900` | `neutral-100` |
+| Muted text | `neutral-500` | `neutral-400` |
+
+### Account theme colors
+
+Each account owner picks a theme color from a fixed set of twenty named
+hues, defined as the `theme_color` PostgreSQL enum in *src/schema.ts*:
+
+~~~~
+amber azure blue cyan fuchsia green grey indigo
+jade lime orange pink pumpkin purple red sand
+slate violet yellow zinc
+~~~~
+
+The palette comes from Pico CSS's named color palette. Each hue is
+expressed at nine tonal stops (`50`, `100`, `200`, `300`, `400`, `500`,
+`600`, `700`, `800`, `900`), stored as RGB triples in
+*src/theme/colors.ts*.
+
+### CSS variable injection
+
+The theme color is applied through CSS custom properties on the
+`` element. *Layout.tsx* reads the account's `themeColor` and
+emits inline declarations:
+
+~~~~ html
+
+~~~~
+
+The UnoCSS configuration exposes these variables as a generic `brand`
+color token:
+
+~~~~ ts
+theme: {
+ colors: {
+ brand: {
+ 50: "rgb(var(--theme-50))",
+ // ... 100 through 900
+ DEFAULT: "rgb(var(--theme-500))",
+ },
+ },
+}
+~~~~
+
+This means components write `bg-brand`, `text-brand-700`,
+`border-brand-200`, and so on, without ever knowing which of the twenty
+hues is currently active. No safelist is needed because no class name
+varies with the theme color.
+
+### Alpha modifiers
+
+Wind4 wraps every brand-colored utility in
+`color-mix(in srgb, ... var(--un-bg-opacity), transparent)`, so a
+`--un-bg-opacity` (and the matching `--un-text-opacity`,
+`--un-border-opacity`, `--un-ring-opacity`, `--un-divide-opacity`,
+`--un-placeholder-opacity`) custom property must be defined before any
+brand utility resolves. *uno.config.ts* sets all of these to `100%` on
+`:root` via a preflight, which makes plain `bg-brand-500` behave like
+fully opaque rgb.
+
+Slash modifiers work as expected on top of this default:
+`bg-brand-500/50`, `text-brand-700/80`, `ring-brand-200/40`, and so on
+resolve to a 50%/80%/40% mix against transparent.
+
+### Dark mode
+
+Dark mode follows the operating system via `prefers-color-scheme: dark`.
+There is no manual toggle in the first pass; that may be added later
+without changing the underlying tokens. All component recipes specify
+both light and dark variants up front.
+
+
+Typography
+----------
+
+### Type families
+
+| Role | Family | Source |
+| -------- | ---------------------------------------------- | ------------------------- |
+| Sans | *Inter* | bunny.net (Google mirror) |
+| Sans CJK | *Noto Sans KR*, *Noto Sans JP*, *Noto Sans SC* | bunny.net |
+| Mono | *JetBrains Mono* | bunny.net |
+
+Fonts are loaded through UnoCSS's `presetWebFonts` with the `bunny`
+provider, which is a privacy-respecting mirror of Google Fonts. The CSS
+font stack lists Inter first, then the three Noto Sans CJK families, and
+falls back to the system stack so initial paint never blocks on a
+network request.
+
+### Type scale
+
+Use the Wind4 default scale unchanged (`text-xs` through `text-5xl`).
+Body copy is `text-base` with `leading-relaxed`. Headings step down by
+one level per nesting depth.
+
+### Long-form content
+
+Rendered Markdown — post bodies, account bio fields, reply chains — is
+wrapped in the `prose` class from `presetTypography`, with
+`prose-neutral` and `dark:prose-invert` variants. Inline code uses the
+mono family; block code is rendered through Shiki and keeps its own
+colors.
+
+
+Spacing and layout
+------------------
+
+The spacing scale is Wind4's default 4 px grid. Use multiples of `2`
+(`0.5rem`), `3`, `4`, `6`, `8`, `12`, and `16` for almost all gaps.
+
+Page widths:
+
+ - Reading column (post body, profile bio, settings forms):
+ `max-w-2xl` (~42 rem).
+ - Dashboard column (timelines, account list): `max-w-3xl` (~48 rem).
+ - Wide chrome (top nav, footer): full width with internal `max-w-5xl`.
+
+Breakpoints follow the Wind4 defaults (`sm` 640 px, `md` 768 px, `lg`
+1024 px, `xl` 1280 px). Mobile is the design start point; widen by
+adding `md:` and `lg:` variants.
+
+
+Iconography
+-----------
+
+Hollo uses a single icon collection: *Lucide*, surfaced through
+UnoCSS's `presetIcons` with the *@iconify-json/lucide* package. Icons
+are written as CSS classes:
+
+~~~~ tsx
+
+~~~~
+
+Sizing follows the surrounding text size by default (`1em`). Icons
+inherit `currentColor`, so they tint with the parent's text color
+(including the theme color where applicable). Decorative icons get
+`aria-hidden="true"`; meaningful icons have a paired text label or
+`aria-label`.
+
+
+Components
+----------
+
+### Button
+
+Three visual ranks exist:
+
+ - *primary*: solid theme-colored background, white text. At most
+ one per pane.
+ - *secondary*: neutral surface, neutral border, theme-colored text on
+ hover.
+ - *ghost*: no background, text-only; used in dense toolbars and inline
+ actions.
+
+A *danger* variant in red is available for destructive submits. Sizes
+are *sm*, *md* (default), and *lg*. All buttons share the same focus
+ring and disabled state.
+
+### Form field
+
+Each field is a labelled stack: label → control → optional hint or
+error. Labels are above the control, never floating. Required fields
+get a small “required” badge to the right of the label rather than an
+asterisk. Errors are red and live below the control.
+
+### Top nav
+
+The dashboard's top nav is a single row: logo on the left, primary
+navigation in the center, and the account chip + sign-out button on the
+right. On small screens the center links collapse into a sheet.
+
+### Card and article
+
+Posts and notifications are rendered as `` elements — semantic
+HTML, no visible card border. A subtle bottom divider separates each
+entry in a list. Avatar, display name, handle, timestamp, and content
+stack vertically on mobile and arrange into a media object on `md`.
+
+### Avatar
+
+Always circular. Sizes: *sm* 1.5 rem, *md* 2.5 rem, *lg* 4 rem,
+*xl* 6 rem. Profile headers use *xl*; comment threads use *sm* or *md*.
+
+### Footer
+
+A single line of muted text at the bottom of every page: software name,
+version, and a link to the source. No social icons.
+
+### Empty state
+
+Centered icon (Lucide), one-line headline, optional subline, optional
+primary call-to-action. No illustrations.
+
+
+Motion
+------
+
+Motion is reserved for state feedback, not decoration. Allowed
+transitions:
+
+ - `colors`: 150 ms, on hover and focus changes.
+ - `opacity`: 150 ms, on disclosure toggles.
+ - `transform: scale`: 100 ms, on press of a button.
+
+Disable all transitions when `prefers-reduced-motion: reduce` is set.
+Page transitions and route animations don't exist; SSR renders the new
+page directly.
+
+
+Accessibility
+-------------
+
+ - Maintain WCAG AA contrast in both light and dark schemes. Theme
+ colors pass contrast against neutral surfaces at the `600` and
+ `700` stops; lighter stops are background-only.
+ - Every focusable element has a visible `:focus-visible` ring (a 2 px
+ ring in `brand-500` or `neutral-500`, offset by 2 px).
+ - Use semantic HTML: `