@@ -57,22 +57,22 @@ Color system
5757### Neutral palette
5858
5959The default surface is achromatic. Hollo uses UnoCSS's * Wind4* neutral
60- scale (* neutral-50* through * neutral-950* ) for backgrounds, borders,
60+ scale (` neutral-50 ` through ` neutral-950 ` ) for backgrounds, borders,
6161surfaces, and text. No saturation enters a page until a theme color is
6262applied.
6363
6464| Role | Light scheme | Dark scheme |
6565| --------------- | ------------- | ------------- |
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* |
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 ` |
7171
7272### Account theme colors
7373
7474Each 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* :
75+ hues, defined as the ` theme_color ` PostgreSQL enum in * src/schema.ts* :
7676
7777~~~~
7878amber azure blue cyan fuchsia green grey indigo
@@ -81,20 +81,21 @@ slate violet yellow zinc
8181~~~~
8282
8383The palette comes from Pico CSS's named color palette. Each hue is
84- expressed at nine tonal stops (* 50, 100, 200, 300, 400, 500, 600, 700,
85- 800, 900* ), stored as RGB triples in * src/theme/colors.ts* .
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* .
8687
8788### CSS variable injection
8889
8990The theme color is applied through CSS custom properties on the
90- ` <html> ` element. * Layout.tsx* reads the account's * themeColor* and
91+ ` <html> ` element. * Layout.tsx* reads the account's ` themeColor ` and
9192emits inline declarations:
9293
9394~~~~ html
9495<html style =" --theme-50 :247 248 250 ; --theme-100 :...; ... --theme-900 :..." >
9596~~~~
9697
97- The UnoCSS configuration exposes these variables as a generic * brand*
98+ The UnoCSS configuration exposes these variables as a generic ` brand `
9899color token:
99100
100101~~~~ ts
@@ -118,15 +119,16 @@ varies with the theme color.
118119
119120Wind4 wraps every brand-colored utility in
120121` color-mix(in srgb, ... var(--un-bg-opacity), transparent) ` , so a
121- ` --un-bg-opacity ` (and the matching ` --un-text-opacity ` , ` --un-border-opacity ` ,
122- ` --un-ring-opacity ` , ` --un-divide-opacity ` , ` --un-placeholder-opacity ` ) custom
123- property must be defined before any brand utility resolves. * uno.config.ts*
124- sets all of these to ` 100% ` on * : root * via a preflight, which makes plain
125- ` bg-brand-500 ` behave like fully opaque rgb.
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.
126128
127- Slash modifiers work as expected on top of this default: ` bg-brand-500/50 ` ,
128- ` text-brand-700/80 ` , ` ring-brand-200/40 ` , and so on resolve to a 50%/80%/40%
129- mix against transparent.
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.
130132
131133### Dark mode
132134
@@ -147,61 +149,61 @@ Typography
147149| Sans CJK | * Noto Sans KR* , * Noto Sans JP* , * Noto Sans SC* | bunny.net |
148150| Mono | * JetBrains Mono* | bunny.net |
149151
150- Fonts are loaded through UnoCSS's * presetWebFonts* with the * bunny*
152+ Fonts are loaded through UnoCSS's ` presetWebFonts ` with the ` bunny `
151153provider, which is a privacy-respecting mirror of Google Fonts. The CSS
152154font stack lists Inter first, then the three Noto Sans CJK families, and
153155falls back to the system stack so initial paint never blocks on a
154156network request.
155157
156158### Type scale
157159
158- Use the Wind4 default scale unchanged (* text-xs* through * text-5xl* ).
159- Body copy is * text-base* with * leading-relaxed* . Headings step down by
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
160162one level per nesting depth.
161163
162164### Long-form content
163165
164166Rendered Markdown — post bodies, account bio fields, reply chains — is
165- wrapped in the * prose* class from * presetTypography* , with
166- * prose-neutral* and * dark: prose-invert * variants. Inline code uses the
167+ wrapped in the ` prose ` class from ` presetTypography ` , with
168+ ` prose-neutral ` and ` dark:prose-invert ` variants. Inline code uses the
167169mono family; block code is rendered through Shiki and keeps its own
168170colors.
169171
170172
171173Spacing and layout
172174------------------
173175
174- The spacing scale is Wind4's default 4 px grid. Use multiples of * 2 *
175- (* 0.5rem* ), * 3 * , * 4 * , * 6 * , * 8 * , * 12 * , and * 16 * for almost all gaps.
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.
176178
177179Page widths:
178180
179- - Reading column (post body, profile bio, settings forms): * max-w-2xl *
180- (~ 42 rem).
181- - Dashboard column (timelines, account list): * max-w-3xl* (~ 48 rem).
182- - Wide chrome (top nav, footer): full width with internal * max-w-5xl* .
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 ` .
183185
184- Breakpoints follow the Wind4 defaults (* sm * 640 px, * md * 768 px, * lg *
185- 1024 px, * xl * 1280 px). Mobile is the design start point; widen by
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
186188adding ` md: ` and ` lg: ` variants.
187189
188190
189191Iconography
190192-----------
191193
192194Hollo uses a single icon collection: * Lucide* , surfaced through
193- UnoCSS's * presetIcons* with the * @iconify-json/lucide * package. Icons
195+ UnoCSS's ` presetIcons ` with the * @iconify-json/lucide * package. Icons
194196are written as CSS classes:
195197
196198~~~~ tsx
197199<span class = " i-lucide-bell text-lg" aria-hidden = " true" />
198200~~~~
199201
200- Sizing follows the surrounding text size by default (* 1em* ). Icons
201- inherit * currentColor* , so they tint with the parent's text color
202+ Sizing follows the surrounding text size by default (` 1em ` ). Icons
203+ inherit ` currentColor ` , so they tint with the parent's text color
202204(including the theme color where applicable). Decorative icons get
203205` aria-hidden="true" ` ; meaningful icons have a paired text label or
204- * aria-label* .
206+ ` aria-label ` .
205207
206208
207209Components
@@ -224,8 +226,8 @@ ring and disabled state.
224226
225227### Form field
226228
227- Each field is a labelled stack: * label → control → optional hint or
228- error* . Labels are above the control, never floating. Required fields
229+ Each field is a labelled stack: label → control → optional hint or
230+ error. Labels are above the control, never floating. Required fields
229231get a small “required” badge to the right of the label rather than an
230232asterisk. Errors are red and live below the control.
231233
@@ -237,10 +239,10 @@ right. On small screens the center links collapse into a sheet.
237239
238240### Card and article
239241
240- Posts and notifications are rendered as * articles * — semantic HTML, no
241- visible card border. A subtle bottom divider separates each entry in a
242- list. Avatar, display name, handle, timestamp, and content stack
243- vertically on mobile and arrange into a media object on * md * .
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 ` .
244246
245247### Avatar
246248
@@ -254,7 +256,7 @@ version, and a link to the source. No social icons.
254256
255257### Empty state
256258
257- Centred icon (Lucide), one-line headline, optional subline, optional
259+ Centered icon (Lucide), one-line headline, optional subline, optional
258260primary call-to-action. No illustrations.
259261
260262
@@ -264,11 +266,11 @@ Motion
264266Motion is reserved for state feedback, not decoration. Allowed
265267transitions:
266268
267- - * colors* : 150 ms, on hover and focus changes.
268- - * opacity* : 150 ms, on disclosure toggles.
269- - * transform: scale* : 100 ms, on press of a button.
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.
270272
271- Disable all transitions when * prefers-reduced-motion: reduce* is set.
273+ Disable all transitions when ` prefers-reduced-motion: reduce ` is set.
272274Page transitions and route animations don't exist; SSR renders the new
273275page directly.
274276
@@ -277,16 +279,16 @@ Accessibility
277279-------------
278280
279281 - Maintain WCAG AA contrast in both light and dark schemes. Theme
280- colors pass contrast against neutral surfaces at the * 600* and
281- * 700* stops; lighter stops are background-only.
282- - Every focusable element has a visible * focus-visible* ring (a 2 px
283- ring in * brand-500* or * neutral-500* , offset by 2 px).
284- - Use semantic HTML: * button* for buttons, * a * for navigation,
285- * form* / * fieldset* / * label* for forms, * article* for individual posts,
286- * nav* for navigation regions.
287- - The * html* element's * lang* attribute reflects the page locale where
288- a content language is known.
289- - Decorative imagery uses * alt=“” * or * aria-hidden* . Meaningful
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
290292 imagery has descriptive alternative text.
291293
292294
@@ -297,40 +299,39 @@ Implementation guide
297299
298300UnoCSS scans every * .tsx* and * .ts* file under * src/* via * @unocss/cli *
299301and writes a single static stylesheet to * src/public/uno.css* . In
300- development, * concurrently* runs the UnoCSS watcher alongside * tsx
301- watch* . In production, * pnpm build* runs * unocss* once before * tsdown* .
302- The stylesheet is served as a static asset, not bundled with the
303- application code, and the Layout component links it like any other
304- * .css* file.
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.
305306
306307### Layout shell
307308
308- * src/components/Layout.tsx* is the only place that emits * html* , * head * ,
309- and * body* . Pages return a Layout-wrapped tree from their handler.
310- Layout is responsible for:
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:
311312
3123131 . Linking * /public/uno.css* .
3133142 . Computing the inline CSS variable string from the requested
314- * themeColor* and putting it on * html* .
315- 3 . Setting * lang* , page metadata (title, OG tags, canonical), and
315+ ` themeColor ` and putting it on ` < html> ` .
316+ 3 . Setting ` lang ` , page metadata (title, OG tags, canonical), and
316317 favicons.
317318
318319### Theme tokens
319320
320- * src/theme/colors.ts* exports a frozen object mapping each
321- * ThemeColor * enum value to its 50–900 RGB triples. When changing the
322- palette, edit only this file; nothing else needs to know about the
323- twenty hues by name.
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.
324325
325326### Forms
326327
327328Forms use the small helper components in * src/components/forms.tsx* :
328329
329- - * Field* — wraps a control with a label, optional hint, and error
330- - * TextField* , * TextareaField* , * SelectField* — labelled controls
331- - * CheckboxField* — checkbox with adjacent label and hint
332- - * FieldSection* — card-shaped * fieldset* with a legend
333- - * SubmitButton* — primary/secondary/danger submit variants
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
334335
335336These compose plain HTML controls with the agreed UnoCSS classes; they
336337never wrap a third-party input library. Reach for them first; only
@@ -339,18 +340,15 @@ express (the OTP field, for example, intentionally diverges).
339340
340341### Prose content
341342
342- Apply the * prose prose-neutral dark: prose-invert * class set to the
343- container that holds rendered Markdown. Don't apply * prose* to
343+ Apply the ` prose prose-neutral dark:prose-invert ` class set to the
344+ container that holds rendered Markdown. Don't apply ` prose ` to
344345arbitrary blocks of UI; it is intended for long-form text only.
345346
346- ### Variant groups
347+ ### Variant group syntax
347348
348- The UnoCSS * variantGroup* transformer is enabled. Group related
349- variants:
350-
351- ~~~~ tsx
352- <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)" >
353- ~~~~
354-
355- Use this judiciously; long flat class lists are still fine when they
356- are easier to read.
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