| 1 | # UI |
| 2 | |
| 3 | ## Philosophy |
| 4 | |
| 5 | A developer tool people use daily to browse code and manage repos. Optimise for: |
| 6 | |
| 7 | 1. **Speed-first** — minimise clicks, maximise information density |
| 8 | 2. **Scannable** — find what you need in under a second |
| 9 | 3. **Quiet confidence** — premium without flashy; the UI should disappear |
| 10 | 4. **Code-centric** — code is the hero, everything else supports it |
| 11 | |
| 12 | |
| 13 | |
| 14 | | Readable density | Compact without cramped. Maximise info per viewport. | |
| 15 | | Clear hierarchy | Strong contrast between labels, content, and muted elements | |
| 16 | | Functional spacing | 8px on items, 12–16px on sections. No wasted space. | |
| 17 | | Keyboard-first | Every action reachable without a mouse | |
| 18 | | Dark-mode primary | Developers live in dark mode. Light mode is supported, secondary. | |
| 19 | |
| 20 | **Do:** small type (`text-xs` / `text-sm` for most UI), small buttons, monospace for |
| 21 | paths, SHAs, and branch names, opacity modifiers for text hierarchy, tight spacing. |
| 22 | |
| 23 | **Don't:** large type outside page titles, shadows in dark mode (use borders), bright |
| 24 | backgrounds, hover animations that move or resize things. |
| 25 | |
| 26 | ## Where the portfolio framing bites |
| 27 | |
| 28 | Steid is portfolio-first, not a Gitea clone. The profile page is the product — repos |
| 29 | are one kind of thing on it, alongside writing and projects. Any layout inherited from |
| 30 | a GitHub-shaped forge needs checking against that before it's copied. |
| 31 | |
5d3dfa5feat: a global top bar, and pages choose their own width22h | 32 | ## The shell |
| 33 | |
| 34 | Settled 2026-09-05, when app-shell navigation (PostHog/GitLab-style sidebar) was |
| 35 | considered and rejected. |
| 36 | |
| 37 | **A slim top bar plus contextual tabs, not an app shell.** The reasoning: |
| 38 | |
| 39 | - Sidebar shells optimise for the logged-in daily user hopping between many top-level |
| 40 | destinations. Steid's first audience is the opposite — an anonymous reader of |
| 41 | someone's site who will never sign in — and rail chrome reads as "self-hosted admin |
| 42 | tool", which is the exact wrong first impression for a portfolio. The managed |
| 43 | offering strengthens this: every paying customer's audience is that visitor. |
| 44 | - Steid has four-ish global destinations; a sidebar would be mostly empty rail, |
| 45 | advertising incompleteness the same way the old stacked-section profile did. |
| 46 | - A shell-for-owners / site-for-visitors split is the two-design-languages trap again. |
| 47 | One vibe. |
| 48 | |
| 49 | What ships instead: |
| 50 | |
| 51 | - **The top bar**: one ~44px row, wordmark on the left → `/`, sign-in state on the |
| 52 | right ("Sign in", or `@handle` / Settings / Sign out). It never grows navigation; |
| 53 | that belongs to the thing being navigated — the profile's tab bar, the repository's. |
| 54 | - **Per-page widths.** The layout imposes none; pages wrap themselves in `narrow` |
| 55 | (48rem — prose-shaped: profile, forms, settings, writing) or `wide` (80rem — |
| 56 | code-shaped: tree, blob, log). Both live in `layout.rs`. A page that forgets one |
| 57 | renders full-bleed, which is visible, not silently wrong. |
| 58 | - Where a sidebar does earn its place is *within* a wide page — a file tree beside a |
| 59 | blob, a settings-section nav — as local structure, not global chrome. |
| 60 | |
| 61 | Open: **the wordmark says "steid"**, but on someone's own domain, portfolio-first |
| 62 | argues the top-left identity slot belongs to the owner, not to Steid. Undecided; |
6d38c90docs: the wordmark slot is future custom branding, not just an identity question18h | 63 | becomes decidable when multi-user lands and "the owner" needs a rule anyway. The |
| 64 | larger shape of it (noted 2026-09-05): companies and individuals — self-hosted or |
| 65 | managed — will likely want **custom text in that slot, and perhaps elsewhere**: a |
| 66 | company name where "steid" sits, maybe a footer line. That is an instance-level |
| 67 | setting on `Organization` or config, not a theme; whatever answers the wordmark |
| 68 | question should leave room for it rather than hardcoding either choice. |
5d3dfa5feat: a global top bar, and pages choose their own width22h | 69 | |
0aca94efeat: a repository is one place, and its landing page says what it is17h | 70 | ## The repository page |
| 71 | |
| 72 | Settled 2026-09-05. `/{handle}/repos/{name}` is the only two-column page in a |
| 73 | repository: code on the left, an About sidebar on the right. Every page below it — |
| 74 | a tree subpath, a file, the log — stays a single `wide` column, the same way GitHub |
| 75 | narrows once you are inside the tree. |
| 76 | |
| 77 | ``` |
| 78 | @jamesgill / |
| 79 | steid [Settings] |
| 80 | Code Commits |
| 81 | ────────────────────────────────────────────────────────────────────────── |
| 82 | main ▾ ⑂ 3 branches · 2 tags │ ABOUT |
| 83 | ● docs: record the forge feature survey │ A personal-first gitforge… |
| 84 | 31a6a7e · 2d │ 🕮 AGPL-3.0 |
| 85 | ┌──────────────────────────────────────┐ │ ───────────────────────── |
| 86 | │ steid │ │ Commits 80 |
| 87 | │ deploy/ plans/ src/ │ │ Branches 3 |
| 88 | │ Cargo.toml README.md LICENSE │ │ Tags 2 |
| 89 | └──────────────────────────────────────┘ │ Latest tag ⌗ v0.2.0 |
| 90 | ┌──────────────────────────────────────┐ │ Pushed 2 days ago |
| 91 | │ README.md │ │ ───────────────────────── |
| 92 | └──────────────────────────────────────┘ │ CLONE |
| 93 | │ https://…/steid.git |
| 94 | ``` |
| 95 | |
| 96 | **The sidebar's About block is the portfolio pitch.** A visitor arriving from a |
| 97 | profile reads it before they read any code — what this is, whether they may use it, |
| 98 | how much of it there is — so it gets the right-hand column and the description gets |
| 99 | its only home there. The header stays one line tall as a result: two homes for a |
| 100 | description means the header grows tallest on exactly the repositories with the most |
| 101 | to say, pushing the code below the fold. |
| 102 | |
| 103 | - **The header and tab strip are shared by every repository page**, so a tree, a file |
| 104 | and a log read as one place rather than three. The strip takes the active tab as a |
| 105 | parameter, so a new tab is one variant and one line. |
| 106 | - **The active tab is underlined in the primary colour.** Nothing else on the strip |
| 107 | is coloured, so it reads as position rather than decoration. The only other use of |
| 108 | the primary colour on the page is the latest-commit dot. |
| 109 | - **The latest-commit bar stands in for the per-file last-commit column** that |
| 110 | [0006](decisions/0006-git-binary-behind-narrow-ports.md) defers. It says the same |
| 111 | thing once instead of once per row, for one `git log --max-count=1`. |
9b1feb6docs: fold the branches and tags handover into plans17h | 112 | - **Counts beside the switcher link to `/branches` and `/tags`.** Both pages sit under |
| 113 | the Code tab — they are ways into the code, not places of their own, and a tab each |
| 114 | for two lists would make the strip advertise plumbing. Each page's heading cross-links |
| 115 | the other. Their rows keep trailing metadata in fixed-width right-aligned columns so |
| 116 | the sha does not zig-zag, and below `sm` drop the subject and Compare link rather than |
| 117 | wrap: a row is a scanning surface. Three distinct empty states — no commits (push |
| 118 | snippet), no tags yet (`git push --tags`), and a timeout that says the repository is fine. |
0aca94efeat: a repository is one place, and its landing page says what it is17h | 119 | - **Sidebar sections are separated by hairlines, never boxed as cards** — the rule the |
7aae36cfix: on a narrow viewport the About sidebar comes first, not last17h | 120 | profile page settled on, and what makes the page read as one surface. |
| 121 | - **Below `lg` the sidebar is not a sidebar: it comes first**, above the file list, with |
| 122 | a rule under it and no stickiness. Ordering it after the code would bury "what is this |
| 123 | and may I use it" under a long README — the one question the visitor arrived with, on |
| 124 | the viewport with the least room to go looking for it. That is why the two-column |
| 125 | container is a flex column rather than a plain block: `order-first` needs a flex |
| 126 | parent. |
0aca94efeat: a repository is one place, and its landing page says what it is17h | 127 | - **A licence is named only when its own text names it.** A licence file Steid cannot |
| 128 | identify is linked to and labelled "Licence": naming the wrong one is a claim about |
| 129 | somebody's legal terms. |
| 130 | |
6f0a608docs: fold the highlighting handover into plans17h | 131 | - **Syntax colour is the one place colour carries information rather than position.** |
| 132 | The palette is deliberately narrow: comments below muted-foreground, strings and |
| 133 | keywords carrying the contrast, punctuation just under foreground, everything else |
| 134 | close to it — a file reads as text with structure, not a parade. It is one |
| 135 | `--syntax-*` block in `styles.css`, so it follows the palette. The over-cap notice is a |
| 136 | row of the blob panel sharing the header's hairline, a page state rather than a banner. |
| 137 | |
0aca94efeat: a repository is one place, and its landing page says what it is17h | 138 | ### Where the next features go |
| 139 | |
| 140 | The slots matter as much as what fills them today. Written down so the next feature |
| 141 | lands in the frame rather than beside it: |
| 142 | |
| 143 | |
| 144 | |
| 145 | | Issues, Pull requests | a tab each, beside Code and Commits | |
| 146 | | A commit page | the sha in the latest-commit bar and in the log | |
9b1feb6docs: fold the branches and tags handover into plans17h | 147 | | Branches, Tags | done — the counts beside the switcher and in the sidebar | |
0aca94efeat: a repository is one place, and its landing page says what it is17h | 148 | | Compare | reached from a branch row on the branches page | |
| 149 | | Blame | a toggle in the blob's own header, beside Raw | |
| 150 | | Archive download | two small links under the clone URL | |
| 151 | | Code search | a box on the right of the toolbar row, which is empty for it | |
| 152 | | Per-file last commit | a column in the listing, once `cat-file --batch` is kept alive | |
| 153 | |
| 154 | **The cost is known and accepted.** The landing page makes **15 `git` processes** for a |
| 155 | repository with a README and a licence, 10 without them, and 1 for an empty one — up |
| 156 | from 7. Five of those are the sidebar's, run concurrently. That is the bill 0006 said |
| 157 | would come due; it comes due at a kept-alive `cat-file --batch`, not before. |
| 158 | |
f0afae5docs: settle the profile page on flat navigation7d | 159 | ## The profile page |
| 160 | |
| 161 | Settled 2026-08-29, after looking at the built page with real data rather than |
| 162 | imagining it. Three sections of equal weight — Repositories, Writing, Projects — two of |
| 163 | them empty boxes, repositories sorted alphabetically so `dotfiles` outranked `steid`. |
| 164 | A third of the page was content and two thirds advertised incompleteness. |
| 165 | |
| 166 | **Flat, app-style navigation. No cards, no boxes.** |
| 167 | |
| 168 | ``` |
| 169 | James Gill |
| 170 | @jamesgill |
| 171 | <bio> |
| 172 | <links> |
| 173 | ───────────────────────────────────────────── |
| 174 | Overview Repositories 3 Writing |
| 175 | ───────────────────────────────────────────── |
| 176 | CURRENTLY BUILDING |
| 177 | steid Rust |
| 178 | A self-hostable gitforge where the profile is the product. |
| 179 | 421 tests · updated 2 hours ago |
| 180 | |
| 181 | REPOSITORIES All 3 → |
| 182 | ────────────────────────────────────────── |
| 183 | topcoat-notes 3d |
| 184 | Notes on building with Topcoat 0.5. |
| 185 | ────────────────────────────────────────── |
| 186 | dotfiles 2w |
| 187 | ``` |
| 188 | |
| 189 | - **A tab bar, not stacked sections.** Empty sections stop being visible failures and |
| 190 | become destinations that simply have nothing in them yet. It is also what makes |
| 191 | Writing shippable without redesigning the page around it. |
| 192 | - **Tabs are links.** No JavaScript, and each tab is a real URL that can be shared. |
| 193 | - **One lead item with genuine typographic weight**, then a compact list. This is the |
| 194 | editorial layer portfolio-first requires: without it the page has no opinion about |
| 195 | what matters, and ordering alone cannot carry that. |
| 196 | - **Hairlines and hover states, never bordered containers.** The row is the unit. |
| 197 | - **Recency, not alphabetical.** Alphabetical is a filing rule; a portfolio needs an |
| 198 | editorial one. |
| 199 | - **Sections with nothing in them are hidden from visitors** and shown to the owner as |
| 200 | an affordance. |
| 201 | |
30ba450docs: one design language, hierarchy without scale7d | 202 | ### One vibe. Hierarchy without scale. |
| 203 | |
| 204 | The first version of this design gave the lead item large type and justified it by |
| 205 | splitting the product into a "portfolio surface" and a "tool surface" with different |
| 206 | rules. **That was wrong and is not what ships.** Two design languages in one product is |
| 207 | how a product stops feeling like one thing, and it contradicted this file's own |
| 208 | philosophy — quiet confidence, the UI disappears, small type throughout. |
| 209 | |
| 210 | Nothing exceeds 14px except the page title. The lead item is distinguished by |
| 211 | **everything except size**: |
| 212 | |
| 213 | |
| 214 | |
| 215 | | Eyebrow label | yes | no | |
| 216 | | Weight | 600 | 500 | |
| 217 | | Description | full width, roomy | compact | |
| 218 | | Space around it | generous | tight | |
| 219 | | Separator | hairline below | none | |
| 220 | |
| 221 | The cost, stated plainly: the distinction is **subtle**. One weight step is not much, |
| 222 | and the eyebrow and whitespace carry most of it. That is the right trade for a developer |
| 223 | tool — but it means the copy in the eyebrow is load-bearing, because it is the clearest |
| 224 | signal that this item is different. |
| 225 | |
10b155cdocs: the profile eyebrow says "Currently building"7d | 226 | **The eyebrow reads "Currently building"**, chosen over a neutral "Featured". It says |
| 227 | something rather than labelling something, which is what a portfolio built in public |
| 228 | wants. The known cost: it is a claim about the present, so pinning a finished project |
| 229 | makes the page lie. The answer if that becomes a problem is a per-pin label, not a |
| 230 | blander default. |
| 231 | |
30ba450docs: one design language, hierarchy without scale7d | 232 | The general rule this sets: **hierarchy comes from weight, colour and space. Reach for |
| 233 | size last, and outside a page title, probably not at all.** |
f0afae5docs: settle the profile page on flat navigation7d | 234 | |
8ed4e5afeat: the root is the owner's profile1d | 235 | ### The front door |
| 236 | |
7da29e0fix: the domain is jpgill.dev, not jpgilldev.com1d | 237 | Steid is meant to be someone's site, reached at their own domain — `jpgill.dev`, not |
| 238 | `git.jpgill.dev`, because a `git.` subdomain announces a Gitea clone and this is |
8ed4e5afeat: the root is the owner's profile1d | 239 | portfolio-first. That makes the root a signpost rather than a page: unclaimed it sends |
| 240 | you to setup, and otherwise it forwards to a profile — your own when signed in, **the |
| 241 | owner's when not**. |
| 242 | |
| 243 | The last case is the one that matters. A stranger arriving at the apex came for the |
| 244 | profile and will never sign in, so a sign-in prompt there is the front door answering the |
| 245 | wrong question. It only applies when the owner is unambiguous: with more than one user |
| 246 | "the owner" has no answer, so the root falls back to a generic landing rather than |
| 247 | electing someone by storage order. |
| 248 | |
f0afae5docs: settle the profile page on flat navigation7d | 249 | ### What it needs that does not exist yet |
| 250 | |
| 251 | - **`updated_at` on a repository.** The design promises recency and nothing stores it. |
| 252 | Asking git costs one fork per repository — a twelve-repo profile would be ~150ms of |
| 253 | `execve` before rendering. So it is a column, touched when a push is authorized. |
| 254 | Slightly wrong if a push then fails; the alternative is wrong more expensively. |
| 255 | - **A `/{handle}/repos` index.** The Repositories tab needs a destination; only |
| 256 | `/{handle}/repos/{name}` exists. |
| 257 | - **A way to choose the lead.** A `pinned` flag on the repository, set from the settings |
| 258 | page that now exists. No pin, no lead section — automatic "most recent" would put a |
| 259 | dotfiles tweak at the top of a portfolio. |
| 260 | |
| 261 | ### Mocking is harder than it looks here |
| 262 | |
| 263 | **Tailwind classes the app does not already use are not in the built stylesheet.** |
| 264 | `build.rs` scans the real sources, so a mockup written against the served CSS silently |
| 265 | loses any new utility — `gap-7` and `tracking-widest` collapsed a nav into |
| 266 | `OverviewRepositories3WritingProjects` before this was understood. Iterate either in the |
| 267 | app itself or, for throwaway exploration, in plain CSS against the theme's custom |
| 268 | properties (`--background`, `--foreground`, `--muted-foreground`, `--border`, `--surface`). |
| 269 | |
| 270 | Dark mode is a `.dark` class on an ancestor, not `prefers-color-scheme`; a mockup |
| 271 | without it renders light. |
| 272 | |
| 273 | ## Stack |
| 274 | |
af126c7feat: tailwind theme and the first components25d | 275 | Settled in [0005](decisions/0005-tailwind-and-copied-components.md). |
| 276 | |
af126c7feat: tailwind theme and the first components25d | 277 | |
| 278 | |
| 279 | | Styling | Tailwind via Topcoat's build script — no Node | |
| 280 | | Theme | `styles.css` at the package root: design tokens, dark-first | |
| 281 | | Components | `topcoat ui add` for primitives, hand-written for Steid's own | |
| 282 | | Mono | IBM Plex Mono, for paths, hashes, branches, handles | |
| 283 | |
af126c7feat: tailwind theme and the first components25d | 284 | ### Tokens |
| 285 | |
af126c7feat: tailwind theme and the first components25d | 286 | Defined in `styles.css` on `:root` (light) and `.dark` (dark, the default): |
| 287 | |
| 288 | ``` |
| 289 | background surface foreground muted-foreground |
| 290 | primary primary-foreground |
| 291 | success success-foreground confirmations, git additions |
| 292 | warning warning-foreground caution, git modifications |
| 293 | destructive destructive-foreground errors, git deletions |
| 294 | border ring shadow-xs shadow-sm |
| 295 | ``` |
| 296 | |
| 297 | `surface`, `success` and `warning` are Steid's additions to Topcoat's `neutral` theme; |
| 298 | the rest were retuned to the palette below. |
| 299 | |
| 300 | **Components reference tokens, never raw colours.** A hardcoded colour will not follow |
| 301 | a palette change and will not adapt to the colour scheme. This is the convention to |
| 302 | enforce in review. |
| 303 | |
| 304 | ### Working on it |
| 305 | |
af126c7feat: tailwind theme and the first components25d | 306 | ```bash |
| 307 | topcoat dev |
| 308 | topcoat asset bundle |
| 309 | topcoat ui list |
| 310 | topcoat ui add <name> |
| 311 | ``` |
| 312 | |
64222c3docs: record the icon staging trap and tick the repo-creation check24d | 313 | `components.toml` is the record of what is installed; don't duplicate that list here. |
| 314 | |
| 315 | **A registry component can need more than a copy.** `select` draws its chevron with an |
| 316 | Iconify icon, which needs the `icon-iconify` feature on `topcoat` *and* the icon set |
| 317 | staged in `build.rs`: |
| 318 | |
| 319 | ```rust |
| 320 | topcoat::icon::iconify::BuildConfig::new().icon_set("feather").stage().unwrap(); |
| 321 | ``` |
| 322 | |
| 323 | The build fails with a message naming the missing set, so it is discoverable — but the |
| 324 | build.rs edit is easy not to expect from a command that only advertises copying a file. |
| 325 | Icons are embedded at build time; nothing is fetched at runtime. |
| 326 | |
| 327 | `topcoat ui add` also rewrites the module list in `src/components.rs` by appending, so |
| 328 | re-alphabetise it afterwards. |
| 329 | |
| 330 | ## Prior art |
| 331 | |
| 332 | Attempt #1 (`steid-backup/AGENTS/UI.md`) has a complete 643-line design system — |
| 333 | OKLCH light/dark palettes with concrete token values, a type scale, spacing scale, and |
| 334 | component markup for sidebar, file tree, commit bar, breadcrumbs, badges, empty |
| 335 | states. It was written for Tailwind + DaisyUI. |
| 336 | |
af126c7feat: tailwind theme and the first components25d | 337 | Its palette has been mined already — the dark-first OKLCH values at hue 260 are what |
| 338 | `styles.css` was retuned to. Its component markup has not, and comes with two caveats: |
| 339 | it specifies DaisyUI, which is a separate choice from Tailwind and not bundled by |
| 340 | Topcoat; and it was written for a GitHub-shaped forge rather than a portfolio-first |
| 341 | one. |
| 342 | |
| 343 | It is also, on its own, longer than every other doc in this directory combined — for |
| 344 | an app that had about nine pages. Take the palette and the principles. Don't |
| 345 | re-specify components before there are components. |