Commit 4e7f0573d0

4e7f0573d0c35b2ec41de44f17f8fe45d3cd2408

parent: f4cdc0af49

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-17 15:53 UTC

web: two-level repository header, landing and register copy

Ref #218
e2e/design_test.go +34 −7
@@ -139,15 +139,42 @@ func TestReadmeRelativeLinks(t *testing.T) {
139139 t.Errorf("explore row missing %q", want)
140140 }
141141 }
142 // The repo header renders the same on every tab of the repo. It sits
143 // above the tab bar, so anything that appears on one tab and not
144 // another moves the navigation between clicks of that navigation.
145 for _, page := range []string{"", "/issues", "/mrs", "/releases"} {
146 _, body := inst.get(t, "/alice/site"+page)
147 if !strings.Contains(body, `href="/explore?q=web"`) {
148 t.Errorf("repo header topics missing on /alice/site%s", page)
142 // The description and topics belong to the code tab; task tabs render
143 // only the identity row and the tab bar.
144 for _, p := range []string{"/alice/site/issues", "/alice/site/mrs", "/alice/site/releases"} {
145 if _, body := inst.get(t, p); strings.Contains(body, `class="chip topic"`) {
146 t.Errorf("%s: header still carries topics on a task tab", p)
149147 }
150148 }
149 if _, body := inst.get(t, "/alice/site"); !strings.Contains(body, `class="chip topic"`) {
150 t.Error("repo home lost its topics")
151 }
152}
153
154// TestLandingRoutes checks the landing page's copy and the two routes.
155func TestLandingRoutes(t *testing.T) {
156 smtp := startFakeSMTP(t)
157 inst := startInstanceWith(t, fmt.Sprintf(
158 "[web]\nmode = \"accounts\"\n[registration]\nmode = \"open\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
159 smtp.addr))
160 _, body := inst.get(t, "/")
161 for _, want := range []string{
162 "A git forge you drive from the terminal.",
163 `class="button primary" href="/explore">Explore repositories</a>`,
164 `class="button btn" href="/register">Create an account</a>`,
165 "web login</code>",
166 } {
167 if !strings.Contains(body, want) {
168 t.Errorf("landing lacks %q", want)
169 }
170 }
171 if strings.Contains(body, "is the whole onboarding") {
172 t.Error("landing still calls repo create the whole onboarding")
173 }
174 _, reg := inst.get(t, "/register")
175 if !strings.Contains(reg, "Paste the contents of your public key file") || !strings.Contains(reg, "/krz/gitbay/wiki/SSH-keys") {
176 t.Error("register page lacks the key hint or the wiki link")
177 }
151178}
152179
153180func TestWebInteractions(t *testing.T) {
internal/httpd/web.go +11 −4
@@ -161,13 +161,20 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
161161 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
162162 s.render(w, "landing.html", struct {
163163 basePage
164 Host string
165 Accounts bool
166 Signup bool
164 Host string
165 Accounts bool
166 Signup bool
167 Picture bool
168 EmailLogin bool
167169 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts",
168 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
170 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed",
171 landingPicture, s.emailLoginEnabled()})
169172}
170173
174// landingPicture says whether the landing page's screenshot images exist to
175// show. Task 15 replaces this with a check of the embedded images.
176var landingPicture = false
177
171178func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
172179 pinned, _ := s.st.PinnedRepos(viewer.ID)
173180 var visible []store.Repo
internal/web/static/style.css +1 −2
@@ -417,7 +417,6 @@ footer a:hover { color: var(--link); }
417417 font-size: var(--fs-2);
418418 display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
419419}
420.repohead .topics { margin: var(--sp-2) 0 0; }
421420.repometa { margin: var(--sp-1) 0 0; color: var(--muted); font-size: var(--fs-1); }
422421.toggles { margin: var(--sp-2) 0 0; color: var(--muted); font-size: var(--fs-1); }
423422
@@ -1369,6 +1368,7 @@ details.refmenu .refdrop a.allrefs {
13691368 readable on the templates that do not set one yet */
13701369.landing { max-width: 48rem; padding: var(--sp-6) 0; }
13711370.landing h1 { font-size: var(--fs-6); display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); }
1371.landing h1 .mark { width: 32px; height: 32px; }
13721372.lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.5; max-width: 40rem; margin: 0 0 var(--sp-5); }
13731373pre.quickstart { margin: 0 0 var(--sp-4); }
13741374.shot { border: 1px solid var(--line); border-radius: var(--r-card); overflow: hidden; margin: var(--sp-5) 0; }
@@ -1377,7 +1377,6 @@ pre.quickstart { margin: 0 0 var(--sp-4); }
13771377.facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); }
13781378.facets p { color: var(--muted); font-size: var(--fs-2); margin: 0; }
13791379.routes { display: flex; gap: var(--sp-3); align-items: center; margin: var(--sp-5) 0 var(--sp-3); }
1380.explorelink { font-weight: 500; }
13811380
13821381/* ---- profile ---- */
13831382.profilehead { border-bottom: 1px solid var(--faint); padding-bottom: var(--sp-4); margin-bottom: var(--sp-4); }
internal/web/templates/landing.html +13 −11
@@ -1,19 +1,21 @@
1{{define "width"}}bounded{{end}}
21{{define "title"}}{{.Site}}{{end}}
2{{define "width"}}bounded{{end}}
33{{define "content"}}
44<div class="landing">
5<h1>{{.Site}}</h1>
6<p class="lede">A CLI-first git forge. SSH is the API: repositories, issues, and
7merge requests are managed from your terminal with nothing but OpenSSH —
8the web is a fast, readable rendering of that state.</p>
9<pre class="quickstart">ssh git@{{.Host}} help # every command, no client needed
5<h1>{{template "mark"}}{{.Site}}</h1>
6<p class="lede">A git forge you drive from the terminal. Repositories, issues, merge requests and CI over SSH, with a fast, readable web view of the same state.</p>
7<pre class="quickstart">ssh git@{{.Host}} help # every command, no client to install
108git clone ssh://git@{{.Host}}/owner/repo.git</pre>
9{{if .Picture}}<div class="shot"><picture>
10 <source srcset="/static/img/mr-dark.png" media="(prefers-color-scheme: dark)">
11 <img src="/static/img/mr-light.png" width="1280" height="900" alt="A merge request page: the conversation on the left, checks and reviewers on the right.">
12</picture></div>{{end}}
1113<div class="facets">
12 <section><h2>Read</h2><p>Browse and clone any public repository, over HTTPS or <code>git://</code>, with no account. Every commit shows whether its signature was verified.</p></section>
13 <section><h2>Write</h2><p>Push over SSH with the key you already have. <code>ssh git@{{.Host}} repo create you/project</code> is the whole onboarding; issues, merge requests and reviews are commands too.</p></section>
14 <section><h2>Review</h2><p>The web is a rendering of the same state: read a diff, comment on a line, approve, merge. Everything it can do, the terminal can do first.</p></section>
14 <section><h2>Read</h2><p>Browse and clone any public repository over HTTPS or <code>git://</code>, no account. Every commit shows whether its signature verified.</p></section>
15 <section><h2>Write</h2><p>Push over SSH with the key you already have. Create a repository, file an issue, open and merge a request, all as commands.</p></section>
16 <section><h2>Review</h2><p>Read a diff, comment on a line, approve, merge, in the browser or the terminal.</p></section>
1517</div>
16<p><a class="explorelink" href="/explore">explore public repositories →</a></p>
17{{if .Accounts}}<p class="meta">have an account? log in from your terminal: <code>gitbay web login</code>{{if .Signup}} · new here? <a href="/register">create an account</a>{{end}}</p>{{end}}
18<div class="routes"><a class="button primary" href="/explore">Explore repositories</a>{{if .Signup}}<a class="button btn" href="/register">Create an account</a>{{end}}</div>
19{{if .Accounts}}<p class="meta">Have an account? {{if .EmailLogin}}<a href="/login">Sign in with an emailed link</a>, or run{{else}}Run{{end}} <code>ssh git@{{.Host}} web login</code>.</p>{{end}}
1820</div>
1921{{end}}
internal/web/templates/layout.html +7 −7
@@ -71,13 +71,13 @@
7171 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}}
7272 </div>
7373 {{$top := topTab (str $ "Tab")}}
74 {{/* The header is the same on every tab: it sits above the tab bar,
75 and anything shown on one tab and not another would move the
76 navigation between clicks of that navigation. */}}
77 {{with field $ "Desc"}}<p class="repodesc">{{.}}</p>{{end}}
78 {{with field $ "Topics"}}<p class="topics">{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}}
79 {{with $.Repo.Settings.Website}}<p class="repometa"><a href="{{.}}" rel="nofollow">{{.}}</a></p>{{end}}
80 {{range field $ "Mirrors"}}<p class="repometa">{{if eq .Direction "push"}}Mirrors to{{else}}Mirrors from{{end}} <a href="{{.URL}}" rel="nofollow">{{.Target}}</a>{{if .Error}} · <span class="bad">sync error: {{.Error}}</span>{{else if .Synced}} · synced {{.Synced}}{{end}}</p>
74 {{/* Description, topics and metadata belong to the code tab. On task
75 tabs only the identity row and the tab bar render, and the header
76 is identical on every page within a tab. */}}
77 {{if eq $top "code"}}
78 <p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}</p>
79 {{if or $.Repo.Settings.Website (field $ "Mirrors")}}<p class="repometa">{{with $.Repo.Settings.Website}}<a href="{{.}}" rel="nofollow">{{.}}</a>{{end}}{{range field $ "Mirrors"}} · {{if eq .Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{.URL}}" rel="nofollow">{{.Target}}</a>{{if .Error}}, <span class="bad">sync error: {{.Error}}</span>{{else if .Synced}}, synced {{.Synced}}{{end}}{{end}}</p>{{end}}
80 {{if $.Viewer}}<p class="toggles">Pinned shows in your rail. Watching sends every issue, request and build to your inbox. Bookmarked lists it under Bookmarks.</p>{{end}}
8181 {{end}}
8282 <nav class="tabs" aria-label="Repository">
8383 <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a>
internal/web/templates/login.html +2 −2
@@ -10,8 +10,8 @@ expires in fifteen minutes.</p>
1010{{else}}
1111{{if .EmailLogin}}
1212<form method="post" action="/login">
13 <label for="identifier">Username or email address</label>
14 <input type="text" id="identifier" name="identifier" autocomplete="username" required>
13 <div class="field"><label for="identifier">Username or email address</label>
14 <input type="text" id="identifier" name="identifier" autocomplete="username" required></div>
1515 <button type="submit">Email me a link</button>
1616</form>
1717<p>Or, from a machine with your registered key:</p>
internal/web/templates/register.html +9 −10
@@ -1,19 +1,18 @@
1{{define "width"}}bounded{{end}}
21{{define "title"}}register · {{.Site}}{{end}}
2{{define "width"}}bounded{{end}}
33{{define "content"}}
4<div class="landing">
54<h1>Create an account</h1>
6{{if eq .Mode "invite"}}<p class="lede">This instance is invite-only: you need an invite code from an admin.</p>
7{{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}}
5{{if eq .Mode "invite"}}<p class="lede">This instance is invite-only. You need an invite code from an admin.</p>
6{{else}}<p class="lede">Open registration. Your account activates once you verify your email.</p>{{end}}
87{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
98<form method="post" action="/register" class="signupform">
10<p><label>Username<br><input type="text" name="username" value="{{.Username}}" required autofocus></label></p>
11{{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p>
12{{else}}<p><label>Email<br><input type="text" name="email" required></label></p>{{end}}
13<p><label>SSH public key — your key is your identity; paste e.g. <code>~/.ssh/id_ed25519.pub</code><br>
14<textarea name="key" aria-label="SSH public key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></label></p>
9<div class="field"><label for="username">Username</label><input type="text" id="username" name="username" value="{{.Username}}" required autofocus></div>
10{{if eq .Mode "invite"}}<div class="field"><label for="invite">Invite code</label><input type="text" id="invite" name="invite" required></div>
11{{else}}<div class="field"><label for="email">Email</label><input type="text" id="email" name="email" required></div>{{end}}
12<div class="field"><label for="key">SSH public key</label>
13<p class="hint">Paste the contents of your public key file, usually <code>~/.ssh/id_ed25519.pub</code>. It starts with <code>ssh-ed25519</code> or <code>ssh-rsa</code>. No key yet? <a href="/krz/gitbay/wiki/SSH-keys">Make one</a>.</p>
14<textarea id="key" name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></div>
1515<p><button type="submit">Create account</button></p>
1616</form>
1717<p class="meta">Prefer the terminal? <code>ssh git@{{.Host}} register --username you {{if eq .Mode "invite"}}--invite &lt;code&gt;{{else}}--email you@example.org{{end}}</code></p>
18</div>
1918{{end}}