Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Remove the retired UI plan document

7defd797e7f48d340b6986f925be59f853733714 · Keenan Nemetz · 10d ago · parent 258ed801

Remove the retired UI plan document

The UI plan was working notes for the web surface, and the rules that still
matter now live in AGENTS.md, which is where a contributor already reads the
copy guidance. Drop the file, and the package comment that sent readers to it.

docs/ui-plan.md −417

@@ -1,417 +0,0 @@
1 # UI plan
2
3 Working notes for Vole's web surface. Decisions here are the brief; change them
4 deliberately rather than by drift.
5
6 ## Direction
7
8 - **Compact, not sparse.** Take Linear's information density and restraint.
9 Avoid GitHub's whitespace-heavy chrome; avoid Sourcehut's bareness. A row of
10 data should be readable at a glance, and a page should fit on one screen when
11 it can.
12 - **Everforest is the theme.** Every colour comes from a custom property on
13 `:root`; `static/everforest.css` supplies six variants over the same layout:
14 dark and light, each in hard, medium, and soft contrast. The picker lives on
15 the Account page, and `static/theme.js` applies the saved variant before the
16 first paint. Its `System` default follows the operating system's dark or
17 light preference and uses the medium contrast; the base sheet's tokens remain
18 the fallback.
19 - **Readability first.** Lexend is the UI face and JetBrains Mono is the code
20 face; both are self-hosted WOFF2 under `static/fonts/` under the SIL Open Font
21 License 1.1, with the system stacks as fallback. The medium (100%) base is
22 15.4px with a 1.55 line height and subpixel-friendly smoothing. Every explicit
23 font size is multiplied by `--font-scale`; the Account page's Text size presets
24 are 90%, 100%, and 110%, stored in this browser and applied before first paint.
25 Medium is the default and matches the former Large size.
26 - **Server rendered.** Go `html/template` produces the page. HTMX is the default
27 dynamic layer, vendored into the binary; JavaScript is required — passkeys,
28 clipboard copy, and rich widgets such as the label/assignee token input all
29 need it. No SPA, no client-side router, no npm build.
30 - **JavaScript required.** There is no no-script fallback: a browser without
31 JavaScript gets a page it cannot operate. Forms post through HTMX, and handlers
32 answer a fragment when `HX-Request: true` and a redirect otherwise.
33 - **Identity later.** Keep the `vole` wordmark as a placeholder and keep colour,
34 type, radius, and spacing in tokens so a real identity is a token swap.
35 - **Single tenant.** No organisation/workspace switcher. The global bar is
36 limited to Home, Activity, search, and the account menu; the repository owns
37 its own horizontal tabs.
38
39 ## Navigation model
40
41 Global bar (slim): `vole` · `Home` · `Activity` · search · account menu. The
42 wordmark links home too, but the text destination keeps the mark from being the
43 only label. The account menu carries Account, Status for instance admins, and
44 Sign out; the current destination is marked. Search is a field on wide screens
45 and takes its own row on narrow ones.
46
47 Repository header: the repository name and its short description sit above the
48 tabs at the home-page header scale, with a lock for a private repository or a
49 globe for a public one. Repository-level actions sit at the right of that name
50 line: Clone always, and Site only when the repository has a deployed site. The
51 tab's own header is a smaller section title because the tabs, not the page, are
52 the navigation. The tabs are:
53
54 `Overview` · `Activity` · `Code` · `Issues` · `Changes` · `Releases` · `Runs` · `Locks` · `Settings`
55
56 No sidebar. A sidebar only pays for its width when there are many persistent
57 destinations; here the repository has several and the global bar is separate,
58 and they fit on one row at desktop widths. On narrow screens the tab row scrolls
59 horizontally rather than collapsing into a menu.
60
61 Every tab opens with the same page header: a title and one-line summary on the
62 left, and that tab's actions (or branch picker) grouped on the right. Filters and
63 list controls stay below the header; the header is the only place a tab's primary
64 action lives.
65
66 The repository tab opens Overview: README in the main column; recent commits, open
67 issues and change requests, recent runs, releases, contributors, and languages in the
68 sidebar. Activity is the chronological feed: issues, change requests, comments, and
69 releases with type filters. Code is an editor-style browser: a persistent tree on the left and the
70 open file in the large right-hand pane; file and folder links swap only that pane
71 through HTMX. Markdown opens as source; a Source/Preview toggle renders it at full
72 pane width. The branch crumb returns to the Code overview rather than opening a
73 second directory listing. Settings appears for the viewer when the repository
74 ACL gives them the admin role.
75
76 A blob is served as escaped lines with their numbers and `id="L<n>"` anchors, and
77 the browser colours it: `static/prism-worker.js` holds Prism and every language it
78 ships, so the server names the language (an extension table, not a detector) and
79 does no lexing. The worker starts only on a page with a blob; a file the table
80 does not claim is served plain.
81
82 ## Surfaces
83
84 Status: `done` exists today · `wip` in progress · `todo` missing.
85
86 ### Global
87
88 - done — repository list (`/`)
89 - done — sign-in, registration, enrollment link
90 - done — account (name, theme, text size, keys, passkeys, sessions)
91 - done — styled 404 page everywhere, including missing detail resources and
92 download paths
93 - done — styled 500 page everywhere; a handler panic before the first byte is
94 recovered into it, and the detail is logged rather than rendered
95 - done — search across visible repositories, by name and description, from the
96 chrome on every page
97 - done — landing page at `/`: instance identity, repository cards with top
98 language, last commit, issue/change-request counts, and latest release, with a short
99 activity rail beside them
100 - done — instance-wide activity feed at `/activity`, merged from every
101 repository the viewer can read, with the repository named on each event, a
102 type filter, and 50-event pages
103 - done — instance admin: repository creation and access rules, accounts,
104 enrollment links, admin grants, keys, and sessions
105 - done — instance-admin status page at `/status` with storage and filesystem
106 usage, LFS/artifact breakdowns, page-view and unique-user counts, runner
107 presence and queue depth, and an effective configuration summary. The page is
108 split into Overview, Storage, Runners, and Configuration tabs; `/healthz` stays
109 the plain-text probe and old `/runners` links redirect to the Runners tab.
110
111 ### Repository
112
113 - done — code browser: persistent file tree, HTMX file/folder swaps, blob with
114 line numbers, raw, markdown source with an explicit full-width preview
115 - done — a README's relative links and images resolve against the document's own
116 directory and open on the code surface, so the same README works from the
117 overview and from the code browser
118 - done — commit history, commit view, parent links, and a diff per changed file
119 in the same file-tree-and-pane layout as a change's Files tab
120 - done — issues list/detail, comments, filters, close/reopen, labels and assignees (create and edit)
121 - done — change-request list/detail with Conversation, Files and Runs tabs; one-level threaded discussion UI, approvals, file tree and diff, inline review comments, close/reopen, and linked issues in an Issues section with a search picker and unlink control
122 - done — releases list/detail, artifacts, archives
123 - done — locks
124 - done — run detail, log, artifacts
125 - done — runs list per repository (`/r/{name}/runs`) with status filters
126 - done — repository activity feed on `/r/{name}/activity`: issues, change requests,
127 comments, releases, type filters, and HTMX fragment updates
128 - done — activity events from metadata-log state changes (closed/reopened),
129 actor filter, and assignee filter
130 - done — repository settings: description, default branch, visibility
131 - done — danger zone: rename and delete with typed confirmation
132 - done — compare view (`/r/{name}/compare/{base}...{head}`), with three-dot
133 merge-base or two-dot endpoint semantics, commit list, and diff
134 - done — file history and blame, with links from the blob toolbar
135 - done — language-aware file icons in the code tree, directory list, and file
136 search results
137 - done — code search within a repository: file names and plain-text contents at
138 a chosen branch, with line anchors
139 - done — release create/edit from the browser: annotated tags with notes;
140 notes editable until files are attached
141 - done — inline review comments render beneath the diff line; the composer opens
142 in place and a posted comment returns to its line note. Conversation comments
143 carry a collapsed excerpt from the blob they anchored to
144
145 ### CI
146
147 - done — runner list, queue, run detail/log/artifacts
148 - done — contributor-branch CI approval on the change request, not the Runs tab or code browser
149 - done — workflow/job list per repository at `/r/{name}/workflows`: triggers,
150 jobs, runner labels, image, steps, outputs, and the latest run for each job
151 - done — cancel a queued run and rerun a finished one as another attempt; a
152 running job is not interrupted
153
154 ## Slice order
155
156 1. ~~**Foundations**~~ — embedded template files, tokenised CSS, slim shell,
157 repository tabs, styled 404 and 500.
158 2. **Code browsing** — done: overview/code split, stats, contributors, runs
159 list, editor-style tree, HTMX navigation, line-numbered blobs, commit/diff,
160 syntax highlighting.
161 3. **Issues and change requests** — done: list/detail layout, composer, filters,
162 close/reopen, discussion, label/assignee triage, writing inline review
163 comments.
164 4. **Activity and settings** — done: repository activity feed with state
165 change requests and actor/assignee filters, repository settings (description, default
166 branch, visibility, danger zone), and the instance-admin access section on the
167 same Settings page.
168 5. **Instance admin** — done: repository create at `/repos/new`, access rules on
169 each repository's Settings tab, rename/delete for repository admins, and
170 `/users` for accounts, enrollment links, admin grants, retirement, keys, and
171 sessions.
172 6. **CI** — done: workflow/job list, cancel a queued run, rerun a finished
173 one as another attempt.
174 7. **Polish** — done: repository search, code search, issue/change-request pagination,
175 empty states, a light token block, skip link, focus-visible outlines, and
176 narrow-screen table handling. Remaining: a deliberate responsive and
177 accessibility audit at 1440/1024/390 px with a real browser.
178
179 ## Dynamic layer
180
181 HTMX 2.0.11 is pinned at `server/web/static/htmx.min.js` (Zero-Clause BSD,
182 license beside it). It is served from the embedded static assets and loaded on
183 every page. Interactions are converted one flow at a time:
184
185 - done — issue close/reopen, labels, assignees, and comments update their
186 section in place. Labels and assignees save on token change; there is no
187 visible save button.
188 - done — change approval updates the live status regions and the page actions,
189 comments update the discussion fragment, and close/reopen are page actions.
190 - done — the change page polls one live endpoint that returns out-of-band
191 updates for the status regions; the target picker, issue search, and
192 discussion are outside that swap.
193 - done — the change Issues section links and unlinks issues in place.
194 - done — Issues and Change requests filters swap their list fragment and push the URL.
195 - done — repository activity feed swaps its event fragment for type filters and
196 pushes the URL.
197 - done — diff lines open an anchored composer in place; posted anchors are
198 stored with the reviewed blob and returned to beneath the line.
199 - done — the Files tab swaps only the selected file's diff pane from its tree,
200 and pushes the file URL.
201 - done — the Runs tab lists the head commit's workflow results and links to each
202 run.
203
204 The token input and passkey ceremonies stay custom JavaScript: HTMX moves HTML
205 from the server, it does not manage client-side chip state or credential APIs.
206
207 ### Dynamic regions
208
209 Every dynamic area is one of two kinds. A **state region** holds a form, input,
210 open popover, or editor and is replaced only by the action that owns it. A
211 **live region** is read-only status or data and may be refreshed in the
212 background. A poll response may contain live regions only; if a widget needs
213 both, its data child is a separate live region and its controls are left alone.
214
215 The change sidebar follows this split. `GET /r/{name}/requests/{id}/live`
216 returns `request-review-status`, `request-changes`, `request-approvals`,
217 `request-checks`, `request-combined`, and `request-merge-note` with
218 `hx-swap-oob="outerHTML"`. The poll element itself uses `hx-swap="none"`, so the
219 target picker, issue search, and comment composers keep their state while the
220 status updates. Write responses use the same out-of-band group when they change
221 more than one live region, or target one state region directly when the write
222 belongs to that widget. The page action group is updated by writes, not by the
223 poll, so a poll never replaces a form. The live handler runs the status loader
224 only; linked issues, numbering, and branch-picker options are loaded by the full
225 sidebar and the writes that own those state regions. Trigger conditions are for
226 visibility and throttling, not for pausing a poll because a widget happens to be
227 open.
228
229 ## Activity feed
230
231 No notification table and no per-account inbox. The feed is derived from Git, the
232 metadata branch, tags, and the run queue. Events carry a type, time, actor,
233 title, link, and (for issue/change-request events) assignees, so the same feed can be
234 filtered by actor and assignee.
235
236 Repository feed is done on `/r/{name}/activity`; Overview stays the repository
237 summary. The instance feed is `/activity`, over visible repositories, with the
238 repository named on each event, a type filter, and 50-event pages. The landing
239 page shows the ten most recent events as a rail beside the repository grid,
240 because a forge's front door should show its repositories first. State changes such as closed and reopened
241 come from the metadata branch log, which carries a canonical
242 `issue: <id> <state>` and `request: <id> <state>` subject. The feed covers
243 issues, change requests, comments, approvals, state changes, and releases. Commits and
244 CI runs are not events: the overview and the runs tab already show them.
245
246 ## Admin scope
247
248 Instance admin covers the operations that used to be CLI-only:
249
250 - accounts: create with an enrollment link, retire, grant/revoke admin;
251 inspect/revoke keys and sessions
252 - repositories: create, rename, delete
253 - per-repository ACL: set roles, default role, approvals, checks, runners
254
255 The Accounts page will not ask an admin to paste an account's SSH key. Account
256 creation uses the existing `identity.CreateWithInvite` path and returns a
257 one-time enrollment link; browser sign-in must be enabled for that link to work.
258
259 Repository admins still own repository settings (description, default branch,
260 visibility, danger zone). Instance admins see an additional access section on the
261 same Settings page: roles, the default role, approvals, checks, and runners.
262 Repository creation stays an instance-admin operation, but it is a standalone
263 page reached from the home page rather than a repository-list admin screen.
264
265 Accounts live at `/users`, reached from an Accounts item in the top navigation
266 that is shown only to instance admins. Every admin write uses the existing stores,
267 CSRF, and the audit log; the web is a second surface over the same operations,
268 not a second implementation.
269
270 ## Where the code lives
271
272 `server/web` is the whole HTML surface. It is one package, split by what a file
273 answers rather than by layer:
274
275 | File | Holds |
276 |---|---|
277 | `web.go` | the `Web` type, `New`, `render`, `open`, the error pages, `htmxRequest` |
278 | `page.go` | `Page`, `pageSection`, `who`, `nav` — the data every page and fragment starts from |
279 | `templatefuncs.go` | the template function map, with a note on escaping (`esc` vs `pathurl`) |
280 | `ui.go` | relative time, avatar initials and tone, breadcrumbs, `safePath`, the `pager` |
281 | `markdown.go` | the goldmark + bluemonday boundary, `#42` / `#<ulid>` cross-references, and resolving a tree document's relative links against its own directory |
282 | `auth.go`, `auth_handlers.go` | passkeys, sessions, CSRF, the middleware, the sign-in pages |
283 | `templates.go`, `icons.go`, `file_icons.go` | embedding, the icon set, language classes |
284 | `templates/` | one file per destination; `chrome.html`, `components.html`, `diff.html` and `role-picker.html` hold what two surfaces share |
285 | `home.go`, `activity.go` | the landing page and the two activity feeds |
286 | `overview.go`, `codebrowser.go`, `code_tree.go`, `code_search.go`, `commit.go`, `compare.go`, `diff.go`, `file_history.go`, `highlight.go` | one code-side read surface each |
287 | `issues.go`, `requests.go`, `request_view.go` | the issue pages, the change-request list and new form, the change conversation page |
288 | `request_files.go` | a change's Files tab and its inline review comments |
289 | `releases.go`, `runs.go`, `workflows.go`, `locks.go` | release, run, workflow and lock pages |
290 | `comments.go` | the comment view, the thread builder, the action menus, the snippet |
291 | `comment_writes.go` | add, edit and delete a comment, for both surfaces |
292 | `tokens.go`, `rolepicker.go` | the label/assignee token input and the instance/repository role control |
293 | `write_context.go`, `capabilities.go`, `audit.go` | the write gate, the ACL rules a page and a handler share, the audit log |
294 | `writes.go`, `release_actions.go`, `run_actions.go`, `ci.go`, `account.go` | the browser's writes, by what they change |
295 | `settings.go`, `admin_access.go` | repository settings, and the instance-admin access rules on the same page |
296 | `admin.go`, `admin_repos.go`, `admin_users.go`, `status.go`, `runners.go` | the instance-admin gate, accounts, repository creation, status, runners |
297
298 Read handler files answer a page or an HTMX fragment; `*_actions` and `writes.go`
299 answer a form POST and redirect. A handler resolves the repository through
300 `w.open`, which is also what makes a private repository indistinguishable from a
301 missing one, and builds its data with `w.page` so the chrome always agrees with
302 the ACL.
303
304 Every write starts at `beginWrite`, which establishes the session, the body bound,
305 the CSRF token, the repository and its rules, and parses the form in that order —
306 `beginPost` and `beginAdminPost` are the account-scoped and instance-admin
307 equivalents. The handler then asks `capabilities.go` for the one rule it needs
308 (`canDiscuss`, `canOpenChangeRequest`, `canTriage`, `canApprove`, `canMaintain`,
309 `canModerateComment`), which is the same function the page used to decide whether
310 to draw the control. A rejected write answers through `w.refuse`, a 403 that never
311 re-renders; a write that fails for another reason answers through `w.fail`, which
312 re-renders the section with the message for HTMX and returns the message as text
313 otherwise.
314
315 A comment is the same object on an issue and on a change request:
316 `metadata.Comment` is one type for both, so `comments.go` has one view, one thread
317 builder and one set of action rules, and `comment_writes.go` has one
318 implementation each of add, edit and delete. A `commentTopic` names what the two
319 surfaces do differently — which writer runs, which section re-renders, where a
320 plain form post lands — and a change request adds the one thing an issue cannot
321 have: a comment anchored to a line of the diff, with a snippet of the lines it was
322 written about.
323
324 Templates live under `server/web/templates/`, one file per destination, with a
325 page's fragments in the page's own file so a fragment and the element it swaps
326 are read together. Four files hold what more than one surface uses: `chrome.html`
327 (shell, global bar, repository header, error page), `components.html` (token
328 input, commit list, file tree, pager), `diff.html`, and `role-picker.html`. All
329 templates share one namespace, so a fragment name is prefixed with its surface
330 (`issue_labels`, `code_main`, `review_main`, `commit_main`); a fragment both
331 surfaces render keeps the shared name (`comment_thread`, `comment_card`).
332
333 The file tree is one widget for all three browsers. The code browser, a change's
334 Files tab and a commit page pass the same tree nodes through `treeContext` or
335 `commitTreeContext`, and `treeView.Href` decides whether a row opens a blob at a
336 ref, the file's diff in a change, or the file's diff in a commit. A commit is the
337 same shape as the Files tab because it answers the same question one file at a
338 time: the tree is built from `git show --name-status`, a single row's patch is
339 read on demand, and only the pane is swapped when a row is opened. The pager is
340 likewise one control for issues, change requests, and the instance feed: the
341 handler supplies the URLs, the label, and (for HTMX) the fragment to swap.
342
343 Static assets are served from `/static/` and embedded in the binary at build
344 time; nothing is loaded from a CDN and no template inlines CSS or JS. The passkey
345 script is an ordinary asset (`static/auth.js`) and runs under the same CSP as the
346 rest: `script-src 'self'`.
347
348 One asset is fetched on demand. Landing a change request redirects to the request
349 page with `?merged=1`, and `static/app.js` answers that parameter by loading
350 `static/confetti.browser.js` (canvas-confetti 1.9.3, ISC, license at
351 `server/web/static/canvas-confetti.LICENSE`) and firing a one-second version of
352 the library's "fireworks" volley from the left and right edges of the viewport.
353 Nothing bursts from the centre, so the celebration frames the page it lands on
354 instead of covering it. The parameter is
355 read once and removed from the address with `replaceState`, so a reload, a bookmark
356 or a shared link is an ordinary visit; the library honours
357 `prefers-reduced-motion` through `disableForReducedMotion`. Every other page pays
358 nothing for it, which is why the 25 KB bundle is acceptable where a
359 loaded-always library would not be.
360
361 The burst always comes from an instance created with `useWorker: false`. The
362 library's default export opts into a Web Worker built from a `blob:` URL, which
363 `default-src 'self'` blocks — and it transfers its canvas to that worker before
364 drawing, so the canvas appears and stays empty with no error. Drawing on the main
365 thread avoids the CSP question entirely instead of widening the policy with
366 `worker-src blob:` for a celebration.
367
368 The route table is `server/routes.go`, one function per surface (assets, global,
369 auth, admin, repository, git). Routes are grouped by prefix rather than by
370 registration order because `ServeMux` ranks by pattern specificity, and the
371 catch-all 404 is registered last only so a reader finds it there.
372
373 ## Icons
374
375 Icons are Lucide 0.468.0 SVGs (ISC, license at `server/web/static/lucide.LICENSE`),
376 stored as path data in `server/web/icons.go` and rendered inline with
377 `{{icon "name"}}`. No icon font, no CDN, no network request; they inherit
378 `currentColor` and size with the surrounding text. A test checks that every icon
379 named in a template exists.
380
381 The header emblem is `static/vole-head.png` used as a CSS mask and tinted with the
382 theme's ink, so it follows the selected Everforest variant. The favicon is a
383 separate transparent mark: `favicon.svg` and the light/dark PNG fallbacks switch
384 between black and white from `prefers-color-scheme` (the browser window's mode),
385 not from the in-app theme choice. That keeps the tab icon legible against the
386 browser's own tab colour.
387
388 ## Fonts
389
390 Lexend (UI) and JetBrains Mono (code) are self-hosted variable WOFF2 subsets under
391 `server/web/static/fonts/`, split into Latin and Latin-ext. Their OFL 1.1 license
392 texts sit beside them, and the system stacks in `--font`/`--mono` cover scripts
393 outside those ranges and the pre-load state. No CDN and no runtime font request
394 outside `/static/`.
395
396 ## Rules
397
398 - Every state-changing form carries the CSRF hidden input.
399 - A write handler opens with `beginWrite` (or `beginPost`/`beginAdminPost`); it
400 never reads the session, the CSRF token, the repo ACL, or the form by hand.
401 - A control and the handler that accepts it ask the same function in
402 `capabilities.go`; neither restates the rule.
403 - A private repository answers 404, never 403.
404 - A rejected write answers through `refuse` (403) or `fail` (the section with the
405 message for HTMX, plain text otherwise). A failed write is not a place for the
406 styled error page: that page is for a 404 or a 500, where there is no form to
407 return to.
408 - No inline CSS or JS in templates; assets live in `server/web/static/`.
409 - One file per destination under `templates/`, with the page's fragments beside
410 it; anything two surfaces share goes in `chrome.html`, `components.html`,
411 `diff.html` or its own component file.
412 - Stylesheet sections are listed in the table of contents at the top of
413 `vole.css`; add a line there when a section is added or moved. The base sheet
414 stays the authority; every Everforest rule in `everforest.css` must stay
415 scoped to `[data-theme^="everforest-"]`.
416 - Tables are for tabular data; navigation and page chrome are not tables.
417 - Test the page at 1440, 1024, and 390 px wide before calling it done.