# Steid

A personal-first gitforge in Rust. Hosts git repos, writing, and projects for one
developer or an organisation. **Portfolio-first, not a Gitea clone** — the profile page
is the product and repos are one kind of thing on it. Use that to break ties.

## Read first

`plans/` is the source of truth and is tracked in git. Read it at the start of a
session rather than inferring intent from the code.

| File | Holds |
|---|---|
| `plans/ROADMAP.md` | vision, stack, the single milestone ladder |
| `plans/current.md` | the active milestone only — steps, watch-fors, backlog |
| `plans/progress.md` | what shipped, and the decisions worth not rediscovering |
| `plans/architecture.md` | layer rules and conventions |
| `plans/runbook.md` | how to run it, config, manual verification |
| `plans/decisions/` | ADRs; `TEMPLATE.md` defines the format |

## Keep the docs current

**Before reporting work complete, update `plans/`.** This is part of finishing the
work, not a follow-up chore — the project has been restarted three times and the
previous attempts lost their reasoning at exactly these handover points.

- `current.md` — tick off finished steps; move completed work out to `progress.md`.
  If it starts reading like a changelog, it has drifted.
- `progress.md` — record decisions and gotchas that aren't obvious from the code. The
  test is: would the next session waste an hour rediscovering this?
- `ROADMAP.md` — only when a milestone's status actually changes.
- `decisions/` — a new ADR when a choice would be expensive to reverse or constrains
  future work. Write it when the decision is made; reconstructed rationale is fiction.

Also update `current.md` when a *new* problem is found — an unfinished item, a
shortcut taken, a hole opened. Carry those forward explicitly at milestone rollover
rather than letting them vanish.

Skip all of this for typos, formatting, and dependency bumps.

## Working style

- **Commit directly to `main`.** No feature branches — solo repo. Cleanliness comes
  from small, self-contained commits that each compile, not from branching.
- Small steps. Build up slowly; prefer a working increment over a big drop.
- Explain *why* in commit messages, not just what.

### Plan the step, then execute it

Milestones are planned in `current.md` as a list of steps. **Before executing a step,
lay out what it will contain — files, signatures, the tests worth writing, and any
decision inside it — and wait.** Then do that one step and stop.

A milestone-level sketch is not a step-level plan. Answering the open questions in a
plan is not the same as approving the code; ask before starting.

### Decisions belong to the user

Surface a choice rather than picking a sensible-looking default, especially anything
expensive to reverse: URL shape, storage layout, visibility defaults, dependencies.
Give a recommendation and the trade-off, then let them decide.

Corollary: **don't add scope that wasn't asked for.** If something seems obviously
needed, propose it. `Repository::description` was added unrequested and had to be
flagged after the fact.

### Docs ship with the code

Update `plans/` in the **same commit** as the change it describes, not a follow-up
`docs:` commit. This has slipped repeatedly; a separate commit is the symptom.

## Conventions

Full detail in `plans/architecture.md`. The short version:

- **Layers:** `domain` (no knowledge of HTTP/SQL/git/Topcoat) → `application` (use
  cases and ports) → `infrastructure` (adapters, web). Dependencies point inward.
- **Every use case takes an `Actor`** and enforces authorization before any side
  effect — one place, reachable from a page, an `/api` route, or a future transport.
- **Typed IDs**, never raw `String` for entity references.
- **`new()` validates, `from_trusted()` doesn't.** Storage adapters use
  `from_trusted`; re-validating stored rows makes a tightened rule unreadable.
- **`from_str` returns `Result`, never `Option`.** A silently-defaulted enum surfaces
  later as the wrong permissions.
- **Every repository port gets two implementations** — in-memory (what makes use cases
  testable without a database) and SQLite.
- Request helpers are **functions taking `cx`**, not middleware or extractors. A page
  that forgets to call one gets nothing; a route added without middleware silently
  gets someone else's data.
- Safe Rust only. No `unsafe`.

## Topcoat, as we use it

Working knowledge that is easy to get wrong and slow to rediscover:

- **Components are invoked bare inside `view!`** — `label(attrs: …, "Text")`, not
  `(label(…)?)`. `if`, `match`, `for` and `let` are native to the macro.
- **`#[path_param]` is an attribute on a tuple struct** — `#[path_param] struct
  Handle(str);` — and the struct name snake-cased is the URL parameter.
- **`#[query_params]` needs `error = …`** to be usable with `?`; otherwise the error
  borrows from `cx` and escapes the handler.
- **`redirect()` is a 307 and preserves the method**, so it must never end a form POST —
  the browser re-POSTs to the target. Post/redirect/get needs a 303. `see_other()` is
  that status but is a *response* type, and `#[page]` must return a view so the layout
  can wrap a failure re-render, so use `web::context::location` with
  `StatusCode::SEE_OTHER` inside `view!`. `Err(redirect(..).into())` is still right for
  a **GET** guard sending a visitor elsewhere.
- **Static routes beat parameterised ones**, so `/auth/login` still wins over
  `/{handle}`.
- **Forms redirect on success and re-render on failure.** Redirecting after a validation
  error discards what was typed and hides the reason. The success redirect is a 303 —
  see above.
- **Boolean attributes need a value** — `required=(true)`, not bare `required`. A `false`
  omits the attribute entirely, so `selected=(bool)` is correct.
- **`view!` needs `__cx` in scope, so a plain `async fn helper(cx: &Cx) -> Result`
  cannot build a view.** Make it `#[component] async fn helper(cx: &Cx, …)` — a
  component may declare `cx: &Cx` and it is *not* passed at the call site. The error is
  a bare "cannot find value `__cx`" pointing into the macro, which names nothing useful.
- **A `#[page]` or `#[component]`'s name becomes a unit struct in module scope**, so it
  shadows anything of the same name elsewhere in the file — parameters *and* `let`
  bindings. A component called `commits` broke `fn commit_log(commits: &[CommitSummary])`;
  separately, `let profile = …` inside a module containing `#[page] async fn profile`
  parses as a **unit-struct pattern rather than a new binding**, and the error mentions
  neither the page nor the shadowing. Name locals for what they hold, not for the page
  they serve.
- **Catch-all params are `{*path}`, read with `#[path_param] struct Path(str);`** — the
  `*` is not part of the name. The whole tail arrives as one percent-decoded string.
  Matching happens on the *raw* path, which is why `%2F` inside a `{rev}` segment
  survives as a single segment and decodes to a slash.
- **Tailwind only ships classes the app already uses.** `build.rs` scans the real
  sources, so a class that appears nowhere in `src/` is absent from the built CSS and
  fails silently — spacing collapses, nothing errors. A throwaway mockup must therefore
  be written in plain CSS against the theme's custom properties, not in Tailwind against
  the served stylesheet. Dark mode is a `.dark` class on an ancestor, not
  `prefers-color-scheme`.
- **UI components reference theme tokens, never raw colours** — see `styles.css`. A
  hardcoded colour follows neither a palette change nor the colour scheme. Registry
  components are copied in by `topcoat ui add`, not depended on.

## Before saying it's done

```bash
cargo test
cargo clippy --all-targets    # expect zero warnings
cargo fmt
```

Report counts accurately — don't state a test number without running it.

Verify behaviour rather than asserting it. A passing unit test is not evidence that a
page works; the session-cookie bug passed every test and failed silently in the
browser. Say plainly what was checked and what wasn't.

### End with what it now lets the user do

Close every completion report with a short rundown in user terms: what can be done now
that couldn't be before, and how — the URL, the command, the thing to click.

**If the answer is "nothing yet", say so plainly**, and name what is still missing
before the capability appears. A step that adds no user-visible capability is normal
and expected; going several steps without noticing is not. Two attempts died inside
plumbing that felt like progress, and this is the check against a third.

It is a rundown of capability, not of files touched — that is the commit message's job.

## Gotchas

- **Topcoat 0.5 needs rustc ≥ 1.95.** On older toolchains `cargo add topcoat` silently
  resolves to an empty `topcoat v0.0.0` placeholder instead of failing.
- **Read the vendored crate, not GitHub `main`.** Topcoat is very new (first release
  2026-07-22) and its repository has already diverged from the released version. The
  authority for the pinned version is
  `~/.cargo/registry/src/*/topcoat-0.5.0/docs/` and the sibling `topcoat-*-0.5.0`
  crates. Checking `main` is how `path_param` was got wrong.
- **`STEID_INSECURE_COOKIES=true`** is set in a gitignored `.env` for local dev,
  because a `Secure` cookie is dropped silently over plain-HTTP localhost. Never
  deploy it.
- **The setup token is in memory only**, so every restart — including each `topcoat
  dev` rebuild — mints a new one.
- Don't leave background servers running; the user drives the app.
- **`cargo build --release` alone produces a binary that will not boot.** `main` calls
  `AssetBundle::load()`, which walks up from the executable looking for
  `assets/manifest.toml` — and `build.rs` does not write one. `topcoat asset bundle`
  does, and it runs `cargo build` itself, so it is the build command, not a step after
  it. A `cargo build`-only container image compiles cleanly and then fails at startup
  with `NotFound`. Found while writing the Dockerfile.
- **`topcoat asset bundle` after a manual build**, or the CSS served is stale.
  `topcoat dev` does it for you. **The symptom is silently wrong layout, not an error** —
  new utility classes simply do not exist, so gaps collapse and sizes fall back to
  defaults, and the page looks like a design mistake rather than a stale build.
- **The build needs network beyond crates.io**: `build.rs` downloads the standalone
  Tailwind CLI from GitHub releases, and those binaries are glibc-linked — which is why
  the container is Debian on both stages and a musl/Alpine builder fails at `cargo
  build`.
- In `sqlite.rs` and similar, **every implementation precedes the `mod tests` block**.
  Appending to the end of the file otherwise lands inside the wrong block.
