steid

@jamesgill /

chore: plans setup

JamesPatrickGill authored 1 month agoparent1111df1Browse filesab7fea973ad099de93aca2ff25c2ca7703ed25a7

7 files changed+555 −0

plans/ROADMAP.md+69 −0View file
@@ -0,0 +1,69 @@
1+# Steid
2+
3+The source of truth for everything in a developer's or organization's portfolio.
4+
5+## Vision
6+
7+Steid is a developer/org identity and portfolio platform. It's where you host your
8+code, your writing, your projects — everything you do, in one place, under your
9+control.
10+
11+Git repos are a core feature, not the whole product. Blogs, documentation, project
12+showcases, and anything else a dev or org wants to present lives here too.
13+
14+Personal-first, but organizations can collaborate on it.
15+
16+**What makes it different from Gitea/Forgejo:** those are GitHub clones scaled down.
17+Steid is portfolio-first — the profile page is the product, and repos are one kind of
18+thing that appears on it. That framing should win any tie-break.
19+
20+## Core Principles
21+
22+- **Fast** — Rust, minimal overhead
23+- **Reliable** — correct first, then optimize
24+- **Personal-first** — great for a single dev, scales to orgs
25+- **Identity-centric** — everything is built around who you are (user/org); auth is
26+ designed in from day one, not bolted on
27+
28+## Stack
29+
30+Starting intent, not settled decisions. Each one gets a record in
31+[decisions/](decisions/) when it's actually made and confirmed in this build.
32+
33+| Concern | Intent |
34+|---|---|
35+| Web framework | Topcoat (tokio-rs) |
36+| Git protocol | shell out to the `git` binary |
37+| SSH transport | embedded russh server |
38+| Crate layout | single crate |
39+| Ownership | personal org owns repos |
40+| Database | SQLite via sqlx |
41+
42+Architecture and conventions: [architecture.md](architecture.md).
43+
44+## Milestone Ladder
45+
46+This repo is a from-scratch rebuild on Topcoat. Milestones 1–4 were built and
47+verified end-to-end in a previous attempt — see [progress.md](progress.md) for what
48+that proved. Treat that code as reference, not as a baseline.
49+
50+| # | Milestone | Status |
51+|---|---|---|
52+| 0 | **Skeleton** — Topcoat app boots, config, one page, SQLite wired | active |
53+| 1 | **Identity** — users, orgs, memberships, sessions, registration policy | not started |
54+| 2 | **Repo model** — repo records + bare repos on disk | not started |
55+| 3 | **Git over SSH** — clone and push via embedded russh | not started |
56+| 4 | **SSH key auth + authz** — pubkey → user, membership-gated push/clone | not started |
57+| 5 | **Repo browsing** — tree, blob, commit log in the web UI | not started |
58+| 6 | **Portfolio** — blogs, pages, project showcases | not started |
59+| 7 | **Collaboration** — issues, PRs, review | not started |
60+| 8 | **API / federation** | not started |
61+
62+Milestones 5–8 have never been built. Everything below 5 has a working reference
63+implementation to crib from.
64+
65+**One numbering scheme.** The previous attempt kept two (a roadmap ladder and a
66+separate build log with conflicting numbers) and they drifted apart within three
67+milestones. If the plan changes, edit this table — don't start a second list.
68+
69+Milestone 0 is broken into steps in [current.md](current.md).
plans/architecture.md+117 −0View file
@@ -0,0 +1,117 @@
1+# Architecture
2+
3+## Layers
4+
5+DDD + clean architecture. Dependencies point inward. The domain knows nothing about
6+HTTP, SQL, git, or Topcoat.
7+
8+```
9+web / ssh (interface) → application (use cases) → domain (entities, ports)
10+
11+ infrastructure (sqlite, git binary, argon2)
12+ implements the ports the domain/application declare
13+```
14+
15+| Layer | Holds | Must not import |
16+|---|---|---|
17+| `domain` | entities, value objects, typed IDs, repository traits, `DomainError` | anything infrastructural |
18+| `application` | use cases, ports (`GitStorage`, `GitProtocolServer`, `PasswordHasher`), `AppConfig` | concrete adapters |
19+| `infrastructure` | SQLite repos, `git` subprocess wrappers, Argon2, russh, web handlers | — |
20+
21+**Every operation takes an `Actor`.** Authorization is checked in the use case, before
22+any side effect. Not in the handler, not in the adapter. The previous attempt got this
23+right and it paid off — `serve_push` could enforce `Role::Owner` in one place
24+regardless of transport.
25+
26+## Conventions
27+
28+These come from two prior attempts. They earned their place.
29+
30+### Typed IDs — never raw strings for entity references
31+
32+```rust
33+// CORRECT — the compiler catches a swapped argument
34+fn get_member(&self, org_id: &OrgId, user_id: &UserId) -> ...
35+
36+// WRONG — silently compiles, fails at runtime
37+fn get_member(&self, org_id: &str, user_id: &str) -> ...
38+```
39+
40+`UserId`, `OrgId`, `MembershipId`, `RepoId`, `InviteCodeId`, `SshKeyId`.
41+
42+### `new()` validates, `from_trusted()` doesn't
43+
44+`new()` is for user input and returns `Result`. `from_trusted()` is for rows loaded
45+out of the database, which were validated on the way in. Infrastructure repos should
46+always use `from_trusted()` — re-validating DB rows means a validation-rule change
47+turns old rows unreadable.
48+
49+### `from_str` returns `Result`, never `Option`
50+
51+A silently-defaulted enum parse is a bug that surfaces days later as wrong
52+permissions. Applies to `Role`, `Visibility`, `RegistrationPolicy`.
53+
54+### Every repository port gets two implementations
55+
56+An in-memory one and a SQLite one. The in-memory one is what makes use cases testable
57+without a database — attempt #2 reached 60 tests this way and they ran fast enough to
58+stay in the loop.
59+
60+### Ports live where they're consumed
61+
62+Repository traits in `domain/repository/`. Service ports the application needs
63+(`GitStorage`, `GitProtocolServer`, `PasswordHasher`) in `application/port.rs`. Use
64+cases take `&impl Port`, not a boxed trait object.
65+
66+## DB-plus-filesystem writes
67+
68+Creating a repo writes to two places that can't share a transaction: the
69+`repositories` row and the bare repo on disk. Neither prior attempt fully solved this.
70+
71+Attempt #1 used a **compensating transaction** — create on disk first, and if the DB
72+insert fails, delete the directory:
73+
74+```rust
75+let repo = self.repository.create(&id).await?;
76+if let Err(e) = self.repo_record_repo.create(&record).await {
77+ let _ = self.repository.delete(&id).await; // compensate
78+ return Err(e.into());
79+}
80+```
81+
82+That leaks an orphaned directory if the process dies between the two calls. Good
83+enough to ship; write down that it's a known hole rather than rediscovering it. The
84+durable fix is a reconciliation sweep on boot, or marking rows pending and committing
85+after the filesystem write lands.
86+
87+## Open question: Topcoat's data access vs clean architecture
88+
89+**This is the one genuinely unresolved design question, and it blocks Milestone 1.**
90+
91+Topcoat's model is async components that query the database directly and check
92+permissions inline, with `#[memoize]` deduplicating calls per request. State comes
93+from `app_context::<T>(cx)`, and the framework's own guidance is to "prefer composable
94+`cx: &Cx` functions over middleware/extractors for auth and request-scoped data."
95+
96+That is a view layer reaching straight for the data layer — the exact thing the layer
97+rules above forbid. The two models are in real tension and the resolution has to be
98+deliberate:
99+
100+- **Hold the line.** Components call use cases; use cases hold the ports. Costs some
101+ of Topcoat's ergonomics and may fight the framework's grain.
102+- **Let components read, force writes through use cases.** Reads go direct (they're
103+ the ones that benefit from memoization and per-component fetching); every mutation
104+ and every authorization decision stays in a use case. Pragmatic middle.
105+- **Adopt Topcoat's model fully.** Fastest to build, and abandons the layering that
106+ made the previous attempt's authorization work uniformly across web *and* SSH.
107+
108+The SSH transport is the thing that makes this non-obvious: `serve_push` has no `Cx`
109+and no request. Whatever authorization lives in a Topcoat component is unavailable to
110+it. Anything enforcing a permission needs to sit somewhere both transports can reach.
111+
112+Decide before Milestone 1 and write it up as decision 0001 — see
113+[decisions/TEMPLATE.md](decisions/TEMPLATE.md).
114+
115+## Safety
116+
117+Safe Rust only. No `unsafe`.
plans/current.md+57 −0View file
@@ -0,0 +1,57 @@
1+# Current
2+
3+> Keep this file short. One active step, one ordered backlog. Completed work moves to
4+> [progress.md](progress.md). If this file starts reading like a changelog, it has
5+> drifted — that's exactly what went wrong last time.
6+
7+## Active: Milestone 0 — Skeleton
8+
9+**Goal:** a Topcoat app that boots, serves one page, reads config from env, and opens
10+a SQLite pool. No domain logic yet. The point is to learn Topcoat's shape before
11+committing the architecture to it.
12+
13+### Steps
14+
15+- [ ] Add `topcoat` 0.5 + `tokio` to `Cargo.toml`; install `topcoat-cli`
16+- [ ] Get the getting-started hello-world page rendering
17+- [ ] Work out how `#[page]` / `module_router!` discovery wants the source tree laid
18+ out — this constrains everything after it
19+- [ ] Config from env via `envy` (`STEID_*`), mirroring `.env.dev` from the previous
20+ attempt (see [runbook.md](runbook.md))
21+- [ ] SQLite pool registered as app context; confirm a page can read it via
22+ `app_context::<T>(cx)`
23+- [ ] Decide the layering question in [architecture.md](architecture.md#open-question-topcoats-data-access-vs-clean-architecture)
24+ — this is the one genuinely open design question and it blocks Milestone 1
25+
26+### Done when
27+
28+`cargo run` (or `topcoat` CLI) serves a page that renders a value read from SQLite,
29+with config supplied by env.
30+
31+## Backlog
32+
33+Ordered. Pull from the top.
34+
35+1. **Milestone 1 — Identity.** Domain model (User, Org, Membership, Actor, Role),
36+ value objects (Email, PasswordHash), typed IDs, repository ports, Argon2 hashing,
37+ registration policy, session cookies. Reference implementation exists and was
38+ solid — port the domain layer, rewrite the web layer.
39+2. **Milestone 2 — Repo model.** `Repository` entity, `Visibility`, `create_repo` use
40+ case, bare repo on disk at `{data_dir}/{org}/{repo}.git`. Watch the
41+ DB-plus-filesystem atomicity problem — see
42+ [architecture.md](architecture.md#db-plus-filesystem-writes).
43+3. **Milestone 3 — Git over SSH.** `GitProtocolServer` port, `GitBinary` adapter,
44+ embedded russh. Channel-splitting is fiddly; the notes in
45+ [progress.md](progress.md#ssh-channel-bridging) are hard-won.
46+4. **Milestone 4 — SSH key auth + authz.**
47+
48+## Open questions
49+
50+- **Topcoat is nine days old** (v0.5.0, first release 2026-07-22, breaking changes
51+ expected). Pin the exact version and expect to chase it. Budget time for churn that
52+ isn't feature work.
53+- Does Topcoat's asset/CSS pipeline coexist with an embedded russh server in one
54+ binary, or does the CLI's watch-and-rebuild model push SSH into a separate process?
55+ Unresolved — affects the embedded-SSH approach and single-binary deploy.
56+- Topcoat ships Tailwind without Node. That reopens the design system from attempt #1
57+ that was dropped purely to avoid an npm build step — see [ui.md](ui.md).
plans/decisions/TEMPLATE.md+50 −0View file
@@ -0,0 +1,50 @@
1+# NNNN — Short imperative title
2+
3+**Status:** proposed | accepted | superseded by [NNNN](NNNN-slug.md) · **Date:** YYYY-MM-DD
4+
5+## Context
6+
7+The situation that forces a choice. What's true right now, what pressure it creates,
8+and what breaks if nothing changes. Write enough that someone who wasn't there can
9+tell whether this still applies — including the constraints that turn out to matter
10+later (team size, scale, dependencies, deadlines).
11+
12+No decision here. Just the facts.
13+
14+## Decision
15+
16+What was chosen, stated plainly and in the active voice. One or two sentences is
17+usually right.
18+
19+## Alternatives considered
20+
21+What else was on the table and why it lost. This is the part future-you actually
22+needs — a rejected option with no recorded reason gets re-litigated every six months.
23+
24+- **Option** — why not.
25+
26+## Consequences
27+
28+What follows, good and bad. Be honest about the costs; a record listing only benefits
29+is marketing, not a decision.
30+
31+- What this makes easy
32+- What this makes hard
33+- New dependencies, obligations, or known holes it opens
34+- How reversible it is, and what reversing would cost
35+
36+---
37+
38+## How to use this directory
39+
40+- One file per decision, numbered sequentially: `0001-slug.md`. Numbers are never
41+ reused, and files are never deleted — a decision that stops applying gets its status
42+ changed to **superseded**, with a link to the one that replaced it. The trail of
43+ wrong turns is the point.
44+- Write one when a choice would be expensive to reverse, when it constrains future
45+ work, or when the reasoning wouldn't be obvious from reading the code. Skip it for
46+ anything the code makes self-evident.
47+- Write it **when the decision is made**, not afterwards. Reconstructed rationale is
48+ mostly fiction.
49+- Link to the record from wherever the choice shows up — `ROADMAP.md`,
50+ `architecture.md` — rather than restating the reasoning in both places.
plans/progress.md+102 −0View file
@@ -0,0 +1,102 @@
1+# Progress
2+
3+## This attempt (#3, Topcoat)
4+
5+Nothing shipped yet. Milestone 0 in progress — see [current.md](current.md).
6+
7+---
8+
9+## Reference: what attempt #2 proved
10+
11+Not this repo's progress. This is a catalogue of what was built and **verified working**
12+in `steid-backup-2026-07-31`, so the rebuild can crib rather than rediscover.
13+
14+Final state: single crate, ~4,200 LOC, 60 passing tests, five milestones.
15+
16+### Identity
17+
18+Domain model (User, Organization, Membership, Actor, Role), `Email` and
19+`PasswordHash` value objects, typed IDs, `DomainError`, four repository ports with
20+in-memory and SQLite implementations each. `RegistrationPolicy` (Personal / Invite /
21+Open) driving which routes exist. Argon2 hashing behind a `PasswordHasher` port with a
22+stub for tests. Use cases: `bootstrap_owner`, `register_user`, `login`, `create_invite`.
23+Signed-cookie sessions via an `AuthUser` extractor.
24+
25+**Gotcha:** organizations must be saved before users — the FK runs that direction.
26+Both `bootstrap_owner` and `register_user` had to be fixed for this.
27+
28+### Repo model
29+
30+`Repository` entity, `RepoId`, `Visibility` (Public/Private), `RepoRepository` port,
31+migration `006_create_repositories.sql`. `create_repo` use case validates the name,
32+rejects duplicates, and initialises the bare repo on disk in the same call. Bare repos
33+live at `{data_dir}/{org}/{repo}.git`, `data_dir` defaulting to `./data`. Repos are
34+created empty, no initial commit, like GitHub.
35+
36+### Git over SSH
37+
38+`GitStorage` port (`init_bare`, `repo_path`) with `DiskGitStorage` shelling out to
39+`git init --bare`. `GitProtocolServer` port (`upload_pack`, `receive_pack`) with
40+`GitBinary` spawning `git upload-pack` / `git receive-pack` via
41+`tokio::process::Command` and pumping stdio with `tokio::io::copy`.
42+
43+`serve_clone` and `serve_push` use cases enforce visibility and actor checks **before
44+any protocol byte flows** — that ordering is the whole point of putting them in the
45+application layer.
46+
47+#### SSH channel bridging
48+
49+The fiddly part, and worth re-reading before Milestone 3:
50+
51+- Store `Channel<Msg>` per `ChannelId` in the handler's map on `channel_open_session`
52+- On `exec_request`, take the channel, split it with `into_stream()` +
53+ `tokio::io::split`
54+- Take stderr via `make_writer_ext(Some(1))` **before** `into_stream()` — that call
55+ consumes the channel, so the order is not optional
56+- No `data()` or `channel_eof()` handlers needed once the streams are split
57+
58+An earlier iteration used mpsc channels, custom `ChannelReader`/`ChannelWriter`, and a
59+`spawn_blocking` thread. All of it was deleted and the result was simpler.
60+
61+### SSH key auth and authorization
62+
63+`SshKey { id, user_id, name, fingerprint, openssh }` + port, migration
64+`007_create_ssh_keys.sql` (`fingerprint` UNIQUE). Fingerprints are SHA256 via
65+`russh::keys::ssh_key::PublicKey::fingerprint(HashAlg::Sha256)`, stored as `SHA256:…`.
66+`add_ssh_key` parses the openssh blob, dedupes on fingerprint, and re-encodes to a
67+canonical form before storing.
68+
69+`auth_none` rejects. `auth_publickey` fingerprints the offered key, looks it up, and
70+on a match stores `user_id` on the handler; `exec_request` builds the real `Actor`
71+from it.
72+
73+Authorization rules as shipped:
74+
75+| Operation | Requirement |
76+|---|---|
77+| Clone, public repo | open |
78+| Clone, private repo | any membership in the repo's org |
79+| Push | `Role::Owner` membership in the repo's org |
80+
81+Web UI at `/{owner}/keys` — owner-only, lists fingerprints, accepts openssh via
82+textarea, revokes per-row.
83+
84+**Verified end-to-end:** clone with an unregistered key → `Permission denied` (exit
85+128); register via web UI → clone and push both succeed; second unregistered key →
86+rejected at auth; revoke via web UI → subsequent clone rejected at auth.
87+
88+### Security notes
89+
90+Attempt #2 ran with a **named, deliberate backdoor** between milestones: SSH accepted
91+any connection and passed a placeholder `Actor` (`UserId("ssh-anonymous")`), leaving
92+push open to anyone who could reach the port. It was recorded with an explicit
93+tightening point (`ssh.rs::exec_request`) and a closing milestone, and it did close.
94+
95+That practice is worth keeping. When this attempt opens a hole to make progress, name
96+it, name the line that closes it, and name the milestone.
97+
98+### Never built
99+
100+Repo browsing (tree/blob/log), HTTP smart protocol, personal access tokens, flash
101+messages, issues, PRs, blogs, pages, project showcases. Milestones 5–8 in
102+[ROADMAP.md](ROADMAP.md) are all greenfield.
plans/runbook.md+93 −0View file
@@ -0,0 +1,93 @@
1+# Runbook
2+
3+> Attempt #2's only setup instructions lived in a plan file describing an architecture
4+> that had already been deleted, so they were actively wrong. Keep this file honest:
5+> if a command here doesn't work, fix it or delete it.
6+
7+## Status
8+
9+Milestone 0 is in progress and the app doesn't boot yet. Everything below marked
10+**(#2)** is carried from the previous attempt and needs re-verifying against Topcoat
11+before it can be trusted.
12+
13+## Dev setup
14+
15+```bash
16+cargo install topcoat-cli # dev server, asset bundling, watch mode
17+cargo run # or the topcoat CLI once routing is wired
18+```
19+
20+Topcoat's CLI builds the app, bundles assets, and watches source directories for
21+rebuilds. How that interacts with the embedded SSH server is unresolved — see
22+[current.md](current.md#open-questions).
23+
24+## Configuration (#2)
25+
26+Env vars, `STEID_` prefixed, loaded with `dotenvy` + `envy`. From attempt #2's
27+`.env.dev`:
28+
29+```
30+STEID_REGISTRATION=personal # personal | invite | open
31+STEID_OWNER_EMAIL=admin@localhost.dev
32+STEID_OWNER_PASSWORD=changeme
33+STEID_OWNER_USERNAME=admin
34+STEID_DATABASE_URL=sqlite:steid.db?mode=rwc
35+STEID_LISTEN_ADDR=127.0.0.1:3000
36+STEID_DATA_DIR=./data # bare repos live here
37+```
38+
39+In `personal` mode the owner account is bootstrapped from `STEID_OWNER_*` on first
40+boot. Keep `.env.prod` out of git.
41+
42+## Repo layout on disk (#2)
43+
44+Bare repos at `{STEID_DATA_DIR}/{org}/{repo}.git`. Created empty — no initial commit.
45+
46+## SSH (#2)
47+
48+The SSH server is embedded (russh), not OpenSSH — there is no `authorized_keys`
49+configuration and no forced command. Users register public keys through the web UI at
50+`/{owner}/keys`, and the server matches incoming keys by SHA256 fingerprint.
51+
52+Host key generation and persistence was never written down. Sort it out during
53+Milestone 3 and document it here — a host key regenerated on each boot means every
54+client gets a changed-host-key warning.
55+
56+```bash
57+git clone git@host:owner/repo
58+```
59+
60+## Manual verification checklist (#2)
61+
62+Attempt #2 verified these by hand each milestone but never wrote down the steps. They
63+are the smoke test for Milestones 2–4:
64+
65+- [ ] Create a repo via the web UI → bare repo appears at
66+ `{data_dir}/{org}/{repo}.git`
67+- [ ] `git clone` an empty repo → succeeds
68+- [ ] `git clone` a repo with history → succeeds
69+- [ ] `git clone` a non-existent repo → clean error, not a hang or panic
70+- [ ] First push to an empty repo → succeeds
71+- [ ] Push to a repo with history → succeeds
72+- [ ] Clone with an unregistered key → `Permission denied`, exit 128
73+- [ ] Register key via `/{owner}/keys` → clone and push both succeed
74+- [ ] Revoke key via web UI → subsequent clone rejected at auth
75+- [ ] Clone a private repo as a non-member → rejected
76+- [ ] Push as a non-owner member → rejected
77+
78+Worth automating as an integration test rather than re-running by hand a fourth time.
79+
80+## Suggested `.gitignore` additions
81+
82+Not applied yet — no code to ignore. When the app lands:
83+
84+```
85+/data
86+*.db
87+*.db-shm
88+*.db-wal
89+.env.prod
90+```
91+
92+**Do not add `/plans`.** Attempt #2 did, and that is why these docs had to be
93+hand-carried between repos.
plans/ui.md+67 −0View file
@@ -0,0 +1,67 @@
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+| Principle | Meaning |
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+
32+## Stack
33+
34+Topcoat bundles assets and ships Tailwind **without Node**, plus htmx and Alpine
35+integrations, Fontsource, and Iconify.
36+
37+That's worth noting: the previous attempt dropped Tailwind purely to avoid an npm
38+build step and hand-rolled CSS instead. Topcoat removes that objection, so Tailwind is
39+back on the table at no tooling cost.
40+
41+Suggested, none of it settled until Milestone 5 needs real pages:
42+
43+| Concern | Candidate |
44+|---|---|
45+| Styling | Tailwind via Topcoat's bundler |
46+| UI font | Inter (Fontsource) |
47+| Mono font | IBM Plex Mono |
48+| Icons | Iconify |
49+| Interactivity | Topcoat signals; htmx where signals fall short |
50+
51+Topcoat's own client reactivity is early and acknowledged as limited, which is why the
52+htmx integration exists. Reach for signals first, fall back without ceremony.
53+
54+## Prior art
55+
56+Attempt #1 (`steid-backup/AGENTS/UI.md`) has a complete 643-line design system —
57+OKLCH light/dark palettes with concrete token values, a type scale, spacing scale, and
58+component markup for sidebar, file tree, commit bar, breadcrumbs, badges, empty
59+states. It was written for Tailwind + DaisyUI.
60+
61+It is worth mining when Milestone 5 arrives, with two caveats: it specifies DaisyUI,
62+which is a separate choice from Tailwind and not bundled by Topcoat; and it was
63+written for a GitHub-shaped forge rather than a portfolio-first one.
64+
65+It is also, on its own, longer than every other doc in this directory combined — for
66+an app that had about nine pages. Take the palette and the principles. Don't
67+re-specify components before there are components.