Redesign the server-rendered front-end#458
Merged
dahlia merged 42 commits intofedify-dev:mainfrom Apr 29, 2026
Merged
Conversation
DESIGN.md introduces the design language for Hollo's server-rendered web pages: simplicity and modernness on an achromatic neutral base, tinted by each account's chosen theme color through CSS custom properties on the html element. It covers brand identity, design principles, the color system (neutral palette, twenty theme hues, and CSS variable injection), typography (Inter + Noto Sans CJK + JetBrains Mono via presetWebFonts), spacing, iconography (Lucide via presetIcons), component recipes, motion, and accessibility expectations. AGENTS.md is updated to point future contributors at DESIGN.md as the source of truth for front-end decisions. Assisted-by: Claude Code:claude-opus-4-7
Adds UnoCSS with the Wind4, Icons (Lucide), Typography, and Web Fonts (bunny.net) presets, configured via uno.config.ts. The brand color tokens are exposed as CSS variables (--theme-50 through --theme-950) so components can write bg-brand, text-brand-700, etc. without ever knowing which of the twenty account theme hues is currently active. The 50-950 RGB triples for each of the twenty theme_color enum values are stored in src/theme/colors.ts, along with a helper that emits the CSS variable string for a given theme color. The pnpm dev script now runs unocss in watch mode alongside tsx watch via concurrently; pnpm build runs unocss once before tsdown. The generated src/public/uno.css is git-ignored. logo-black.svg and logo-white.svg are copied into src/public/ for self-hosting; the project root copies remain so the README files can keep referencing them. Assisted-by: Claude Code:claude-opus-4-7
Layout.tsx now links a single /public/uno.css stylesheet and injects the per-account theme color into the html element as inline CSS custom properties (--theme-50 through --theme-950), which the brand color tokens in uno.config.ts dereference. The body element gets a neutral surface (bg-neutral-50 in light, bg-neutral-950 in dark) and the new sans-serif font stack. DashboardLayout.tsx is rebuilt around the new UnoCSS classes: the header is a thin bordered bar with the Hollo wordmark, an inline navigation row that highlights the active menu, and a self-hosted logo (/public/logo-*.svg) instead of the previous jsdelivr CDN. The footer is centered muted text. The twenty-two pico.*.min.css files and the legacy hollo.css are deleted. The only remaining bit of hollo.css -- the Shiki dark-mode override -- is moved into uno.config.ts as a preflight so it stays in the generated stylesheet. Generated screenshots and the .playwright-mcp directory are git-ignored. Assisted-by: Claude Code:claude-opus-4-7
Three fixes that surfaced after the layout migration when the new utilities started being used in earnest: 1. The variant group transformer (transformerVariantGroup) was not expanding "dark:(bg-x text-y)" syntax in CLI mode, so classes inside the parens never reached the generated CSS. The transformer is removed from uno.config.ts and every call site is rewritten with the long form (dark:bg-x dark:text-y). 2. presetWind4 emits bg-* rules as "color-mix(in srgb, rgb(...) var(--un-bg-opacity), transparent)". Without --un-bg-opacity defined the whole expression is invalid and falls back to transparent, which made every theme-colored surface disappear. A preflight now sets the default opacity custom properties (--un-bg-opacity, --un-text-opacity, etc.) to 100% on :root. 3. Cloudflare in front of dorikom.hollo.social caches uno.css for four hours, so newly-built stylesheets were not visible to the browser. Layout.tsx now appends a ?v= query string sourced from the file mtime of src/public/uno.css, evaluated on each render so dev rebuilds always bust the CDN cache. Assisted-by: Claude Code:claude-opus-4-7
Introduces an AuthCard component that wraps all unauthenticated auth screens: a centered, max-w-md card with the Hollo logo above, a bordered surface in light, and a flat dark-mode panel. LoginForm, SetupForm, and OtpForm are rebuilt with stacked labelled inputs, focus rings tinted in the active brand color, inline error and hint messages, and a primary submit button that uses the theme color (defaults to azure for the unauthenticated dashboard chrome). The OTP code field uses the JetBrains Mono stack with wide letter-spacing for readability. login.tsx and setup/index.tsx are updated to render the new LoginPage, OtpPage, and SetupPage inside AuthCard. The setup page's reverse-proxy warning is restyled as a neutral red notice. Assisted-by: Claude Code:claude-opus-4-7
The instance landing page (/) now leads with the Hollo logo and the host name, and lists each account owner as a card with a circular avatar, display name, handle, and prose-styled bio. The administration dashboard link drops below the cards as a muted secondary action with a Lucide settings icon. The active theme color picks up the first account owner's selection so the public face of a one-account instance picks up that owner's brand tint. Assisted-by: Claude Code:claude-opus-4-7
The profile header is now a card with a tall hero (the cover image, or a brand-tinted gradient when no cover is set), an overlapping circular avatar bordered in the page surface color, and a stacked block of display name, handle, follower stats, prose-styled bio, and a two-column field grid. The surrounding ProfilePage is wired into a max-w-2xl reading column, the featured tags become muted chips that pick up the brand color on hover, the posts list uses divide-y separators, and the prev/next pagination is a flex row with Lucide arrow icons. The "#tag" page heading uses the brand color so the tag-filtered view picks up the account's tint. Assisted-by: Claude Code:claude-opus-4-7
The post block is now a media object: a square avatar on the left, then display name, handle, optional reply target, body, attachments, optional poll, and a footer with timestamp and counts. The footer uses Lucide icons for shared/likes/shares and the post body is wrapped in prose for proper Markdown rendering. Quoted posts inset themselves into a softer secondary surface so they read as a quoted block rather than a separate post. Pinned posts get a small brand-colored "Pinned" tag at the top. Polls switch from a table to a stack of bars whose width grows with the vote percentage. Media attachments lay out in a two-column grid on wider viewports. The article wrapper drops Pico's inline borders; the divide-y that the profile page now renders between consecutive posts provides separation instead. Assisted-by: Claude Code:claude-opus-4-7
The account list page has a heading row with a "New account" call-to-action and renders each account owner as a card with a circular avatar, display name, handle, prose-styled bio, a created/fetched timestamp, and a footer of edit, migrate, and delete actions. AccountForm splits its fields into four card-shaped sections (Identity, Privacy, Defaults, Updates) using legend headings, the same labelled-input pattern as the auth forms, a small checkbox helper, and a primary submit button. The theme color selector drops the pico-color-* classes and now renders as a plain styled select with capitalized hue names. The edit account page gets a breadcrumb-style "Accounts" link and a brand-tinted handle in the title. The migrate page is left for a follow-up. Assisted-by: Claude Code:claude-opus-4-7
The emoji list, new emoji, and import pages all switch to the dashboard's standard layout: a heading with breadcrumb-style back link where appropriate, primary and secondary action buttons in the header, and content rendered inside bordered cards. The emoji table is now a clean grid with a sticky header row, checkboxes that submit a brand-tinted "Delete selected" button, and an empty state when no emojis exist yet. The new and import forms use the labelled-input pattern, with file inputs styled to match the brand button. Assisted-by: Claude Code:claude-opus-4-7
The federation control panel is split into three card sections (Force refresh, Task queue, Shut down). The refresh form uses the inline label pattern from the auth forms and shows contextual hint or error text below the field. The task queue becomes a clean two-column table with monospace types and right-aligned, tabular-nums message counts; an explicit empty state replaces the empty pico table when the queue is empty. Assisted-by: Claude Code:claude-opus-4-7
The page now lays out four card sections (statistics, preview, progress, cleanup) using the dashboard's standard surface treatment. The progress block replaces the native <progress> element with a brand-tinted bar, and successful and failed counts get green and red emphasis. Date inputs and buttons adopt the form patterns used elsewhere in the dashboard. Assisted-by: Claude Code:claude-opus-4-7
The hashtag page now uses the same max-w-2xl reading column as the profile page, with a Hashtag eyebrow above the title, a brand-tinted hash sign in the title, and a count of matching posts in the subtitle. Posts are separated by the same divide-y treatment used on profile pages. Assisted-by: Claude Code:claude-opus-4-7
The OAuth consent screen is now an AuthCard with a clear list of requested scopes shown as monospace chips, account picker radio cards that highlight on selection, and side-by-side Deny and Allow buttons. The authorization-code page reuses AuthCard to display the code as a copyable monospace block. Assisted-by: Claude Code:claude-opus-4-7
The 2FA settings card now shows a status pill (Enabled or Disabled) in the header, the setup flow lays the QR code and explanatory text side by side on wider viewports, the verify input uses the JetBrains Mono large-tracked field, and the disable action becomes a clearly red destructive button. Assisted-by: Claude Code:claude-opus-4-7
The migrate page now follows the same card-section layout as the rest of the dashboard. Aliases are listed as monospace chips, the existing account handle is shown as a brand-tinted inline code, the "irreversible" warning is highlighted in red, the export table gets right-aligned tabular numbers and a Lucide download icon for each CSV link, and the import progress block uses the same brand-tinted progress bar as the thumbnail cleanup page. The import form upgrades to a labelled grid with a brand-styled file input. Assisted-by: Claude Code:claude-opus-4-7
src/components/forms.tsx introduces a small set of components that the auth forms, account form, and emoji forms can share: - Field label + hint/error wrapper around an arbitrary control - TextField labelled <input> (text/email/password/number/url) - TextareaField labelled <textarea> with an optional value - SelectField labelled <select> - CheckboxField checkbox laid out beside a label and hint - FieldSection card-shaped <fieldset> with a legend and description - SubmitButton primary/secondary/danger button variants The helpers fix the styling vocabulary in one place so individual forms can stop carrying long inline class lists. Assisted-by: Claude Code:claude-opus-4-7
LoginForm, SetupForm, and OtpForm switch to TextField, SubmitButton, and the new shared field styles, dropping their local copies of the input/label/error class strings. Visual output is unchanged; the change is in the source size and how easy each form is to read. OtpForm keeps its custom mono large-tracked input since it intentionally diverges from the standard TextField geometry. Assisted-by: Claude Code:claude-opus-4-7
AccountForm switches to FieldSection, TextField, TextareaField, SelectField, CheckboxField, and SubmitButton. All four sections (Identity, Privacy, Defaults, Updates) collapse from local markup into helper calls, and the local CheckboxField implementation is removed in favor of the shared one. The theme color picker is factored into a small ThemeColorField component so a follow-up commit can swap it for a swatch grid without touching the rest of the form. Assisted-by: Claude Code:claude-opus-4-7
The theme color field now renders the twenty hues as a grid of colored buttons instead of a flat <select>. Each swatch is a radio input with the brand's 500-tone background, the active choice gets a neutral ring and a check icon, and a hint line shows the picked color's name in plain English. The grid is wider than the other Default fields, so the section also restructures the row: language and visibility share a two-column row, and the swatch grid spans the full width below them. Assisted-by: Claude Code:claude-opus-4-7
Earlier the transformer was dropped because the CLI seemed not to expand "dark:(bg-x text-y)" shorthand. A direct test on a disposable file confirmed the CLI does call configured transformers when it extracts utilities, so the transformer is re-added to uno.config.ts and forms.tsx switches the focus, disabled, and read-only blocks of its base input class to the shorthand form. Nested groups are not expanded, so dark:read-only:* keeps two flat utility names rather than nesting under the dark:() group. DESIGN.md's Forms section now documents the actual helpers shipped in src/components/forms.tsx instead of the planned set. Assisted-by: Claude Code:claude-opus-4-7
A short experiment confirmed that bg-brand-500/50, text-brand-700/80, ring-brand-200/40, and friends all resolve correctly under the current setup: Wind4 emits a color-mix expression that uses the slash percentage as the mix amount, and falls back to the :root --un-*-opacity preflight default of 100% when no slash is given. DESIGN.md now spells out the mechanism (the color-mix wrapper, the opacity custom properties, and the slash modifier) under the Color system section. The brand color sample in that section also drops the stale <alpha-value> placeholder so it matches the live config. Assisted-by: Claude Code:claude-opus-4-7
Posts that have a stored preview_card now show it as a media- object card below the body and any poll: a square thumbnail on the left (when og:image is set), the host name as a small all- caps eyebrow, the page title in semibold, and a two-line description. The whole card is a single anchor that opens the target in a new tab with rel="noopener nofollow". The card is suppressed when the post already has attached media, matching Mastodon's own UI. Quoted posts keep showing their content but never their preview card, so nested quote blocks don't pile up two link cards on top of each other. The PreviewCard data and fetcher already lived in src/previewcard.ts; this commit only adds the visual layer. Assisted-by: Claude Code:claude-opus-4-7
The /@handle/:postId page used to drop a bare PostView and its replies straight into the body, which in the new layout left the post pinned to the top-left of the viewport. PostPage now wraps its content in the same max-w-2xl reading column the profile page uses, with horizontal padding and breathing room above and below. PostView gains a "featured" prop that the permalink page sets to true. In featured mode the avatar steps up from size-11 to size-12, the display name renders at text-lg, and the body prose grows from prose-sm to prose-base — small bumps that make a single, focused post read like the centerpiece it is on its own page without breaking the divide-y rhythm of timelines or the inset look of quote blocks. The replies section gets a small "N replies" eyebrow above its divide-y stack so it reads as a separate region from the main post. Assisted-by: Claude Code:claude-opus-4-7
The swatch ring and check-mark used to be rendered conditionally
on the server-side isSelected value, so clicking a different
swatch in the browser changed the radio's :checked state without
visibly updating the UI. The fix moves both pieces to CSS:
- the label uses has-[:checked]:ring-neutral-900 to draw the
selected ring,
- the check-mark span is always present and toggled with
peer-checked:opacity-100 against a default opacity-0.
The hint that previously echoed "Currently picked: X" is dropped
because it would otherwise lag a click. The Field's id now
points at the swatch matching the active color so a hint click
focuses something useful.
Assisted-by: Claude Code:claude-opus-4-7
UnoCSS Wind4's reset zeroes border widths on every element but doesn't override the user-agent input/textarea/select borders, which leaves Chromium picking its own 2px inset border for form controls in some configurations and ignoring the .border-color utility. uno.config.ts now sets border-style: solid and border-width: 1px on input, textarea, and select via a preflight, and forms.tsx's fieldBase drops the redundant border/border-solid utilities since the preflight covers them. Assisted-by: Claude Code:claude-opus-4-7
Browsers default to the arrow cursor over <button> elements, which makes them feel like static labels rather than something you can click. A small preflight rule sets cursor: pointer on every enabled <button> and any element with role="button", and keeps cursor: not-allowed on disabled ones. Assisted-by: Claude Code:claude-opus-4-7
The third section of the account form holds the default language, default visibility, and theme color. "Defaults" only fits the first two; the theme color is a per-account preference rather than a default applied to anything. Renaming the legend to "Preferences" lets the three fields sit together without feeling out of place. Assisted-by: Claude Code:claude-opus-4-7
The previous file picker was a thin bordered shell with the default Chrome "Choose file / No file chosen" combo on the left, which read as half-styled next to the rest of the form. The image field now renders a centered drag-and-drop-style labelled dropzone: dashed neutral border, lucide image-up glyph, a "Click to choose an image" call-to-action, and a PNG/JPEG/GIF/WebP hint underneath. The native <input type=file> sits inside the label as sr-only. The forms preflight stops applying the 1px solid border to input[type=file], input[type=checkbox], and input[type=radio] since those types intentionally diverge from text-style controls. Assisted-by: Claude Code:claude-opus-4-7
The account form used to render each FieldSection as its own small card. That made the page read as four separate forms, visually different from the single-card forms used elsewhere (login, setup, emoji new, federation refresh, migrate, etc.). FieldSection is now a borderless fieldset with a small all-caps legend and a thin top divider before sibling sections, and the account form wraps all of its sections in a single rounded neutral card. The submit button sits below a final divider inside the same card so the form reads as one cohesive sheet. Assisted-by: Claude Code:claude-opus-4-7
The forms helper inputs rendered at the browser default 16px, while every hand-written form on the dashboard (emoji new, federation refresh, thumbnail cleanup, migrate, etc.) used text-sm. Putting the two side by side made the account form feel oversized and out of family. fieldBase now sets text-sm so TextField, TextareaField, and SelectField match the rest of the dashboard at 14px. The OTP input keeps its custom large mono geometry. Assisted-by: Claude Code:claude-opus-4-7
The fieldBase string for the form helpers used variant group
shorthand like "focus:(border-brand-500 outline-none ring-2
ring-brand-100)". transformerVariantGroup expanded that for
class extraction at build time, but the original shorthand
shipped to the browser inside className verbatim. The browser
splits className on whitespace, so "ring-2" landed on every
input as a standalone class and pinned a 2 px black ring around
the field forever — which made AccountForm inputs look like
they had a heavier, darker border than the rest of the dashboard.
The fix is straightforward: write each variant out long-form
("focus:border-brand-500 focus:outline-none focus:ring-2 ...")
so the className the browser sees matches the CSS rules that
were actually generated. transformerVariantGroup is removed
from uno.config.ts since the shorthand is no longer worth the
foot-gun.
Assisted-by: Claude Code:claude-opus-4-7
The padding-top and divider used to live on the <fieldset> itself. fieldset/legend has native rendering quirks that swallow padding-top, so the legend ended up flush against the divider while children sat 36 px below it. Side by side that read as the section heading clinging to the previous section. The wrapper is now a plain <div> that carries the divider and the 24 px padding-top, with the bare <fieldset> nested inside. The padding now actually lands above the legend, and the gap between the legend and its first child grows from mt-3 to mt-4 (12 → 16 px) so the heading no longer sits closer to the divider than to the controls it introduces. Assisted-by: Claude Code:claude-opus-4-7
The username input on the new and edit account forms now sits between two muted neutral chips: a leading "@" and a trailing "@hostname". This makes it obvious that the value being typed is the local part of a fediverse handle that resolves to @user@host on the network. The chips share the same wrapper as the input so the focus ring lights up the whole field as a unit via focus-within. AccountForm and NewAccountPage gain a host prop that the route handlers populate from new URL(c.req.url).host. The forms preflight selector is also relaxed: it now uses :where() so the input/textarea/select rule contributes no extra specificity, which lets utility classes like border-0 (used by the inner username input inside its wrapper) actually win. Assisted-by: Claude Code:claude-opus-4-7
The account theme color now actually shows up in three high-
visibility places on the profile page:
- The following and followers counts render the numbers
themselves in brand-700 (brand-400 in dark mode), with the
"following" / "followers" labels staying muted.
- Markdown links inside profile bios use prose-a:text-brand-700
so every external link in the bio picks up the brand tint
instead of the default neutral.
- Featured tags are now solid brand-50 chips with a brand-200
border and brand-700 text, instead of muted neutral chips
that only turned brand on hover.
Post bodies (Post.tsx) get the same prose-a override, so links
inside post text on profiles, hashtag pages, and permalinks pick
up the page's theme color too.
Assisted-by: Claude Code:claude-opus-4-7
Selecting text now lights up in the page's theme color instead of the browser's default blue. A small ::selection block in the preflight uses brand-200 over brand-900 in light mode and brand-800 over brand-100 in dark mode, all sourced from the same --theme-* CSS variables that Layout already injects on <html>. Profile and post pages adopt the account owner's chosen color; the dashboard chrome falls back to azure as elsewhere. Assisted-by: Claude Code:claude-opus-4-7
UnoCSS sorts utility rules alphabetically, which lands .dark:divide-* before .divide-* in the generated stylesheet because "dark" precedes "divide". Same specificity, source order decides — so .divide-neutral-200 wins in both schemes and the inter-post divider on profile, hashtag, and reply pages stayed almost white in dark mode. A small preflight pins the divider color directly: neutral-200 by default, neutral-800 under prefers-color-scheme: dark, with specificity 0,2,0 so the existing utility classes can no longer override it. Other dark-mode prone classes (border-, bg-, text-, etc.) all sort before "dark" alphabetically and don't hit this issue, so this targeted fix is enough. Assisted-by: Claude Code:claude-opus-4-7
Brand-600 sat on top of neutral-950 surfaces a step too bright,
so primary buttons read as more attention-grabbing than the
content around them. Each primary button now drops one notch
in dark mode: bg-brand-600 stays in light, while dark mode uses
brand-700 with a brand-800 hover. The same shift is applied to
the file-selector button on migrate's CSV import.
Covered:
- SubmitButton primary variant in src/components/forms.tsx
- Dashboard call-to-action buttons (New account, Edit, Add
emoji, Refresh, Preview, Clean, Import, Save changes)
- Auth setup, OAuth Allow, account list Edit
- Migrate import file picker
Progress bars (the brand-600 fill on cleanup/migrate progress)
are left as-is so they still read as a visible bar in dark mode.
Assisted-by: Claude Code:claude-opus-4-7
Adds a Version 0.9.0 entry that summarizes the move from Pico CSS to the UnoCSS-based design system: the new tokens, fonts, and icons; the page-by-page rebuild; the form primitives, theme color picker, and username chip layout; the dark mode and selection color treatment; and the static asset cleanup. Assisted-by: Claude Code:claude-opus-4-7
Member
Author
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5a5fc5bf01
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
CSS class names, HTML elements, attributes, properties, custom property names, function names, types, and props are now wrapped in backticks instead of italics, since italics in this project are reserved for package names, file paths, font families, and brand/concept names per AGENTS.md's markdown style guide. Italics are kept on package names like @iconify-json/lucide and @unocss/cli, font families like Inter or Noto Sans KR, file paths like src/components/Layout.tsx, and brand/concept names like Wind4 or Lucide. The "Variant groups" section is also rewritten to document the project's actual policy: variant group shorthand is not used because it leaks into HTML class attributes and triggers false positive class matches. Assisted-by: Claude Code:claude-opus-4-7
Five tests started failing once the redesign rewrote the
markup of pages they assert against:
- src/oauth.test.ts looks up the listed scopes via
"#scopes > li > code". The new authorization page used
the same <ul>/<li>/<code> structure but had dropped the
id="scopes" attribute on the <ul>. Re-added.
- src/pages/federation.test.tsx expects the canonical handle
conflict copy to read "Account refresh was blocked by a
canonical handle conflict." The redesign had shortened it
to "Refresh blocked by a canonical handle conflict.";
restore the longer wording — it's also clearer to the
reader.
- src/pages/thumbnail_cleanup.test.ts asserted
"Number of Items: 4,980" on the preview output. The
preview now renders the count inside a <dl><dt>Items</dt>
<dd>4,980</dd> grid, so the test is updated to assert the
new "Items" label and "4,980" value separately, and its
expect.assertions count moves from 5 to 6.
- src/pages/profile/index.test.tsx asserted
"Posts tagged #TestTag" as a single string. The redesign
splits the heading into "Posts tagged " followed by a
brand-tinted <span>#TestTag</span>, so the assertion is
split in two and the assertions count moves from 5 to 6.
Assisted-by: Claude Code:claude-opus-4-7
The previous "*.png" entry in .gitignore matched every PNG file anywhere in the tree, including the existing screenshots under docs/ and the default avatar at assets/default-screenshot.png, and would have silently dropped any future PNG asset added by a contributor. Replace it with "/*.png", which only ignores the ad-hoc Playwright screenshots that land in the repository root during local front-end development. Addresses fedify-dev#458 (comment) Assisted-by: Claude Code:claude-opus-4-7
Member
Author
|
@codex review |
|
Codex Review: Didn't find any major issues. Swish! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This pull request replaces Pico CSS with a new in-house design system, documented in DESIGN.md and styled through UnoCSS, and rebuilds every server-rendered page on top of it. The result is a single coherent visual language for the public profile pages, single-post permalinks, hashtag streams, and the entire administration dashboard, with each account owner's chosen theme color tinting their own profile and post pages while the dashboard chrome stays achromatic.
Highlights
Design system
Brand color system
<html>as--theme-50through--theme-950CSS variables, sourced from a static RGB table in src/theme/colors.ts.bg-brand,text-brand-700, etc., so they automatically pick up whichever of the twenty hues the page belongs to. No safelist is needed.::selection) all adopt the active brand color.Pages
Every public and dashboard page is rebuilt: login, setup, OTP, the public home, account profiles, single-post permalinks (with a featured larger body and an “N replies” header), the hashtag stream, the account list and editor, custom emojis (with a drag-and-drop-style image picker), federation, thumbnail cleanup, the OAuth consent screen, and the dashboard 2FA panel.
Post component
Posts render as a media object with a circular avatar, prose markdown body, attached media as a two-column grid, polls as brand-tinted progress bars, quoted posts as an inset card, and Open Graph link previews as a thumbnail/host/title/description card.
Forms
A small set of primitives in src/components/forms.tsx (
Field,TextField,TextareaField,SelectField,CheckboxField,FieldSection,SubmitButton) backs the auth, account, emoji, federation, thumbnail cleanup, and migrate forms. The account form's theme color picker is a twenty-swatch grid; the username field is bracketed by@and@hostchips so the resulting fediverse handle is obvious.Typography and icons
Web Fonts come from bunny.net (a privacy-respecting Google Fonts mirror): Inter for Latin, Noto Sans KR/JP/SC for CJK, and JetBrains Mono for code. Lucide icons replace ad-hoc iconography via UnoCSS's
presetIcons.Dark mode
prefers-color-scheme: darkis honored automatically. Primary buttons step frombrand-600tobrand-700so they don't dominate dark surfaces;divide-yborders are pinned toneutral-200/neutral-800via a preflight (because UnoCSS sortsdark:divide-*beforedivide-*alphabetically and would otherwise let the light variant win); text selection adopts the active brand color in both schemes.Build pipeline
pnpm devnow runsunocss --watchalongsidetsx watchviaconcurrently;pnpm buildrunsunocssonce beforetsdown. src/public/uno.css is git-ignored. The Hollo logos move from a jsDelivr URL to src/public/logo-black.svg and src/public/logo-white.svg. src/components/Layout.tsx appends a?v=<mtime>cache buster so CDN caches always pick up the latest stylesheet.Screenshots
Public home
Dashboard — account list
Dashboard — account editor
Public profile