Skip to content
Merged
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
156c392
Document the front-end design system
dahlia Apr 28, 2026
5ca754f
Introduce the UnoCSS toolchain
dahlia Apr 28, 2026
ca90f6c
Migrate the layout shell to UnoCSS and remove Pico CSS
dahlia Apr 28, 2026
119060e
Fix UnoCSS color generation and stylesheet caching
dahlia Apr 28, 2026
1970f3e
Redesign the login, setup, and OTP pages
dahlia Apr 28, 2026
21c5c76
Redesign the public home page
dahlia Apr 28, 2026
7357d0b
Redesign the profile page
dahlia Apr 28, 2026
7df79bb
Redesign the post component
dahlia Apr 28, 2026
22d1912
Redesign the account management screens
dahlia Apr 28, 2026
783baae
Redesign the custom emojis pages
dahlia Apr 28, 2026
8fc2b4d
Redesign the federation page
dahlia Apr 28, 2026
6c68bbf
Redesign the thumbnail cleanup page
dahlia Apr 28, 2026
9ba0615
Redesign the public hashtag page
dahlia Apr 28, 2026
39d1233
Redesign the OAuth authorization screens
dahlia Apr 28, 2026
8579507
Redesign the dashboard auth page
dahlia Apr 28, 2026
d2bb828
Redesign the migrate account page
dahlia Apr 28, 2026
9ea04fc
Extract reusable form primitives
dahlia Apr 28, 2026
8dd6f16
Adopt form primitives in the auth forms
dahlia Apr 28, 2026
dd2efff
Adopt form primitives in AccountForm
dahlia Apr 28, 2026
b21ac2e
Replace theme color picker with a swatch grid
dahlia Apr 28, 2026
42f7b10
Re-enable the variant group transformer
dahlia Apr 28, 2026
88f487d
Document the brand color alpha behaviour
dahlia Apr 28, 2026
19ef762
Render link preview cards in posts
dahlia Apr 29, 2026
523b07c
Tighten the single-post permalink page
dahlia Apr 29, 2026
a130f8c
Fix theme color picker click feedback
dahlia Apr 29, 2026
00d7697
Force solid 1px borders on form inputs
dahlia Apr 29, 2026
2bb5362
Show a pointer cursor on buttons
dahlia Apr 29, 2026
55170fd
Rename the Defaults section to Preferences
dahlia Apr 29, 2026
ea4e7f1
Style the emoji image upload as a dropzone
dahlia Apr 29, 2026
8740296
Unify the account form shape with the other forms
dahlia Apr 29, 2026
3c4c0af
Match input typography across forms
dahlia Apr 29, 2026
229e57f
Stop emitting variant group shorthand into HTML
dahlia Apr 29, 2026
7e2ebe9
Balance the section heading spacing in FieldSection
dahlia Apr 29, 2026
ee1cf10
Show @ and @host chips around the username field
dahlia Apr 29, 2026
31d9c9e
Lean on the brand color in profile bios, stats, and tags
dahlia Apr 29, 2026
ae88c91
Tint text selection with the active brand color
dahlia Apr 29, 2026
1f8c65a
Fix divide-y borders in dark mode
dahlia Apr 29, 2026
133cebb
Dim primary buttons in dark mode
dahlia Apr 29, 2026
5a5fc5b
Note the front-end redesign in CHANGES.md
dahlia Apr 29, 2026
69a1c49
Switch DESIGN.md code tokens from italics to backticks
dahlia Apr 29, 2026
1e2eaed
Fix tests broken by the front-end redesign
dahlia Apr 29, 2026
d490c96
Scope the screenshot ignore rule to the repository root
dahlia Apr 29, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ node_modules/
tmp/
mise.local.toml
**/skills/npm-*
src/public/uno.css
.playwright-mcp/
*.png
Comment thread
dahlia marked this conversation as resolved.
Outdated

coverage/**
!coverage/.gitkeep
Expand Down
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<html>`),
- 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
Expand Down
173 changes: 110 additions & 63 deletions CHANGES.md

Large diffs are not rendered by default.

356 changes: 356 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,356 @@
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
`<html>` element. *Layout.tsx* reads the account's *themeColor* and
emits inline declarations:

~~~~ html
<html style="--theme-50:247 248 250; --theme-100:...; ... --theme-900:...">
~~~~

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
<span class="i-lucide-bell text-lg" aria-hidden="true" />
~~~~

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 *articles* — 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

Centred 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: *button* for buttons, *a* for navigation,
*form*/*fieldset*/*label* for forms, *article* for individual posts,
*nav* for navigation regions.
- The *html* element's *lang* attribute reflects the page locale where
a content language is known.
- Decorative imagery uses *alt=“”* or *aria-hidden*. Meaningful
imagery has descriptive alternative text.


Implementation guide
--------------------

### Stylesheet pipeline

UnoCSS scans every *.tsx* and *.ts* file under *src/* via *@unocss/cli*
and writes a single static stylesheet to *src/public/uno.css*. In
development, *concurrently* runs the UnoCSS watcher alongside *tsx
watch*. In production, *pnpm build* runs *unocss* once before *tsdown*.
The stylesheet is served as a static asset, not bundled with the
application code, and the Layout component links it like any other
*.css* file.

### Layout shell

*src/components/Layout.tsx* is the only place that emits *html*, *head*,
and *body*. Pages return a Layout-wrapped tree from their handler.
Layout is responsible for:

1. Linking */public/uno.css*.
2. Computing the inline CSS variable string from the requested
*themeColor* and putting it on *html*.
3. Setting *lang*, page metadata (title, OG tags, canonical), and
favicons.

### Theme tokens

*src/theme/colors.ts* exports a frozen object mapping each
*ThemeColor* enum value to its 50–900 RGB triples. When changing the
palette, edit only this file; nothing else needs to know about the
twenty hues by name.

### Forms

Forms use the small helper components in *src/components/forms.tsx*:

- *Field* — wraps a control with a label, optional hint, and error
- *TextField*, *TextareaField*, *SelectField* — labelled controls
- *CheckboxField* — checkbox with adjacent label and hint
- *FieldSection* — card-shaped *fieldset* with a legend
- *SubmitButton* — primary/secondary/danger submit variants

These compose plain HTML controls with the agreed UnoCSS classes; they
never wrap a third-party input library. Reach for them first; only
hand-roll a new control when the form needs geometry the helpers can't
express (the OTP field, for example, intentionally diverges).

### Prose content

Apply the *prose prose-neutral dark:prose-invert* class set to the
container that holds rendered Markdown. Don't apply *prose* to
arbitrary blocks of UI; it is intended for long-form text only.

### Variant groups

The UnoCSS *variantGroup* transformer is enabled. Group related
variants:

~~~~ tsx
<button class="rounded-md px-4 py-2 (bg-brand text-white hover:bg-brand-600 focus-visible:ring-2 focus-visible:ring-brand-500)">
~~~~

Use this judiciously; long flat class lists are still fine when they
are easier to read.
Loading
Loading