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.
Reorganise the web package around one implementation per rule
c717d4eb92b4257b05fa065918b0071f6302f53e · Keenan Nemetz · 13d ago · parent 4506a4d3
Reorganise the web package around one implementation per rule
server/web had grown by feature, so several rules and widgets existed twice: the
comment stack for issues and change requests, the pager, the file tree, the write
preamble, and the ACL predicates. Each pair agreed today and would drift
tomorrow. No route, stored format, or permission rule changes; the few behaviour
differences found along the way are listed at the end.
Files now say what they answer:
web.go the Web type, render, open, the error pages
page.go Page, pageSection, who, nav
templatefuncs.go the template function map, with the escaping rules noted
ui.go relative time, avatars, breadcrumbs, the pager
markdown.go the goldmark and bluemonday boundary
codebrowser.go the code browser, out of web.go
tokens.go the label/assignee token input
rolepicker.go the role control, out of admin.go
comments.go the comment view, thread builder, action menus, snippet
comment_writes.go add, edit and delete a comment, for both surfaces
write_context.go beginWrite/beginPost/beginAdminPost, fail, refuse
capabilities.go the ACL rules a page and a handler share
audit.go the audit log
request_view.go the change conversation page, out of requests.go
admin_access.go the instance-admin access rules, out of settings.go
A comment is one thing, so it has one view and one thread builder: metadata.Comment
is the same type on both surfaces and only a change request can anchor one to a
diff line. Adding, editing and deleting are one implementation each, with a
commentTopic naming what differs per surface. The pager is one control for issues,
changes and the instance feed; the file tree is one widget for the code browser
and a change's Files tab.
Every write starts at beginWrite (session, body bound, CSRF, repository, rules,
form) and asks capabilities.go for the one rule it needs, so the control a page
draws and the check a handler makes cannot disagree. A rejected write answers
through refuse; a write that fails for another reason answers through fail, which
re-renders the section for HTMX and returns text otherwise.
Templates follow the same split: chrome.html, components.html, diff.html and
role-picker.html hold what more than one surface renders, and each page owns its
fragments. The route table moved to routes.go, one function per surface.
Behaviour changes found while unifying:
- A reply cycle (two comments answering each other, possible through a pushed
metadata branch) used to drop both comments from the page; anything the walk
does not reach is now emitted as a root.
- A rejected anchored comment keeps its "Commenting on path:line" note and
hidden anchor fields, so the reviewer does not have to pick the line again.
- A repository whose access rules cannot be read answers 500 rather than 403;
the account was being blamed for a broken instance.
- Pager wording is now "Showing a-b of n / page x of y" with Previous/Next
everywhere, and the instance feed's page links keep their kind filter.
- The activity feed, issue list and change list share one page control.
Docs: docs/ui-plan.md gains a "Where the code lives" map, the write gate, the
shared comment model, the route table, and rules for rejected writes, shared
template fragments, and the stylesheet's section order. The passkey script is now
an ordinary asset (static/auth.js) under the same CSP as the rest.
server/web/static/vole.css +73−17
| @@ -1,5 +1,41 @@ | |||
| 1 | 1 | /* Vole's web surface. One stylesheet, token-driven: a light theme is another | |
| 2 | block of variables, and a real identity is a change to the top block only. */ | ||
| 2 | block of variables, and a real identity is a change to the top block only. | ||
| 3 | |||
| 4 | Sections, in file order. Rules cascade in this order too, so a later section | ||
| 5 | can override an earlier one and a section cannot be moved without checking | ||
| 6 | what it was quietly overriding: | ||
| 7 | |||
| 8 | tokens custom properties, dark and light (:root) | ||
| 9 | chrome document shell, global bar, account menu | ||
| 10 | repository header name, visibility, tab row | ||
| 11 | panels and tables the bordered surfaces lists and pages sit on | ||
| 12 | controls buttons, inputs, selects, form layout | ||
| 13 | labels, flashes, empty pages | ||
| 14 | auth sign-in, registration, invite, account | ||
| 15 | repository home clone button, readme, the home row layout | ||
| 16 | diffs and markdown code/diff tables, hunks, comment cells, .md | ||
| 17 | shared responsive rules the few rules that apply to every surface | ||
| 18 | repository overview summary page: main column and sidebar | ||
| 19 | code tab bar and crumbs the bar above the open file | ||
| 20 | code browser and file tree the persistent tree beside the pane | ||
| 21 | syntax highlighting chroma classes | ||
| 22 | repository overview sidebar compact commit and run rows | ||
| 23 | issues and changes list pages, thread, forms, metadata sidebar | ||
| 24 | icons inline SVG sizing | ||
| 25 | activity feed event rows for one repository and the instance | ||
| 26 | accessibility skip link, focus rings, reduced motion | ||
| 27 | light theme the second token block | ||
| 28 | pager previous/next between list pages | ||
| 29 | code search per-file result blocks | ||
| 30 | blame per-line commit column | ||
| 31 | landing page hero, repository grid, feed rail | ||
| 32 | status page instance storage, runners, configuration | ||
| 33 | language colours file-icon and language-dot palette | ||
| 34 | |||
| 35 | Note the two pairs that are easy to confuse: "repository home" is the clone | ||
| 36 | button and readme on /r/<name>, while "landing page" is the instance home at | ||
| 37 | /; "repository overview" is the summary page, while "repository overview | ||
| 38 | sidebar" is the small commit and run rows it uses. */ | ||
| 3 | 39 | ||
| 4 | 40 | :root { | |
| 5 | 41 | color-scheme: dark; | |
| @@ -487,7 +523,7 @@ a.label:hover { color: var(--accent); border-color: var(--accent); text-decorati | |||
| 487 | 523 | .auth { max-width: 34rem; margin-top: 8vh; } | |
| 488 | 524 | .auth h1 { margin-bottom: 6px; } | |
| 489 | 525 | ||
| 490 | /* ---------- repo home ---------- */ | ||
| 526 | /* ---------- repository home ---------- */ | ||
| 491 | 527 | ||
| 492 | 528 | details.clone { position: relative; } | |
| 493 | 529 | details.clone > summary { | |
| @@ -526,7 +562,7 @@ details.clone[open] > summary { border-color: var(--accent); } | |||
| 526 | 562 | ||
| 527 | 563 | .readme { margin: 16px 0; } | |
| 528 | 564 | ||
| 529 | /* ---------- code, diffs, markdown ---------- */ | ||
| 565 | /* ---------- diffs and markdown ---------- */ | ||
| 530 | 566 | ||
| 531 | 567 | code, pre, kbd { | |
| 532 | 568 | font-family: var(--mono); | |
| @@ -702,7 +738,7 @@ pre code { background: none; border: 0; padding: 0; font-size: inherit; } | |||
| 702 | 738 | .inline-comment-form .form-actions { margin-top: 0; } | |
| 703 | 739 | .diff .binary { padding: 10px 12px; color: var(--fg-muted); font-size: 12px; margin: 0; } | |
| 704 | 740 | ||
| 705 | /* ---------- responsive ---------- */ | ||
| 741 | /* ---------- shared responsive rules ---------- */ | ||
| 706 | 742 | ||
| 707 | 743 | @media (max-width: 720px) { | |
| 708 | 744 | h1 { font-size: 16px; } | |
| @@ -711,7 +747,7 @@ pre code { background: none; border: 0; padding: 0; font-size: inherit; } | |||
| 711 | 747 | .nowrap { white-space: normal; } | |
| 712 | 748 | } | |
| 713 | 749 | ||
| 714 | /* ---------- overview ---------- */ | ||
| 750 | /* ---------- repository overview ---------- */ | ||
| 715 | 751 | ||
| 716 | 752 | .overview { | |
| 717 | 753 | display: grid; | |
| @@ -862,7 +898,7 @@ td.fit { width: 1%; white-space: nowrap; } | |||
| 862 | 898 | .overview-side section:first-child { padding-top: 12px; } | |
| 863 | 899 | } | |
| 864 | 900 | ||
| 865 | /* ---------- code browsing ---------- */ | ||
| 901 | /* ---------- code tab bar and crumbs ---------- */ | ||
| 866 | 902 | ||
| 867 | 903 | /* The tab bar and the tree header share a height so their content starts on the | |
| 868 | 904 | same line. The bar is the file's identity, not a separate title block. */ | |
| @@ -918,7 +954,7 @@ td.fit { width: 1%; white-space: nowrap; } | |||
| 918 | 954 | border-top-right-radius: 0; | |
| 919 | 955 | } | |
| 920 | 956 | ||
| 921 | /* ---------- code browser ---------- */ | ||
| 957 | /* ---------- code browser and file tree ---------- */ | ||
| 922 | 958 | ||
| 923 | 959 | .code-browser { | |
| 924 | 960 | display: grid; | |
| @@ -1145,7 +1181,9 @@ details[open] > summary .tree-chevron { transform: rotate(90deg); } | |||
| 1145 | 1181 | .chroma .gi { color: var(--good); } | |
| 1146 | 1182 | .chroma .err { color: var(--bad); text-decoration: underline; } | |
| 1147 | 1183 | ||
| 1148 | /* Compact activity rows in the overview sidebar. */ | ||
| 1184 | /* ---------- repository overview sidebar ---------- */ | ||
| 1185 | |||
| 1186 | /* Compact commit and run rows beside the overview. */ | ||
| 1149 | 1187 | ||
| 1150 | 1188 | .side-commit, | |
| 1151 | 1189 | .side-run { | |
| @@ -1751,8 +1789,10 @@ button.link, .link { min-height: 0; } | |||
| 1751 | 1789 | } | |
| 1752 | 1790 | .label-option:hover, .label-option:focus { background: var(--bg-hover); filter: none; } | |
| 1753 | 1791 | ||
| 1754 | /* Icons are inline SVG from Lucide. They inherit currentColor and size with | ||
| 1755 | the text around them. */ | ||
| 1792 | /* ---------- icons ---------- */ | ||
| 1793 | |||
| 1794 | /* Inline SVG from Lucide. Icons inherit currentColor and size with the text | ||
| 1795 | around them. */ | ||
| 1756 | 1796 | .icon { width: 14px; height: 14px; flex: 0 0 auto; } | |
| 1757 | 1797 | .tabs a { display: inline-flex; align-items: center; gap: 5px; } | |
| 1758 | 1798 | h2 .icon { width: 13px; height: 13px; margin-right: 4px; vertical-align: -2px; color: var(--fg-faint); } | |
| @@ -1791,7 +1831,7 @@ form.htmx-request .label-tokens { opacity: 0.6; } | |||
| 1791 | 1831 | .anchor-note { margin: 0 0 8px; font-size: 12px; } | |
| 1792 | 1832 | @media (max-width: 720px) { .diff td.comment-cell a { opacity: 0.8; } } | |
| 1793 | 1833 | ||
| 1794 | /* Repository activity feed. */ | ||
| 1834 | /* ---------- activity feed ---------- */ | ||
| 1795 | 1835 | .activity-list { | |
| 1796 | 1836 | border: 1px solid var(--border); | |
| 1797 | 1837 | border-radius: var(--radius-lg); | |
| @@ -1839,6 +1879,8 @@ form.htmx-request .label-tokens { opacity: 0.6; } | |||
| 1839 | 1879 | .state.active { color: var(--good); } | |
| 1840 | 1880 | .state.retired { color: var(--fg-faint); } | |
| 1841 | 1881 | ||
| 1882 | /* ---------- accessibility ---------- */ | ||
| 1883 | |||
| 1842 | 1884 | /* Keyboard users get out of the chrome before the tabs. */ | |
| 1843 | 1885 | .skip-link { | |
| 1844 | 1886 | position: absolute; | |
| @@ -1870,6 +1912,8 @@ textarea:focus-visible { | |||
| 1870 | 1912 | .panel > table { min-width: 34rem; } | |
| 1871 | 1913 | } | |
| 1872 | 1914 | ||
| 1915 | /* ---------- light theme ---------- */ | ||
| 1916 | |||
| 1873 | 1917 | /* A light theme is the same layout with a second set of tokens. It follows the | |
| 1874 | 1918 | operating system until a real theme switch exists. */ | |
| 1875 | 1919 | @media (prefers-color-scheme: light) { | |
| @@ -1896,10 +1940,14 @@ textarea:focus-visible { | |||
| 1896 | 1940 | --warn-soft: rgba(148, 98, 0, 0.12); | |
| 1897 | 1941 | } | |
| 1898 | 1942 | } | |
| 1943 | /* ---------- pager ---------- */ | ||
| 1944 | |||
| 1899 | 1945 | .pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 12px 0; } | |
| 1900 | 1946 | .pager .button { font-size: 12px; } | |
| 1901 | 1947 | ||
| 1902 | /* Code search results: one block per file, line numbers linked to blob anchors. */ | ||
| 1948 | /* ---------- code search ---------- */ | ||
| 1949 | |||
| 1950 | /* One block per file, with line numbers linked to blob anchors. */ | ||
| 1903 | 1951 | .search-code input[type="search"] { width: 10rem; } | |
| 1904 | 1952 | .search-group { margin: 14px 0; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--bg-panel); overflow: hidden; } | |
| 1905 | 1953 | .search-group h3 { margin: 0; padding: 7px 10px; background: var(--bg-raised); border-bottom: 1px solid var(--border); font-size: 12px; } | |
| @@ -1911,7 +1959,9 @@ textarea:focus-visible { | |||
| 1911 | 1959 | @media (max-width: 720px) { .search-code { display: none; } } | |
| 1912 | 1960 | .code-main tr:target { background: var(--accent-soft); } | |
| 1913 | 1961 | ||
| 1914 | /* Blame: a commit column spans the consecutive lines it last touched. */ | ||
| 1962 | /* ---------- blame ---------- */ | ||
| 1963 | |||
| 1964 | /* A commit column spans the consecutive lines it last touched. */ | ||
| 1915 | 1965 | .blame-panel { overflow-x: auto; } | |
| 1916 | 1966 | .blame { min-width: 40rem; } | |
| 1917 | 1967 | .blame td.blame-meta { | |
| @@ -1926,9 +1976,11 @@ textarea:focus-visible { | |||
| 1926 | 1976 | .blame td.ln { width: 6ch; text-align: right; color: var(--fg-faint); font-family: var(--mono); font-size: 11px; } | |
| 1927 | 1977 | .blame td.code { font-family: var(--mono); font-size: 12px; white-space: pre; } | |
| 1928 | 1978 | ||
| 1929 | /* Landing page: identity, a repository grid, and the merged feed as a rail. The | ||
| 1930 | feed has a full page at /activity; here it is a sign the forge is alive, not | ||
| 1931 | the subject of the page. */ | ||
| 1979 | /* ---------- landing page ---------- */ | ||
| 1980 | |||
| 1981 | /* Identity, a repository grid, and the merged feed as a rail. The feed has a | ||
| 1982 | full page at /activity; here it is a sign the forge is alive, not the subject | ||
| 1983 | of the page. */ | ||
| 1932 | 1984 | .hero { | |
| 1933 | 1985 | display: flex; | |
| 1934 | 1986 | align-items: flex-end; | |
| @@ -2057,7 +2109,9 @@ textarea:focus-visible { | |||
| 2057 | 2109 | .repo-grid { grid-template-columns: 1fr; } | |
| 2058 | 2110 | } | |
| 2059 | 2111 | ||
| 2060 | /* Status page: no rule under the header itself; the tab row keeps its rule. */ | ||
| 2112 | /* ---------- status page ---------- */ | ||
| 2113 | |||
| 2114 | /* No rule under the header itself; the tab row keeps its rule. */ | ||
| 2061 | 2115 | .status-head { border-bottom: 0; margin-bottom: 8px; } | |
| 2062 | 2116 | .status-tabs { margin-bottom: 14px; border-bottom: 1px solid var(--border); } | |
| 2063 | 2117 | .status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 10px; margin: 14px 0; } | |
| @@ -2087,6 +2141,8 @@ textarea:focus-visible { | |||
| 2087 | 2141 | } | |
| 2088 | 2142 | .config-group:hover td { background: var(--bg-raised); } | |
| 2089 | 2143 | ||
| 2144 | /* ---------- language colours ---------- */ | ||
| 2145 | |||
| 2090 | 2146 | /* Language hints for file rows. Active tree rows still win through specificity. */ | |
| 2091 | 2147 | .tree-icon.lang-go, .dirlist a.file.lang-go .icon { color: #00add8; } | |
| 2092 | 2148 | .tree-icon.lang-rust, .dirlist a.file.lang-rust .icon { color: #dea584; } | |