Skip to content

Commit b3106e6

Browse files
authored
Merge pull request #458 from dahlia/redesign
Redesign the server-rendered front-end
2 parents 27e1794 + d490c96 commit b3106e6

59 files changed

Lines changed: 4393 additions & 1707 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ node_modules/
77
tmp/
88
mise.local.toml
99
**/skills/npm-*
10+
src/public/uno.css
11+
.playwright-mcp/
12+
/*.png
1013

1114
coverage/**
1215
!coverage/.gitkeep

AGENTS.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ Key architectural components
126126
| *src/federation/index.ts* | Federation setup with inbox listeners |
127127
| *src/oauth/middleware.ts* | Authentication middleware |
128128
| *src/entities/status.ts* | Status entity serialization |
129+
| *DESIGN.md* | Design system and front-end conventions |
129130

130131

131132
Technology stack
@@ -187,6 +188,24 @@ export function MyComponent({ name }: { name: string }) {
187188
import React from 'react'; // Don't do this
188189
~~~~
189190

191+
### Design system and front-end conventions
192+
193+
When working on any user-facing page (admin dashboard, profile, post,
194+
auth, OAuth screens, etc.), read *DESIGN.md* first. It defines:
195+
196+
- the visual design principles (simplicity, modernness, content first,
197+
lightweight SSR, accessibility),
198+
- the color system (achromatic neutrals plus per-account theme color
199+
via CSS custom properties on `<html>`),
200+
- typography, spacing, iconography, and component recipes,
201+
- the UnoCSS toolchain conventions (preset choices, prose application
202+
areas, theme token injection, variant groups).
203+
204+
Treat *DESIGN.md* as the single source of truth for front-end decisions
205+
that aren't directly answered by the source code. Never introduce ad-hoc
206+
CSS or inline styling that contradicts it; if the document is missing
207+
guidance on a real case, update *DESIGN.md* in the same change.
208+
190209
### Database guidelines
191210

192211
- *Migrations*: Always generate migrations for schema changes

CHANGES.md

Lines changed: 110 additions & 63 deletions
Large diffs are not rendered by default.

DESIGN.md

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
Hollo design system
2+
===================
3+
4+
This document defines the visual design language and front-end conventions
5+
of Hollo's web pages. Hollo is primarily a headless ActivityPub server, but
6+
it ships a small surface of server-rendered HTML pages — the admin
7+
dashboard, account profiles, individual posts, the OAuth consent screen,
8+
and a handful of utility screens. This document specifies how those pages
9+
should look, feel, and be implemented.
10+
11+
The design language values *simplicity* and *modernness* over decoration.
12+
Surfaces are achromatic by default; color only enters the page through
13+
the account owner's chosen *theme color*, which tints the profile and
14+
post pages of that account. The visual center of every page is the
15+
content the user came for, not the chrome around it.
16+
17+
18+
Brand identity
19+
--------------
20+
21+
The name *Hollo* is always written with a capital “H” and lowercase
22+
“ollo”. The mark is a circular badge with the wordmark “Hollo” inside,
23+
distributed as two SVGs at the project root:
24+
25+
- *logo-black.svg* — for use on light backgrounds.
26+
- *logo-white.svg* — for use on dark backgrounds.
27+
28+
Both files are served from */public/* and embedded inline only when an
29+
icon-sized rendering is needed. Don't recolor the mark; switch between
30+
black and white based on the surrounding surface.
31+
32+
The voice of the UI is short and matter-of-fact. Prefer plain English
33+
sentences over imperative shouts; trust the reader.
34+
35+
36+
Design principles
37+
-----------------
38+
39+
- *Simplicity*: prefer fewer controls, fewer borders, fewer surfaces.
40+
Visual hierarchy comes from typography and spacing, not from boxes.
41+
- *Modernness*: use modern CSS features (logical properties, OKLCH
42+
color, container queries when needed) but never at the cost of
43+
progressive degradation.
44+
- *Content first*: text columns stay at a comfortable measure. Media
45+
expands only when it earns the room.
46+
- *Lightweight SSR*: pages are server-rendered with Hono JSX and ship
47+
zero client-side JavaScript by default. Never reach for a runtime
48+
framework to solve a styling problem that CSS can answer.
49+
- *Accessibility*: every interactive element is keyboard-reachable, has
50+
a visible focus state, and meets WCAG AA contrast in both light and
51+
dark color schemes.
52+
53+
54+
Color system
55+
------------
56+
57+
### Neutral palette
58+
59+
The default surface is achromatic. Hollo uses UnoCSS's *Wind4* neutral
60+
scale (`neutral-50` through `neutral-950`) for backgrounds, borders,
61+
surfaces, and text. No saturation enters a page until a theme color is
62+
applied.
63+
64+
| Role | Light scheme | Dark scheme |
65+
| --------------- | ------------- | ------------- |
66+
| Page background | `neutral-50` | `neutral-950` |
67+
| Surface | `white` | `neutral-900` |
68+
| Subtle border | `neutral-200` | `neutral-800` |
69+
| Body text | `neutral-900` | `neutral-100` |
70+
| Muted text | `neutral-500` | `neutral-400` |
71+
72+
### Account theme colors
73+
74+
Each account owner picks a theme color from a fixed set of twenty named
75+
hues, defined as the `theme_color` PostgreSQL enum in *src/schema.ts*:
76+
77+
~~~~
78+
amber azure blue cyan fuchsia green grey indigo
79+
jade lime orange pink pumpkin purple red sand
80+
slate violet yellow zinc
81+
~~~~
82+
83+
The palette comes from Pico CSS's named color palette. Each hue is
84+
expressed at nine tonal stops (`50`, `100`, `200`, `300`, `400`, `500`,
85+
`600`, `700`, `800`, `900`), stored as RGB triples in
86+
*src/theme/colors.ts*.
87+
88+
### CSS variable injection
89+
90+
The theme color is applied through CSS custom properties on the
91+
`<html>` element. *Layout.tsx* reads the account's `themeColor` and
92+
emits inline declarations:
93+
94+
~~~~ html
95+
<html style="--theme-50:247 248 250; --theme-100:...; ... --theme-900:...">
96+
~~~~
97+
98+
The UnoCSS configuration exposes these variables as a generic `brand`
99+
color token:
100+
101+
~~~~ ts
102+
theme: {
103+
colors: {
104+
brand: {
105+
50: "rgb(var(--theme-50))",
106+
// ... 100 through 900
107+
DEFAULT: "rgb(var(--theme-500))",
108+
},
109+
},
110+
}
111+
~~~~
112+
113+
This means components write `bg-brand`, `text-brand-700`,
114+
`border-brand-200`, and so on, without ever knowing which of the twenty
115+
hues is currently active. No safelist is needed because no class name
116+
varies with the theme color.
117+
118+
### Alpha modifiers
119+
120+
Wind4 wraps every brand-colored utility in
121+
`color-mix(in srgb, ... var(--un-bg-opacity), transparent)`, so a
122+
`--un-bg-opacity` (and the matching `--un-text-opacity`,
123+
`--un-border-opacity`, `--un-ring-opacity`, `--un-divide-opacity`,
124+
`--un-placeholder-opacity`) custom property must be defined before any
125+
brand utility resolves. *uno.config.ts* sets all of these to `100%` on
126+
`:root` via a preflight, which makes plain `bg-brand-500` behave like
127+
fully opaque rgb.
128+
129+
Slash modifiers work as expected on top of this default:
130+
`bg-brand-500/50`, `text-brand-700/80`, `ring-brand-200/40`, and so on
131+
resolve to a 50%/80%/40% mix against transparent.
132+
133+
### Dark mode
134+
135+
Dark mode follows the operating system via `prefers-color-scheme: dark`.
136+
There is no manual toggle in the first pass; that may be added later
137+
without changing the underlying tokens. All component recipes specify
138+
both light and dark variants up front.
139+
140+
141+
Typography
142+
----------
143+
144+
### Type families
145+
146+
| Role | Family | Source |
147+
| -------- | ---------------------------------------------- | ------------------------- |
148+
| Sans | *Inter* | bunny.net (Google mirror) |
149+
| Sans CJK | *Noto Sans KR*, *Noto Sans JP*, *Noto Sans SC* | bunny.net |
150+
| Mono | *JetBrains Mono* | bunny.net |
151+
152+
Fonts are loaded through UnoCSS's `presetWebFonts` with the `bunny`
153+
provider, which is a privacy-respecting mirror of Google Fonts. The CSS
154+
font stack lists Inter first, then the three Noto Sans CJK families, and
155+
falls back to the system stack so initial paint never blocks on a
156+
network request.
157+
158+
### Type scale
159+
160+
Use the Wind4 default scale unchanged (`text-xs` through `text-5xl`).
161+
Body copy is `text-base` with `leading-relaxed`. Headings step down by
162+
one level per nesting depth.
163+
164+
### Long-form content
165+
166+
Rendered Markdown — post bodies, account bio fields, reply chains — is
167+
wrapped in the `prose` class from `presetTypography`, with
168+
`prose-neutral` and `dark:prose-invert` variants. Inline code uses the
169+
mono family; block code is rendered through Shiki and keeps its own
170+
colors.
171+
172+
173+
Spacing and layout
174+
------------------
175+
176+
The spacing scale is Wind4's default 4 px grid. Use multiples of `2`
177+
(`0.5rem`), `3`, `4`, `6`, `8`, `12`, and `16` for almost all gaps.
178+
179+
Page widths:
180+
181+
- Reading column (post body, profile bio, settings forms):
182+
`max-w-2xl` (~42 rem).
183+
- Dashboard column (timelines, account list): `max-w-3xl` (~48 rem).
184+
- Wide chrome (top nav, footer): full width with internal `max-w-5xl`.
185+
186+
Breakpoints follow the Wind4 defaults (`sm` 640 px, `md` 768 px, `lg`
187+
1024 px, `xl` 1280 px). Mobile is the design start point; widen by
188+
adding `md:` and `lg:` variants.
189+
190+
191+
Iconography
192+
-----------
193+
194+
Hollo uses a single icon collection: *Lucide*, surfaced through
195+
UnoCSS's `presetIcons` with the *@iconify-json/lucide* package. Icons
196+
are written as CSS classes:
197+
198+
~~~~ tsx
199+
<span class="i-lucide-bell text-lg" aria-hidden="true" />
200+
~~~~
201+
202+
Sizing follows the surrounding text size by default (`1em`). Icons
203+
inherit `currentColor`, so they tint with the parent's text color
204+
(including the theme color where applicable). Decorative icons get
205+
`aria-hidden="true"`; meaningful icons have a paired text label or
206+
`aria-label`.
207+
208+
209+
Components
210+
----------
211+
212+
### Button
213+
214+
Three visual ranks exist:
215+
216+
- *primary*: solid theme-colored background, white text. At most
217+
one per pane.
218+
- *secondary*: neutral surface, neutral border, theme-colored text on
219+
hover.
220+
- *ghost*: no background, text-only; used in dense toolbars and inline
221+
actions.
222+
223+
A *danger* variant in red is available for destructive submits. Sizes
224+
are *sm*, *md* (default), and *lg*. All buttons share the same focus
225+
ring and disabled state.
226+
227+
### Form field
228+
229+
Each field is a labelled stack: label → control → optional hint or
230+
error. Labels are above the control, never floating. Required fields
231+
get a small “required” badge to the right of the label rather than an
232+
asterisk. Errors are red and live below the control.
233+
234+
### Top nav
235+
236+
The dashboard's top nav is a single row: logo on the left, primary
237+
navigation in the center, and the account chip + sign-out button on the
238+
right. On small screens the center links collapse into a sheet.
239+
240+
### Card and article
241+
242+
Posts and notifications are rendered as `<article>` elements — semantic
243+
HTML, no visible card border. A subtle bottom divider separates each
244+
entry in a list. Avatar, display name, handle, timestamp, and content
245+
stack vertically on mobile and arrange into a media object on `md`.
246+
247+
### Avatar
248+
249+
Always circular. Sizes: *sm* 1.5 rem, *md* 2.5 rem, *lg* 4 rem,
250+
*xl* 6 rem. Profile headers use *xl*; comment threads use *sm* or *md*.
251+
252+
### Footer
253+
254+
A single line of muted text at the bottom of every page: software name,
255+
version, and a link to the source. No social icons.
256+
257+
### Empty state
258+
259+
Centered icon (Lucide), one-line headline, optional subline, optional
260+
primary call-to-action. No illustrations.
261+
262+
263+
Motion
264+
------
265+
266+
Motion is reserved for state feedback, not decoration. Allowed
267+
transitions:
268+
269+
- `colors`: 150 ms, on hover and focus changes.
270+
- `opacity`: 150 ms, on disclosure toggles.
271+
- `transform: scale`: 100 ms, on press of a button.
272+
273+
Disable all transitions when `prefers-reduced-motion: reduce` is set.
274+
Page transitions and route animations don't exist; SSR renders the new
275+
page directly.
276+
277+
278+
Accessibility
279+
-------------
280+
281+
- Maintain WCAG AA contrast in both light and dark schemes. Theme
282+
colors pass contrast against neutral surfaces at the `600` and
283+
`700` stops; lighter stops are background-only.
284+
- Every focusable element has a visible `:focus-visible` ring (a 2 px
285+
ring in `brand-500` or `neutral-500`, offset by 2 px).
286+
- Use semantic HTML: `<button>` for buttons, `<a>` for navigation,
287+
`<form>`/`<fieldset>`/`<label>` for forms, `<article>` for individual
288+
posts, `<nav>` for navigation regions.
289+
- The `<html>` element's `lang` attribute reflects the page locale
290+
where a content language is known.
291+
- Decorative imagery uses `alt=""` or `aria-hidden`. Meaningful
292+
imagery has descriptive alternative text.
293+
294+
295+
Implementation guide
296+
--------------------
297+
298+
### Stylesheet pipeline
299+
300+
UnoCSS scans every *.tsx* and *.ts* file under *src/* via *@unocss/cli*
301+
and writes a single static stylesheet to *src/public/uno.css*. In
302+
development, *concurrently* runs the UnoCSS watcher alongside `tsx watch`. In
303+
production, `pnpm build` runs `unocss` once before `tsdown`. The stylesheet is
304+
served as a static asset, not bundled with the application code, and the Layout
305+
component links it like any other *.css* file.
306+
307+
### Layout shell
308+
309+
*src/components/Layout.tsx* is the only place that emits `<html>`,
310+
`<head>`, and `<body>`. Pages return a Layout-wrapped tree from their
311+
handler. Layout is responsible for:
312+
313+
1. Linking */public/uno.css*.
314+
2. Computing the inline CSS variable string from the requested
315+
`themeColor` and putting it on `<html>`.
316+
3. Setting `lang`, page metadata (title, OG tags, canonical), and
317+
favicons.
318+
319+
### Theme tokens
320+
321+
*src/theme/colors.ts* exports a frozen object mapping each `ThemeColor`
322+
enum value to its 50–900 RGB triples. When changing the palette, edit
323+
only this file; nothing else needs to know about the twenty hues by
324+
name.
325+
326+
### Forms
327+
328+
Forms use the small helper components in *src/components/forms.tsx*:
329+
330+
- `Field` — wraps a control with a label, optional hint, and error
331+
- `TextField`, `TextareaField`, `SelectField` — labelled controls
332+
- `CheckboxField` — checkbox with adjacent label and hint
333+
- `FieldSection` — borderless `<fieldset>` with a legend
334+
- `SubmitButton` — primary/secondary/danger submit variants
335+
336+
These compose plain HTML controls with the agreed UnoCSS classes; they
337+
never wrap a third-party input library. Reach for them first; only
338+
hand-roll a new control when the form needs geometry the helpers can't
339+
express (the OTP field, for example, intentionally diverges).
340+
341+
### Prose content
342+
343+
Apply the `prose prose-neutral dark:prose-invert` class set to the
344+
container that holds rendered Markdown. Don't apply `prose` to
345+
arbitrary blocks of UI; it is intended for long-form text only.
346+
347+
### Variant group syntax
348+
349+
Variant group shorthand (`focus:(border-brand-500 ring-2)`) is **not**
350+
used in this project. `transformerVariantGroup` only expands the
351+
shorthand at class extraction time, but the original string still ships
352+
in the HTML `class` attribute, where the browser splits it on
353+
whitespace and matches `ring-2` (etc.) as a standalone class. Always
354+
write each variant out long-form (`focus:border-brand-500 focus:ring-2 …`).

0 commit comments

Comments
 (0)