|
| 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