| 1 | # Steid |
| 2 | |
| 3 | A personal-first gitforge in Rust. Hosts git repos, writing, and projects for one |
| 4 | developer or an organisation. **Portfolio-first, not a Gitea clone** — the profile page |
| 5 | is the product and repos are one kind of thing on it. Use that to break ties. |
| 6 | |
| 7 | ## Read first |
| 8 | |
| 9 | `plans/` is the source of truth and is tracked in git. Read it at the start of a |
| 10 | session rather than inferring intent from the code. |
| 11 | |
| 12 | | File | Holds | |
| 13 | |---|---| |
| 14 | | `plans/ROADMAP.md` | vision, stack, the single milestone ladder | |
| 15 | | `plans/current.md` | the active milestone only — steps, watch-fors, backlog | |
| 16 | | `plans/progress.md` | what shipped, and the decisions worth not rediscovering | |
| 17 | | `plans/architecture.md` | layer rules and conventions | |
| 18 | | `plans/runbook.md` | how to run it, config, manual verification | |
| 19 | | `plans/decisions/` | ADRs; `TEMPLATE.md` defines the format | |
| 20 | |
| 21 | ## Keep the docs current |
| 22 | |
| 23 | **Before reporting work complete, update `plans/`.** This is part of finishing the |
| 24 | work, not a follow-up chore — the project has been restarted three times and the |
| 25 | previous attempts lost their reasoning at exactly these handover points. |
| 26 | |
| 27 | - `current.md` — tick off finished steps; move completed work out to `progress.md`. |
| 28 | If it starts reading like a changelog, it has drifted. |
| 29 | - `progress.md` — record decisions and gotchas that aren't obvious from the code. The |
| 30 | test is: would the next session waste an hour rediscovering this? |
| 31 | - `ROADMAP.md` — only when a milestone's status actually changes. |
| 32 | - `decisions/` — a new ADR when a choice would be expensive to reverse or constrains |
| 33 | future work. Write it when the decision is made; reconstructed rationale is fiction. |
| 34 | |
| 35 | Also update `current.md` when a *new* problem is found — an unfinished item, a |
| 36 | shortcut taken, a hole opened. Carry those forward explicitly at milestone rollover |
| 37 | rather than letting them vanish. |
| 38 | |
| 39 | Skip all of this for typos, formatting, and dependency bumps. |
| 40 | |
| 41 | ## Working style |
| 42 | |
| 43 | - **Commit directly to `main`.** No feature branches — solo repo. Cleanliness comes |
| 44 | from small, self-contained commits that each compile, not from branching. |
| 45 | - Small steps. Build up slowly; prefer a working increment over a big drop. |
| 46 | - Explain *why* in commit messages, not just what. |
| 47 | |
| 48 | ### Plan the step, then execute it |
| 49 | |
| 50 | Milestones are planned in `current.md` as a list of steps. **Before executing a step, |
| 51 | lay out what it will contain — files, signatures, the tests worth writing, and any |
| 52 | decision inside it — and wait.** Then do that one step and stop. |
| 53 | |
| 54 | A milestone-level sketch is not a step-level plan. Answering the open questions in a |
| 55 | plan is not the same as approving the code; ask before starting. |
| 56 | |
| 57 | ### Decisions belong to the user |
| 58 | |
| 59 | Surface a choice rather than picking a sensible-looking default, especially anything |
| 60 | expensive to reverse: URL shape, storage layout, visibility defaults, dependencies. |
| 61 | Give a recommendation and the trade-off, then let them decide. |
| 62 | |
| 63 | Corollary: **don't add scope that wasn't asked for.** If something seems obviously |
| 64 | needed, propose it. `Repository::description` was added unrequested and had to be |
| 65 | flagged after the fact. |
| 66 | |
| 67 | ### Docs ship with the code |
| 68 | |
| 69 | Update `plans/` in the **same commit** as the change it describes, not a follow-up |
| 70 | `docs:` commit. This has slipped repeatedly; a separate commit is the symptom. |
| 71 | |
| 72 | ## Conventions |
| 73 | |
| 74 | Full detail in `plans/architecture.md`. The short version: |
| 75 | |
| 76 | - **Layers:** `domain` (no knowledge of HTTP/SQL/git/Topcoat) → `application` (use |
| 77 | cases and ports) → `infrastructure` (adapters, web). Dependencies point inward. |
| 78 | - **Every use case takes an `Actor`** and enforces authorization before any side |
| 79 | effect — one place, reachable from a page, an `/api` route, or a future transport. |
| 80 | - **Typed IDs**, never raw `String` for entity references. |
| 81 | - **`new()` validates, `from_trusted()` doesn't.** Storage adapters use |
| 82 | `from_trusted`; re-validating stored rows makes a tightened rule unreadable. |
| 83 | - **`from_str` returns `Result`, never `Option`.** A silently-defaulted enum surfaces |
| 84 | later as the wrong permissions. |
| 85 | - **Every repository port gets two implementations** — in-memory (what makes use cases |
| 86 | testable without a database) and SQLite. |
| 87 | - Request helpers are **functions taking `cx`**, not middleware or extractors. A page |
| 88 | that forgets to call one gets nothing; a route added without middleware silently |
| 89 | gets someone else's data. |
| 90 | - Safe Rust only. No `unsafe`. |
| 91 | |
| 92 | ## Topcoat, as we use it |
| 93 | |
| 94 | Working knowledge that is easy to get wrong and slow to rediscover: |
| 95 | |
| 96 | - **Components are invoked bare inside `view!`** — `label(attrs: …, "Text")`, not |
| 97 | `(label(…)?)`. `if`, `match`, `for` and `let` are native to the macro. |
| 98 | - **`#[path_param]` is an attribute on a tuple struct** — `#[path_param] struct |
| 99 | Handle(str);` — and the struct name snake-cased is the URL parameter. |
| 100 | - **`#[query_params]` needs `error = …`** to be usable with `?`; otherwise the error |
| 101 | borrows from `cx` and escapes the handler. |
| 102 | - **`redirect()` is a 307 and preserves the method**, so it must never end a form POST — |
| 103 | the browser re-POSTs to the target. Post/redirect/get needs a 303. `see_other()` is |
| 104 | that status but is a *response* type, and `#[page]` must return a view so the layout |
| 105 | can wrap a failure re-render, so use `web::context::location` with |
| 106 | `StatusCode::SEE_OTHER` inside `view!`. `Err(redirect(..).into())` is still right for |
| 107 | a **GET** guard sending a visitor elsewhere. |
| 108 | - **Static routes beat parameterised ones**, so `/auth/login` still wins over |
| 109 | `/{handle}`. |
| 110 | - **Forms redirect on success and re-render on failure.** Redirecting after a validation |
| 111 | error discards what was typed and hides the reason. The success redirect is a 303 — |
| 112 | see above. |
| 113 | - **Boolean attributes need a value** — `required=(true)`, not bare `required`. A `false` |
| 114 | omits the attribute entirely, so `selected=(bool)` is correct. |
| 115 | - **`view!` needs `__cx` in scope, so a plain `async fn helper(cx: &Cx) -> Result` |
| 116 | cannot build a view.** Make it `#[component] async fn helper(cx: &Cx, …)` — a |
| 117 | component may declare `cx: &Cx` and it is *not* passed at the call site. The error is |
| 118 | a bare "cannot find value `__cx`" pointing into the macro, which names nothing useful. |
| 119 | - **A `#[page]` or `#[component]`'s name becomes a unit struct in module scope**, so it |
| 120 | shadows anything of the same name elsewhere in the file — parameters *and* `let` |
| 121 | bindings. A component called `commits` broke `fn commit_log(commits: &[CommitSummary])`; |
| 122 | separately, `let profile = …` inside a module containing `#[page] async fn profile` |
| 123 | parses as a **unit-struct pattern rather than a new binding**, and the error mentions |
| 124 | neither the page nor the shadowing. Name locals for what they hold, not for the page |
| 125 | they serve. |
| 126 | - **Catch-all params are `{*path}`, read with `#[path_param] struct Path(str);`** — the |
| 127 | `*` is not part of the name. The whole tail arrives as one percent-decoded string. |
| 128 | Matching happens on the *raw* path, which is why `%2F` inside a `{rev}` segment |
| 129 | survives as a single segment and decodes to a slash. |
| 130 | - **Tailwind only ships classes the app already uses.** `build.rs` scans the real |
| 131 | sources, so a class that appears nowhere in `src/` is absent from the built CSS and |
| 132 | fails silently — spacing collapses, nothing errors. A throwaway mockup must therefore |
| 133 | be written in plain CSS against the theme's custom properties, not in Tailwind against |
| 134 | the served stylesheet. Dark mode is a `.dark` class on an ancestor, not |
| 135 | `prefers-color-scheme`. |
| 136 | - **UI components reference theme tokens, never raw colours** — see `styles.css`. A |
| 137 | hardcoded colour follows neither a palette change nor the colour scheme. Registry |
| 138 | components are copied in by `topcoat ui add`, not depended on. |
| 139 | |
| 140 | ## Before saying it's done |
| 141 | |
| 142 | ```bash |
| 143 | cargo test |
| 144 | cargo clippy --all-targets # expect zero warnings |
| 145 | cargo fmt |
| 146 | ``` |
| 147 | |
| 148 | Report counts accurately — don't state a test number without running it. |
| 149 | |
| 150 | Verify behaviour rather than asserting it. A passing unit test is not evidence that a |
| 151 | page works; the session-cookie bug passed every test and failed silently in the |
| 152 | browser. Say plainly what was checked and what wasn't. |
| 153 | |
| 154 | ### End with what it now lets the user do |
| 155 | |
| 156 | Close every completion report with a short rundown in user terms: what can be done now |
| 157 | that couldn't be before, and how — the URL, the command, the thing to click. |
| 158 | |
| 159 | **If the answer is "nothing yet", say so plainly**, and name what is still missing |
| 160 | before the capability appears. A step that adds no user-visible capability is normal |
| 161 | and expected; going several steps without noticing is not. Two attempts died inside |
| 162 | plumbing that felt like progress, and this is the check against a third. |
| 163 | |
| 164 | It is a rundown of capability, not of files touched — that is the commit message's job. |
| 165 | |
| 166 | ## Gotchas |
| 167 | |
| 168 | - **Topcoat 0.5 needs rustc ≥ 1.95.** On older toolchains `cargo add topcoat` silently |
| 169 | resolves to an empty `topcoat v0.0.0` placeholder instead of failing. |
| 170 | - **Read the vendored crate, not GitHub `main`.** Topcoat is very new (first release |
| 171 | 2026-07-22) and its repository has already diverged from the released version. The |
| 172 | authority for the pinned version is |
| 173 | `~/.cargo/registry/src/*/topcoat-0.5.0/docs/` and the sibling `topcoat-*-0.5.0` |
| 174 | crates. Checking `main` is how `path_param` was got wrong. |
| 175 | - **`STEID_INSECURE_COOKIES=true`** is set in a gitignored `.env` for local dev, |
| 176 | because a `Secure` cookie is dropped silently over plain-HTTP localhost. Never |
| 177 | deploy it. |
| 178 | - **The setup token is in memory only**, so every restart — including each `topcoat |
| 179 | dev` rebuild — mints a new one. |
| 180 | - Don't leave background servers running; the user drives the app. |
| 181 | - **`cargo build --release` alone produces a binary that will not boot.** `main` calls |
| 182 | `AssetBundle::load()`, which walks up from the executable looking for |
| 183 | `assets/manifest.toml` — and `build.rs` does not write one. `topcoat asset bundle` |
| 184 | does, and it runs `cargo build` itself, so it is the build command, not a step after |
| 185 | it. A `cargo build`-only container image compiles cleanly and then fails at startup |
| 186 | with `NotFound`. Found while writing the Dockerfile. |
| 187 | - **`topcoat asset bundle` after a manual build**, or the CSS served is stale. |
| 188 | `topcoat dev` does it for you. **The symptom is silently wrong layout, not an error** — |
| 189 | new utility classes simply do not exist, so gaps collapse and sizes fall back to |
| 190 | defaults, and the page looks like a design mistake rather than a stale build. |
| 191 | - **The build needs network beyond crates.io**: `build.rs` downloads the standalone |
| 192 | Tailwind CLI from GitHub releases, and those binaries are glibc-linked — which is why |
| 193 | the container is Debian on both stages and a musl/Alpine builder fails at `cargo |
| 194 | build`. |
| 195 | - In `sqlite.rs` and similar, **every implementation precedes the `mod tests` block**. |
| 196 | Appending to the end of the file otherwise lands inside the wrong block. |