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

merged merged by cmc on 2026-08-25 23:20 UTC · krz/gitbay:carbon-r2 into main

3 files changed, +214 −61

Layout: unified · split

internal/web/static/style.css +140 −40
@@ -95,7 +95,7 @@
95 --bad: #fa4d56; 95 --bad: #fa4d56;
96 --done: #a56eff; 96 --done: #a56eff;
97 --neutral: #a8a8a8; 97 --neutral: #a8a8a8;
98 --code-bg: #1e1e1e; 98 --code-bg: #131313;
99 --shell-line: #393939; 99 --shell-line: #393939;
100 } 100 }
101} 101}
@@ -133,11 +133,15 @@ code, pre, .code, td.mode, td.size {
133 margin: 0 auto; 133 margin: 0 auto;
134 padding: 0 var(--sp-5); 134 padding: 0 var(--sp-5);
135} 135}
136/* the shell: an ink bar in both schemes, Carbon UI-shell style */ 136/* the shell: an ink bar in both schemes, Carbon UI-shell style. Scoped
137header { 137 to the page header so cards with their own <header> stay untouched. */
138body > header {
138 background: var(--shell-bg); 139 background: var(--shell-bg);
139 border-bottom: 1px solid var(--shell-line); 140 border-bottom: 1px solid var(--shell-line);
140 padding: 0; 141 padding: 0;
142 position: sticky;
143 top: 0;
144 z-index: 30;
141} 145}
142a.site { 146a.site {
143 font-weight: 600; 147 font-weight: 600;
@@ -146,6 +150,7 @@ a.site {
146 display: inline-flex; 150 display: inline-flex;
147 align-items: center; 151 align-items: center;
148 gap: var(--sp-2); 152 gap: var(--sp-2);
153 padding: 0 var(--sp-4);
149} 154}
150a.site:hover { text-decoration: none; color: #ffffff; } 155a.site:hover { text-decoration: none; color: #ffffff; }
151a.site svg.mark { display: block; } 156a.site svg.mark { display: block; }
@@ -155,45 +160,134 @@ nav.topnav {
155 display: flex; 160 display: flex;
156 align-items: center; 161 align-items: center;
157 gap: 0; 162 gap: 0;
158 min-height: 3rem; 163 height: 3rem;
159} 164}
160nav.topnav .spacer { flex: 1; } 165nav.topnav .spacer { flex: 1; }
161nav.topnav > a:not(.site) { 166/* the avatar square is the only nav item in the shell; everything else
162 color: var(--shell-muted); 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;
163 font-size: var(--fs-2); 177 font-size: var(--fs-2);
164 padding: 0.85rem var(--sp-3); 178 font-weight: 600;
165 line-height: 1; 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);
166} 234}
167nav.topnav > a:not(.site):hover { color: #ffffff; background: #353535; text-decoration: none; } 235.siderail .glabel:not(.sub) {
168nav.topnav a.navuser { 236 color: var(--fg);
169 font-weight: 600; 237 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);
170 color: #ffffff; 250 color: #ffffff;
171 box-shadow: inset 0 -3px 0 #0f62fe;
172} 251}
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; }
175main.container { 252main.container {
176 width: 100%; 253 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);
178 padding-bottom: var(--sp-6); 259 padding-bottom: var(--sp-6);
179} 260}
180/* repo pages carry a left rail: the repohead spans the top, the sidenav 261@media (max-width: 52rem) {
181 takes column one, everything after flows in column two. Browsers 262 /* the rail folds into a wrapping strip above the content */
182 without :has() fall back to single-column flow, which still works. */ 263 .shell { display: block; }
183main.container:has(> nav.tabs) { 264 .siderail {
184 display: grid; 265 width: auto;
185 grid-template-columns: 11.5rem minmax(0, 1fr); 266 height: auto;
186 column-gap: var(--sp-6); 267 position: static;
187 max-width: 80rem; 268 flex-direction: row;
188} 269 flex-wrap: wrap;
189main.container:has(> nav.tabs) > * { grid-column: 2; } 270 align-items: center;
190main.container:has(> nav.tabs) > .repohead { grid-column: 1 / -1; } 271 border-right: none;
191main.container:has(> nav.tabs) > nav.tabs { 272 border-bottom: 1px solid var(--line);
192 grid-column: 1; 273 }
193 grid-row: 2 / span 99; 274 .railmain, .railfoot {
194 align-self: start; 275 display: flex;
195 position: sticky; 276 flex-wrap: wrap;
196 top: var(--sp-4); 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; }
197} 291}
198 292
199/* tables */ 293/* tables */
@@ -261,8 +355,8 @@ nav.tabs a.active {
261 355
262.clone { color: var(--muted); font-size: var(--fs-2); } 356.clone { color: var(--muted); font-size: var(--fs-2); }
263.clone code { 357.clone code {
264 background: var(--surface); 358 background: var(--code-bg);
265 border: 1px solid var(--faint); 359 border: 1px solid var(--line);
266 padding: 0.15rem 0.5rem; 360 padding: 0.15rem 0.5rem;
267 border-radius: var(--r-sm); 361 border-radius: var(--r-sm);
268} 362}
@@ -507,7 +601,7 @@ a.memberchip .role { color: var(--muted); }
507.landing .lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.7; margin: 0 0 var(--sp-4); } 601.landing .lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.7; margin: 0 0 var(--sp-4); }
508pre.quickstart { 602pre.quickstart {
509 background: var(--code-bg); 603 background: var(--code-bg);
510 border: 1px solid var(--faint); 604 border: 1px solid var(--line);
511 border-radius: var(--r-md); 605 border-radius: var(--r-md);
512 padding: var(--sp-3) var(--sp-4); 606 padding: var(--sp-3) var(--sp-4);
513 overflow-x: auto; 607 overflow-x: auto;
@@ -540,7 +634,7 @@ button.linklike:hover { text-decoration: underline; filter: none; }
540} 634}
541/* Highlighting is class-based with a palette per scheme, so code blocks 635/* Highlighting is class-based with a palette per scheme, so code blocks
542 follow the site's own colors. */ 636 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); }
544.code pre { margin: 0; background: transparent !important; } 638.code pre { margin: 0; background: transparent !important; }
545@media (prefers-color-scheme: dark) { 639@media (prefers-color-scheme: dark) {
546 .code { border-color: var(--line); } 640 .code { border-color: var(--line); }
@@ -550,13 +644,18 @@ button.linklike:hover { text-decoration: underline; filter: none; }
550.rendered { max-width: 100%; overflow-wrap: anywhere; } 644.rendered { max-width: 100%; overflow-wrap: anywhere; }
551.rendered pre { 645.rendered pre {
552 background: var(--code-bg); 646 background: var(--code-bg);
553 border: 1px solid var(--faint); 647 border: 1px solid var(--line);
554 padding: var(--sp-3) var(--sp-4); 648 padding: var(--sp-3) var(--sp-4);
555 border-radius: var(--r-md); 649 border-radius: var(--r-md);
556 overflow-x: auto; 650 overflow-x: auto;
557} 651}
558.rendered code { background: var(--code-bg); padding: 0.1rem 0.35rem; border-radius: var(--r-sm); } 652.rendered code {
559.rendered pre code { background: transparent; padding: 0; } 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; }
560.rendered blockquote { 659.rendered blockquote {
561 border-left: 3px solid var(--line); 660 border-left: 3px solid var(--line);
562 margin-left: 0; 661 margin-left: 0;
@@ -589,7 +688,7 @@ button.linklike:hover { text-decoration: underline; filter: none; }
589 688
590pre.message { 689pre.message {
591 background: var(--code-bg); 690 background: var(--code-bg);
592 border: 1px solid var(--faint); 691 border: 1px solid var(--line);
593 padding: var(--sp-3) var(--sp-4); 692 padding: var(--sp-3) var(--sp-4);
594 border-radius: var(--r-md); 693 border-radius: var(--r-md);
595 white-space: pre-wrap; 694 white-space: pre-wrap;
@@ -614,7 +713,7 @@ details.difffold summary .add { color: var(--ok); }
614details.difffold summary .del { color: var(--bad); } 713details.difffold summary .del { color: var(--bad); }
615pre.diff { 714pre.diff {
616 background: var(--code-bg); 715 background: var(--code-bg);
617 border: 1px solid var(--faint); 716 border: 1px solid var(--line);
618 padding: var(--sp-3) var(--sp-4); 717 padding: var(--sp-3) var(--sp-4);
619 border-radius: var(--r-md); 718 border-radius: var(--r-md);
620 overflow-x: auto; 719 overflow-x: auto;
@@ -721,6 +820,7 @@ article.release {
721 padding: var(--sp-3) var(--sp-4); 820 padding: var(--sp-3) var(--sp-4);
722 margin: var(--sp-4) 0; 821 margin: var(--sp-4) 0;
723} 822}
823.releasehead { background: none; border: 0; padding: 0; }
724.releasehead h3 { margin: 0 0 var(--sp-1); } 824.releasehead h3 { margin: 0 0 var(--sp-1); }
725.releasehead .meta { margin: 0 0 var(--sp-2); } 825.releasehead .meta { margin: 0 0 var(--sp-2); }
726table.assets { margin-top: var(--sp-2); } 826table.assets { margin-top: var(--sp-2); }
internal/web/templates/layout.html +33 −21
@@ -9,18 +9,46 @@
9</head> 9</head>
10<body> 10<body>
11<header> 11<header>
12 <nav class="container topnav"> 12 <nav class="topnav">
13 <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> 13 <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>
14 <span class="spacer"></span> 14 <span class="spacer"></span>
15 <a href="/explore">explore</a> 15 {{if .Viewer}}<a class="avatar" href="/{{.Viewer}}" title="{{.Viewer}}" aria-label="{{.Viewer}}">{{initial .Viewer}}</a>
16 {{if .Viewer}}<a href="/new">new repository</a> 16 {{else}}<a class="avatar guest" href="/login" title="sign in" aria-label="sign in">→</a>{{end}}
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}}
19 </nav> 17 </nav>
20</header> 18</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>
21<main class="container"> 48<main class="container">
22{{template "content" .}} 49{{template "content" .}}
23</main> 50</main>
51</div>
24<footer> 52<footer>
25 <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> 53 <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>
26</footer> 54</footer>
@@ -35,22 +63,6 @@
35{{if .Topics}}<p class="topics">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}} 63{{if .Topics}}<p class="topics">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}}
36{{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> 64{{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>
37{{end}}</div> 65{{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>
54{{end}} 66{{end}}
55 67
56{{define "reporow"}}<li> 68{{define "reporow"}}<li>
internal/web/web.go +41
@@ -6,6 +6,7 @@ import (
6 "embed" 6 "embed"
7 "html/template" 7 "html/template"
8 "io" 8 "io"
9 "reflect"
9 "runtime/debug" 10 "runtime/debug"
10 "strings" 11 "strings"
11 "sync" 12 "sync"
@@ -76,6 +77,46 @@ var funcs = template.FuncMap{
76 }, 77 },
77 "add": func(a, b int) int { return a + b }, 78 "add": func(a, b int) int { return a + b },
78 "sub": func(a, b int) int { return a - b }, 79 "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 },
79 // when formats a stored RFC3339 timestamp for display; unparseable 120 // when formats a stored RFC3339 timestamp for display; unparseable
80 // values pass through unchanged. 121 // values pass through unchanged.
81 "when": func(s string) string { 122 "when": func(s string) string {