A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit ba046389a8

ba046389a8614e133b3fdd33d25fa3055bca7e7d

parent: 7b5be134cc

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-25T23:19:59Z

Carbon r2: persistent rail, scoped shell, readable code blocks

The rail becomes the navigation surface, Carbon UI-shell style: global
links at the top, the repo's grouped sections below them when there is
one, and the signed-in user pinned to a footer the scroll never hides.
The top bar keeps only the wordmark and an avatar square. Templates
read repo context through nil-safe field lookups, so pages without it
render the global rail unchanged.

The shell background is scoped to the page header, so cards with their
own <header> (releases) stay on their tile.

Code blocks separate from their surface again: --line borders instead
of hairlines, and a dark ground darker than the tiles it sits in.
Ref #35
internal/web/static/style.css +140 −40
@@ -95,7 +95,7 @@
9595 --bad: #fa4d56;
9696 --done: #a56eff;
9797 --neutral: #a8a8a8;
98 --code-bg: #1e1e1e;
98 --code-bg: #131313;
9999 --shell-line: #393939;
100100 }
101101 }
@@ -133,11 +133,15 @@ code, pre, .code, td.mode, td.size {
133133 margin: 0 auto;
134134 padding: 0 var(--sp-5);
135135 }
136/* the shell: an ink bar in both schemes, Carbon UI-shell style */
137header {
136/* the shell: an ink bar in both schemes, Carbon UI-shell style. Scoped
137 to the page header so cards with their own <header> stay untouched. */
138body > header {
138139 background: var(--shell-bg);
139140 border-bottom: 1px solid var(--shell-line);
140141 padding: 0;
142 position: sticky;
143 top: 0;
144 z-index: 30;
141145 }
142146 a.site {
143147 font-weight: 600;
@@ -146,6 +150,7 @@ a.site {
146150 display: inline-flex;
147151 align-items: center;
148152 gap: var(--sp-2);
153 padding: 0 var(--sp-4);
149154 }
150155 a.site:hover { text-decoration: none; color: #ffffff; }
151156 a.site svg.mark { display: block; }
@@ -155,45 +160,134 @@ nav.topnav {
155160 display: flex;
156161 align-items: center;
157162 gap: 0;
158 min-height: 3rem;
163 height: 3rem;
159164 }
160165 nav.topnav .spacer { flex: 1; }
161nav.topnav > a:not(.site) {
162 color: var(--shell-muted);
166/* the avatar square is the only nav item in the shell; everything else
167 lives in the rail */
168a.avatar {
169 width: 2rem;
170 height: 2rem;
171 margin-right: var(--sp-3);
172 display: inline-flex;
173 align-items: center;
174 justify-content: center;
175 background: var(--accent);
176 color: #ffffff;
163177 font-size: var(--fs-2);
164 padding: 0.85rem var(--sp-3);
165 line-height: 1;
178 font-weight: 600;
179 flex: none;
180}
181a.avatar:hover { text-decoration: none; filter: brightness(1.12); }
182a.avatar.guest { background: #393939; color: #f4f4f4; }
183
184/* the rail: persistent navigation, global links first, repo sections
185 below them, the signed-in user pinned to the bottom */
186.shell { display: flex; flex: 1; align-items: stretch; }
187.siderail {
188 flex: none;
189 width: 13.5rem;
190 background: var(--surface);
191 border-right: 1px solid var(--line);
192 position: sticky;
193 top: 3rem;
194 height: calc(100vh - 3rem);
195 display: flex;
196 flex-direction: column;
197}
198.railmain { flex: 1; overflow-y: auto; padding: var(--sp-3) 0; }
199.railfoot {
200 flex: none;
201 border-top: 1px solid var(--line);
202 padding: var(--sp-2) 0;
203 background: var(--surface);
204}
205.siderail a, .siderail button.linklike {
206 display: flex;
207 align-items: center;
208 gap: var(--sp-2);
209 width: 100%;
210 text-align: left;
211 color: var(--fg);
212 font-size: var(--fs-2);
213 padding: 0.4rem var(--sp-4);
214 border-left: 3px solid transparent;
215 line-height: 1.35;
216}
217.siderail a:hover, .siderail button.linklike:hover {
218 background: var(--hover);
219 text-decoration: none;
220 color: var(--fg);
221}
222.siderail a.active {
223 border-left-color: var(--accent);
224 background: var(--hover);
225 font-weight: 600;
226}
227.siderail .glabel {
228 display: block;
229 font-size: var(--fs-0);
230 color: var(--muted);
231 letter-spacing: 0.04em;
232 padding: 0 var(--sp-4);
233 margin: var(--sp-4) 0 var(--sp-1);
166234 }
167nav.topnav > a:not(.site):hover { color: #ffffff; background: #353535; text-decoration: none; }
168nav.topnav a.navuser {
235.siderail .glabel:not(.sub) {
236 color: var(--fg);
169237 font-weight: 600;
238 border-top: 1px solid var(--line);
239 padding-top: var(--sp-3);
240 overflow-wrap: anywhere;
241}
242.siderail .glabel.sub { margin-top: var(--sp-2); }
243.siderail .railuser { font-weight: 600; }
244.siderail .avatar.sm {
245 width: 1.25rem;
246 height: 1.25rem;
247 margin: 0;
248 font-size: var(--fs-0);
249 background: var(--accent);
170250 color: #ffffff;
171 box-shadow: inset 0 -3px 0 #0f62fe;
172251 }
173nav.topnav button.linklike { color: var(--shell-muted); padding: 0.85rem var(--sp-3); }
174nav.topnav button.linklike:hover { color: #ffffff; background: #353535; text-decoration: none; }
175252 main.container {
176253 width: 100%;
177 padding-top: var(--sp-3);
254 min-width: 0;
255 flex: 1;
256 margin: 0;
257 max-width: 66rem;
258 padding-top: var(--sp-4);
178259 padding-bottom: var(--sp-6);
179260 }
180/* repo pages carry a left rail: the repohead spans the top, the sidenav
181 takes column one, everything after flows in column two. Browsers
182 without :has() fall back to single-column flow, which still works. */
183main.container:has(> nav.tabs) {
184 display: grid;
185 grid-template-columns: 11.5rem minmax(0, 1fr);
186 column-gap: var(--sp-6);
187 max-width: 80rem;
188}
189main.container:has(> nav.tabs) > * { grid-column: 2; }
190main.container:has(> nav.tabs) > .repohead { grid-column: 1 / -1; }
191main.container:has(> nav.tabs) > nav.tabs {
192 grid-column: 1;
193 grid-row: 2 / span 99;
194 align-self: start;
195 position: sticky;
196 top: var(--sp-4);
261@media (max-width: 52rem) {
262 /* the rail folds into a wrapping strip above the content */
263 .shell { display: block; }
264 .siderail {
265 width: auto;
266 height: auto;
267 position: static;
268 flex-direction: row;
269 flex-wrap: wrap;
270 align-items: center;
271 border-right: none;
272 border-bottom: 1px solid var(--line);
273 }
274 .railmain, .railfoot {
275 display: flex;
276 flex-wrap: wrap;
277 align-items: center;
278 overflow: visible;
279 padding: var(--sp-1) var(--sp-2);
280 border-top: none;
281 }
282 .railmain { flex: 1 1 auto; }
283 .siderail a, .siderail button.linklike {
284 width: auto;
285 border-left: none;
286 border-bottom: 3px solid transparent;
287 padding: var(--sp-1) var(--sp-2);
288 }
289 .siderail a.active { border-left: none; border-bottom-color: var(--accent); background: none; }
290 .siderail .glabel { display: none; }
197291 }
198292
199293 /* tables */
@@ -261,8 +355,8 @@ nav.tabs a.active {
261355
262356 .clone { color: var(--muted); font-size: var(--fs-2); }
263357 .clone code {
264 background: var(--surface);
265 border: 1px solid var(--faint);
358 background: var(--code-bg);
359 border: 1px solid var(--line);
266360 padding: 0.15rem 0.5rem;
267361 border-radius: var(--r-sm);
268362 }
@@ -507,7 +601,7 @@ a.memberchip .role { color: var(--muted); }
507601 .landing .lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.7; margin: 0 0 var(--sp-4); }
508602 pre.quickstart {
509603 background: var(--code-bg);
510 border: 1px solid var(--faint);
604 border: 1px solid var(--line);
511605 border-radius: var(--r-md);
512606 padding: var(--sp-3) var(--sp-4);
513607 overflow-x: auto;
@@ -540,7 +634,7 @@ button.linklike:hover { text-decoration: underline; filter: none; }
540634 }
541635 /* Highlighting is class-based with a palette per scheme, so code blocks
542636 follow the site's own colors. */
543.code { background: var(--code-bg); color: var(--fg); padding: var(--sp-3) var(--sp-4); }
637.code { background: var(--code-bg); color: var(--fg); padding: var(--sp-3) var(--sp-4); border-color: var(--line); }
544638 .code pre { margin: 0; background: transparent !important; }
545639 @media (prefers-color-scheme: dark) {
546640 .code { border-color: var(--line); }
@@ -550,13 +644,18 @@ button.linklike:hover { text-decoration: underline; filter: none; }
550644 .rendered { max-width: 100%; overflow-wrap: anywhere; }
551645 .rendered pre {
552646 background: var(--code-bg);
553 border: 1px solid var(--faint);
647 border: 1px solid var(--line);
554648 padding: var(--sp-3) var(--sp-4);
555649 border-radius: var(--r-md);
556650 overflow-x: auto;
557651 }
558.rendered code { background: var(--code-bg); padding: 0.1rem 0.35rem; border-radius: var(--r-sm); }
559.rendered pre code { background: transparent; padding: 0; }
652.rendered code {
653 background: var(--code-bg);
654 border: 1px solid var(--line);
655 padding: 0.05rem 0.3rem;
656 border-radius: var(--r-sm);
657}
658.rendered pre code { background: transparent; border: none; padding: 0; }
560659 .rendered blockquote {
561660 border-left: 3px solid var(--line);
562661 margin-left: 0;
@@ -589,7 +688,7 @@ button.linklike:hover { text-decoration: underline; filter: none; }
589688
590689 pre.message {
591690 background: var(--code-bg);
592 border: 1px solid var(--faint);
691 border: 1px solid var(--line);
593692 padding: var(--sp-3) var(--sp-4);
594693 border-radius: var(--r-md);
595694 white-space: pre-wrap;
@@ -614,7 +713,7 @@ details.difffold summary .add { color: var(--ok); }
614713 details.difffold summary .del { color: var(--bad); }
615714 pre.diff {
616715 background: var(--code-bg);
617 border: 1px solid var(--faint);
716 border: 1px solid var(--line);
618717 padding: var(--sp-3) var(--sp-4);
619718 border-radius: var(--r-md);
620719 overflow-x: auto;
@@ -721,6 +820,7 @@ article.release {
721820 padding: var(--sp-3) var(--sp-4);
722821 margin: var(--sp-4) 0;
723822 }
823.releasehead { background: none; border: 0; padding: 0; }
724824 .releasehead h3 { margin: 0 0 var(--sp-1); }
725825 .releasehead .meta { margin: 0 0 var(--sp-2); }
726826 table.assets { margin-top: var(--sp-2); }
internal/web/templates/layout.html +33 −21
@@ -9,18 +9,46 @@
99 </head>
1010 <body>
1111 <header>
12 <nav class="container topnav">
12 <nav class="topnav">
1313 <a class="site" href="/"><svg class="mark" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"><rect x="1" y="1" width="22" height="22" rx="6" fill="var(--accent)"/><path d="M5 9.5c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="var(--accent-fg)" stroke-width="2.2" fill="none" stroke-linecap="round"/><path d="M5 15c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="var(--accent-fg)" stroke-width="2.2" fill="none" stroke-linecap="round"/></svg>{{.Site}}</a>
1414 <span class="spacer"></span>
15 <a href="/explore">explore</a>
16 {{if .Viewer}}<a href="/new">new repository</a>
17 <a class="navuser" href="/{{.Viewer}}">{{.Viewer}}</a>
18 <form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form>{{end}}
15 {{if .Viewer}}<a class="avatar" href="/{{.Viewer}}" title="{{.Viewer}}" aria-label="{{.Viewer}}">{{initial .Viewer}}</a>
16 {{else}}<a class="avatar guest" href="/login" title="sign in" aria-label="sign in"></a>{{end}}
1917 </nav>
2018 </header>
19<div class="shell">
20<nav class="siderail" aria-label="site">
21 <div class="railmain">
22 <a {{if eq (str . "Tab") "explore"}}class="active" {{end}}href="/explore">explore</a>
23 {{if .Viewer}}<a href="/new">new repository</a>{{end}}
24 {{with field . "Repo"}}
25 <span class="glabel">{{.OwnerName}}/{{.Name}}</span>
26 <span class="glabel sub">code</span>
27 <a {{if eq (str $ "Tab") "files"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}">files</a>
28 <a {{if eq (str $ "Tab") "log"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/log">log</a>
29 <a {{if eq (str $ "Tab") "refs"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/refs">refs</a>
30 <span class="glabel sub">work</span>
31 <a {{if eq (str $ "Tab") "issues"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/issues">issues</a>
32 <a {{if eq (str $ "Tab") "merge requests"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/mrs">merge requests</a>
33 <a {{if eq (str $ "Tab") "builds"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/builds">builds</a>
34 <span class="glabel sub">publish</span>
35 <a {{if eq (str $ "Tab") "releases"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/releases">releases</a>
36 {{if field $ "HasWiki"}}<a {{if eq (str $ "Tab") "wiki"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/wiki">wiki</a>{{end}}
37 <span class="glabel sub">tools</span>
38 <a {{if eq (str $ "Tab") "search"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/search">search</a>
39 <a href="/{{.OwnerName}}/{{.Name}}/archive/{{str $ "Ref"}}.tar.gz">archive</a>
40 {{end}}
41 </div>
42 <div class="railfoot">
43 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar sm">{{initial .Viewer}}</span>{{.Viewer}}</a>
44 <form method="post" action="/logout"><button type="submit" class="linklike">log out</button></form>
45 {{else}}<a href="/login">sign in</a>{{end}}
46 </div>
47</nav>
2148 <main class="container">
2249 {{template "content" .}}
2350 </main>
51</div>
2452 <footer>
2553 <div class="container"><p>powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a>{{with gitbayVersion}} · <code><a href="https://gitbay.org/krz/gitbay/commit/{{gitbayCommit}}">{{.}}</a></code>{{end}} · <a href="/privacy">privacy</a></p></div>
2654 </footer>
@@ -35,22 +63,6 @@
3563 {{if .Topics}}<p class="topics">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}}
3664 {{range .Mirrors}}<p class="meta mirrorline">{{if eq .Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{.URL}}" rel="nofollow">{{.Target}}</a>{{if .Error}} · <span class="mirrorerr">sync error: {{.Error}}</span>{{else if .Synced}} · synced {{.Synced}}{{end}}</p>
3765 {{end}}</div>
38<nav class="tabs">
39 <span class="glabel">code</span>
40 <a {{if eq .Tab "files"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
41 <a {{if eq .Tab "log"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
42 <a {{if eq .Tab "refs"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a>
43 <span class="glabel">work</span>
44 <a {{if eq .Tab "issues"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a>
45 <a {{if eq .Tab "merge requests"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a>
46 <a {{if eq .Tab "builds"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds">builds</a>
47 <span class="glabel">publish</span>
48 <a {{if eq .Tab "releases"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/releases">releases</a>
49 {{if .HasWiki}}<a {{if eq .Tab "wiki"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/wiki">wiki</a>
50 {{end}}<span class="glabel">tools</span>
51 <a {{if eq .Tab "search"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">search</a>
52 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a>
53</nav>
5466 {{end}}
5567
5668 {{define "reporow"}}<li>
internal/web/web.go +41
@@ -6,6 +6,7 @@ import (
66 "embed"
77 "html/template"
88 "io"
9 "reflect"
910 "runtime/debug"
1011 "strings"
1112 "sync"
@@ -76,6 +77,46 @@ var funcs = template.FuncMap{
7677 },
7778 "add": func(a, b int) int { return a + b },
7879 "sub": func(a, b int) int { return a - b },
80 // initial is the avatar letter for a username.
81 "initial": func(s string) string {
82 for _, r := range s {
83 return strings.ToUpper(string(r))
84 }
85 return "?"
86 },
87 // str is field, narrowed to strings: missing or non-string fields
88 // yield "", which comparisons handle without erroring.
89 "str": func(v any, name string) string {
90 rv := reflect.ValueOf(v)
91 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
92 rv = rv.Elem()
93 }
94 if rv.Kind() != reflect.Struct {
95 return ""
96 }
97 f := rv.FieldByName(name)
98 if !f.IsValid() || f.Kind() != reflect.String {
99 return ""
100 }
101 return f.String()
102 },
103 // field reads a field the page struct may not have, so the layout can
104 // render repo navigation without every page carrying repo context.
105 // Missing fields yield nil, which templates treat as absent.
106 "field": func(v any, name string) any {
107 rv := reflect.ValueOf(v)
108 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
109 rv = rv.Elem()
110 }
111 if rv.Kind() != reflect.Struct {
112 return nil
113 }
114 f := rv.FieldByName(name)
115 if !f.IsValid() || !f.CanInterface() {
116 return nil
117 }
118 return f.Interface()
119 },
79120 // when formats a stored RFC3339 timestamp for display; unparseable
80121 // values pass through unchanged.
81122 "when": func(s string) string {