# UI

## Philosophy

A developer tool people use daily to browse code and manage repos. Optimise for:

1. **Speed-first** — minimise clicks, maximise information density
2. **Scannable** — find what you need in under a second
3. **Quiet confidence** — premium without flashy; the UI should disappear
4. **Code-centric** — code is the hero, everything else supports it

| Principle | Meaning |
|---|---|
| Readable density | Compact without cramped. Maximise info per viewport. |
| Clear hierarchy | Strong contrast between labels, content, and muted elements |
| Functional spacing | 8px on items, 12–16px on sections. No wasted space. |
| Keyboard-first | Every action reachable without a mouse |
| Dark-mode primary | Developers live in dark mode. Light mode is supported, secondary. |

**Do:** small type (`text-xs` / `text-sm` for most UI), small buttons, monospace for
paths, SHAs, and branch names, opacity modifiers for text hierarchy, tight spacing.

**Don't:** large type outside page titles, shadows in dark mode (use borders), bright
backgrounds, hover animations that move or resize things.

## Where the portfolio framing bites

Steid is portfolio-first, not a Gitea clone. The profile page is the product — repos
are one kind of thing on it, alongside writing and projects. Any layout inherited from
a GitHub-shaped forge needs checking against that before it's copied.

## The shell

Settled 2026-09-05, when app-shell navigation (PostHog/GitLab-style sidebar) was
considered and rejected.

**A slim top bar plus contextual tabs, not an app shell.** The reasoning:

- Sidebar shells optimise for the logged-in daily user hopping between many top-level
  destinations. Steid's first audience is the opposite — an anonymous reader of
  someone's site who will never sign in — and rail chrome reads as "self-hosted admin
  tool", which is the exact wrong first impression for a portfolio. The managed
  offering strengthens this: every paying customer's audience is that visitor.
- Steid has four-ish global destinations; a sidebar would be mostly empty rail,
  advertising incompleteness the same way the old stacked-section profile did.
- A shell-for-owners / site-for-visitors split is the two-design-languages trap again.
  One vibe.

What ships instead:

- **The top bar**: one ~44px row, wordmark on the left → `/`, sign-in state on the
  right ("Sign in", or `@handle` / Settings / Sign out). It never grows navigation;
  that belongs to the thing being navigated — the profile's tab bar, the repository's.
- **Per-page widths.** The layout imposes none; pages wrap themselves in `narrow`
  (48rem — prose-shaped: profile, forms, settings, writing) or `wide` (80rem —
  code-shaped: tree, blob, log). Both live in `layout.rs`. A page that forgets one
  renders full-bleed, which is visible, not silently wrong.
- Where a sidebar does earn its place is *within* a wide page — a file tree beside a
  blob, a settings-section nav — as local structure, not global chrome.

Open: **the wordmark says "steid"**, but on someone's own domain, portfolio-first
argues the top-left identity slot belongs to the owner, not to Steid. Undecided;
becomes decidable when multi-user lands and "the owner" needs a rule anyway. The
larger shape of it (noted 2026-09-05): companies and individuals — self-hosted or
managed — will likely want **custom text in that slot, and perhaps elsewhere**: a
company name where "steid" sits, maybe a footer line. That is an instance-level
setting on `Organization` or config, not a theme; whatever answers the wordmark
question should leave room for it rather than hardcoding either choice.

## The repository page

Settled 2026-09-05. `/{handle}/repos/{name}` is the only two-column page in a
repository: code on the left, an About sidebar on the right. Every page below it —
a tree subpath, a file, the log — stays a single `wide` column, the same way GitHub
narrows once you are inside the tree.

```
@jamesgill /
steid                                                            [Settings]
Code   Commits
──────────────────────────────────────────────────────────────────────────
main ▾  ⑂ 3 branches · 2 tags               │  ABOUT
● docs: record the forge feature survey     │  A personal-first gitforge…
  31a6a7e · 2d                              │  🕮 AGPL-3.0
┌──────────────────────────────────────┐    │  ─────────────────────────
│ steid                                │    │  Commits          80
│ deploy/  plans/  src/                │    │  Branches          3
│ Cargo.toml  README.md  LICENSE       │    │  Tags              2
└──────────────────────────────────────┘    │  Latest tag   ⌗ v0.2.0
┌──────────────────────────────────────┐    │  Pushed     2 days ago
│ README.md                            │    │  ─────────────────────────
└──────────────────────────────────────┘    │  CLONE
                                            │  https://…/steid.git
```

**The sidebar's About block is the portfolio pitch.** A visitor arriving from a
profile reads it before they read any code — what this is, whether they may use it,
how much of it there is — so it gets the right-hand column and the description gets
its only home there. The header stays one line tall as a result: two homes for a
description means the header grows tallest on exactly the repositories with the most
to say, pushing the code below the fold.

- **The header and tab strip are shared by every repository page**, so a tree, a file
  and a log read as one place rather than three. The strip takes the active tab as a
  parameter, so a new tab is one variant and one line.
- **The active tab is underlined in the primary colour.** Nothing else on the strip
  is coloured, so it reads as position rather than decoration. The only other use of
  the primary colour on the page is the latest-commit dot.
- **The latest-commit bar stands in for the per-file last-commit column** that
  [0006](decisions/0006-git-binary-behind-narrow-ports.md) defers. It says the same
  thing once instead of once per row, for one `git log --max-count=1`.
- **Counts beside the switcher link to `/branches` and `/tags`.** Both pages sit under
  the Code tab — they are ways into the code, not places of their own, and a tab each
  for two lists would make the strip advertise plumbing. Each page's heading cross-links
  the other. Their rows keep trailing metadata in fixed-width right-aligned columns so
  the sha does not zig-zag, and below `sm` drop the subject and Compare link rather than
  wrap: a row is a scanning surface. Three distinct empty states — no commits (push
  snippet), no tags yet (`git push --tags`), and a timeout that says the repository is fine.
- **Sidebar sections are separated by hairlines, never boxed as cards** — the rule the
  profile page settled on, and what makes the page read as one surface.
- **Below `lg` the sidebar is not a sidebar: it comes first**, above the file list, with
  a rule under it and no stickiness. Ordering it after the code would bury "what is this
  and may I use it" under a long README — the one question the visitor arrived with, on
  the viewport with the least room to go looking for it. That is why the two-column
  container is a flex column rather than a plain block: `order-first` needs a flex
  parent.
- **A licence is named only when its own text names it.** A licence file Steid cannot
  identify is linked to and labelled "Licence": naming the wrong one is a claim about
  somebody's legal terms.

- **Syntax colour is the one place colour carries information rather than position.**
  The palette is deliberately narrow: comments below muted-foreground, strings and
  keywords carrying the contrast, punctuation just under foreground, everything else
  close to it — a file reads as text with structure, not a parade. It is one
  `--syntax-*` block in `styles.css`, so it follows the palette. The over-cap notice is a
  row of the blob panel sharing the header's hairline, a page state rather than a banner.

- **Download links are for the revision being viewed**, under the clone URL; someone
  reading a tag wants that tag's tarball. An empty repository shows none.
- **The search box is the width of the About sidebar** on `sm` and up, so the two columns
  line up; full width below and on the results page. `<mark>` is `bg-primary/25`, the only
  other primary on the page besides the active tab and the latest-commit dot: it marks
  the thing being looked for, the same rule. Every search state is a bordered panel with
  one sentence. A long matched line scrolls inside its own `<code>`.

- **A commit page is a Commits-tab page**, and its Commits link points at the log at
  that commit's own id, not the revision in the URL: a branch name there would send
  someone to a different commit tomorrow. **A compare page is a Code-tab page**: it is
  about two revisions of the code, reached from the branches page and also directly at
  `/compare`.
- **Success and destructive appear as text in exactly three places**: the commit's
  `+a −b`, each file header's, and the truncated file list. Diff line tints are the same
  tokens at 12% (rows) and 20% (gutters) through `color-mix` in plain CSS. The primary
  colour is used only for links out of a dead end ("View the whole file", "Compare them
  the other way round").

- **A file has two views and one header.** `Code · Blame · Raw` sits where Raw alone
  used to, the active view in the primary colour, the same rule as the tab strip. Raw is
  never active: it downloads. **Blame's rows are the blob's rows** — same type, leading
  and height, so they read as two readings of one thing; the commit is shown once per run
  and held to a single line. **The age tint is texture, not a heat map**: a hairline down
  the left of each run at 5–30% of the primary. If it ever reads as a value to look up, it
  is too strong.

### Where the next features go

The slots matter as much as what fills them today. Written down so the next feature
lands in the frame rather than beside it:

| Feature | Where it goes |
|---|---|
| Issues, Pull requests | a tab each, beside Code and Commits |
| A commit page | done — the sha in the latest-commit bar and in the log |
| Branches, Tags | done — the counts beside the switcher and in the sidebar |
| Compare | done — a branch row on the branches page, or `/compare` directly |
| Blame | done — the toggle in the file header, both views |
| Archive download | done — two small links under the clone URL |
| Code search | done — the box on the right of the toolbar row |
| Per-file last commit | a column in the listing, once `cat-file --batch` is kept alive |

**The cost is known and accepted.** The landing page makes **15 `git` processes** for a
repository with a README and a licence, 10 without them, and 1 for an empty one — up
from 7. Five of those are the sidebar's, run concurrently. That is the bill 0006 said
would come due; it comes due at a kept-alive `cat-file --batch`, not before.

## The profile page

Settled 2026-08-29, after looking at the built page with real data rather than
imagining it. Three sections of equal weight — Repositories, Writing, Projects — two of
them empty boxes, repositories sorted alphabetically so `dotfiles` outranked `steid`.
A third of the page was content and two thirds advertised incompleteness.

**Flat, app-style navigation. No cards, no boxes.**

```
James Gill
@jamesgill
<bio>
<links>
─────────────────────────────────────────────
Overview   Repositories 3   Writing
─────────────────────────────────────────────
CURRENTLY BUILDING
steid  Rust
A self-hostable gitforge where the profile is the product.
421 tests · updated 2 hours ago

REPOSITORIES                                All 3 →
──────────────────────────────────────────
topcoat-notes                                    3d
Notes on building with Topcoat 0.5.
──────────────────────────────────────────
dotfiles                                         2w
```

- **A tab bar, not stacked sections.** Empty sections stop being visible failures and
  become destinations that simply have nothing in them yet. It is also what makes
  Writing shippable without redesigning the page around it.
- **Tabs are links.** No JavaScript, and each tab is a real URL that can be shared.
- **One lead item with genuine typographic weight**, then a compact list. This is the
  editorial layer portfolio-first requires: without it the page has no opinion about
  what matters, and ordering alone cannot carry that.
- **Hairlines and hover states, never bordered containers.** The row is the unit.
- **Recency, not alphabetical.** Alphabetical is a filing rule; a portfolio needs an
  editorial one.
- **Sections with nothing in them are hidden from visitors** and shown to the owner as
  an affordance.

### One vibe. Hierarchy without scale.

The first version of this design gave the lead item large type and justified it by
splitting the product into a "portfolio surface" and a "tool surface" with different
rules. **That was wrong and is not what ships.** Two design languages in one product is
how a product stops feeling like one thing, and it contradicted this file's own
philosophy — quiet confidence, the UI disappears, small type throughout.

Nothing exceeds 14px except the page title. The lead item is distinguished by
**everything except size**:

| Device | Lead | List row |
|---|---|---|
| Eyebrow label | yes | no |
| Weight | 600 | 500 |
| Description | full width, roomy | compact |
| Space around it | generous | tight |
| Separator | hairline below | none |

The cost, stated plainly: the distinction is **subtle**. One weight step is not much,
and the eyebrow and whitespace carry most of it. That is the right trade for a developer
tool — but it means the copy in the eyebrow is load-bearing, because it is the clearest
signal that this item is different.

**The eyebrow reads "Currently building"**, chosen over a neutral "Featured". It says
something rather than labelling something, which is what a portfolio built in public
wants. The known cost: it is a claim about the present, so pinning a finished project
makes the page lie. The answer if that becomes a problem is a per-pin label, not a
blander default.

The general rule this sets: **hierarchy comes from weight, colour and space. Reach for
size last, and outside a page title, probably not at all.**

### The front door

Steid is meant to be someone's site, reached at their own domain — `jpgill.dev`, not
`git.jpgill.dev`, because a `git.` subdomain announces a Gitea clone and this is
portfolio-first. That makes the root a signpost rather than a page: unclaimed it sends
you to setup, and otherwise it forwards to a profile — your own when signed in, **the
owner's when not**.

The last case is the one that matters. A stranger arriving at the apex came for the
profile and will never sign in, so a sign-in prompt there is the front door answering the
wrong question. It only applies when the owner is unambiguous: with more than one user
"the owner" has no answer, so the root falls back to a generic landing rather than
electing someone by storage order.

### What it needs that does not exist yet

- **`updated_at` on a repository.** The design promises recency and nothing stores it.
  Asking git costs one fork per repository — a twelve-repo profile would be ~150ms of
  `execve` before rendering. So it is a column, touched when a push is authorized.
  Slightly wrong if a push then fails; the alternative is wrong more expensively.
- **A `/{handle}/repos` index.** The Repositories tab needs a destination; only
  `/{handle}/repos/{name}` exists.
- **A way to choose the lead.** A `pinned` flag on the repository, set from the settings
  page that now exists. No pin, no lead section — automatic "most recent" would put a
  dotfiles tweak at the top of a portfolio.

### Mocking is harder than it looks here

**Tailwind classes the app does not already use are not in the built stylesheet.**
`build.rs` scans the real sources, so a mockup written against the served CSS silently
loses any new utility — `gap-7` and `tracking-widest` collapsed a nav into
`OverviewRepositories3WritingProjects` before this was understood. Iterate either in the
app itself or, for throwaway exploration, in plain CSS against the theme's custom
properties (`--background`, `--foreground`, `--muted-foreground`, `--border`, `--surface`).

Dark mode is a `.dark` class on an ancestor, not `prefers-color-scheme`; a mockup
without it renders light.

## Stack

Settled in [0005](decisions/0005-tailwind-and-copied-components.md).

| Concern | Choice |
|---|---|
| Styling | Tailwind via Topcoat's build script — no Node |
| Theme | `styles.css` at the package root: design tokens, dark-first |
| Components | `topcoat ui add` for primitives, hand-written for Steid's own |
| Mono | IBM Plex Mono, for paths, hashes, branches, handles |

### Tokens

Defined in `styles.css` on `:root` (light) and `.dark` (dark, the default):

```
background  surface  foreground  muted-foreground
primary     primary-foreground
success     success-foreground      confirmations, git additions
warning     warning-foreground      caution, git modifications
destructive destructive-foreground  errors, git deletions
border      ring      shadow-xs  shadow-sm
```

`surface`, `success` and `warning` are Steid's additions to Topcoat's `neutral` theme;
the rest were retuned to the palette below.

**Components reference tokens, never raw colours.** A hardcoded colour will not follow
a palette change and will not adapt to the colour scheme. This is the convention to
enforce in review.

### Working on it

```bash
topcoat dev              # rebuilds CSS and re-bundles on change
topcoat asset bundle     # manual builds only; a stale bundle serves stale CSS
topcoat ui list          # what the registry offers
topcoat ui add <name>    # copies source into src/components/
```

`components.toml` is the record of what is installed; don't duplicate that list here.

**A registry component can need more than a copy.** `select` draws its chevron with an
Iconify icon, which needs the `icon-iconify` feature on `topcoat` *and* the icon set
staged in `build.rs`:

```rust
topcoat::icon::iconify::BuildConfig::new().icon_set("feather").stage().unwrap();
```

The build fails with a message naming the missing set, so it is discoverable — but the
build.rs edit is easy not to expect from a command that only advertises copying a file.
Icons are embedded at build time; nothing is fetched at runtime.

`topcoat ui add` also rewrites the module list in `src/components.rs` by appending, so
re-alphabetise it afterwards.

## Prior art

Attempt #1 (`steid-backup/AGENTS/UI.md`) has a complete 643-line design system —
OKLCH light/dark palettes with concrete token values, a type scale, spacing scale, and
component markup for sidebar, file tree, commit bar, breadcrumbs, badges, empty
states. It was written for Tailwind + DaisyUI.

Its palette has been mined already — the dark-first OKLCH values at hue 260 are what
`styles.css` was retuned to. Its component markup has not, and comes with two caveats:
it specifies DaisyUI, which is a separate choice from Tailwind and not bundled by
Topcoat; and it was written for a GitHub-shaped forge rather than a portfolio-first
one.

It is also, on its own, longer than every other doc in this directory combined — for
an app that had about nine pages. Take the palette and the principles. Don't
re-specify components before there are components.
