web: a UI sweep across the rail, dashboard, tables and fork !440

merged merged by cmc on 2026-09-20 04:53 UTC · krz/gitbay:web-ui-sweep into main

18 files changed, +317 −94

Layout: unified · split

CHANGELOG.org +24
@@ -74,6 +74,30 @@ Nothing is lost in between — the table keeps the text.
74 never been pushed to. An unresolvable branch is still an error 74 never been pushed to. An unresolvable branch is still an error
75 anywhere the repository already has refs. 75 anywhere the repository already has refs.
76 76
77A web UI sweep.
78
79- The rail's squares are 44px at every width, and its glyphs are 20px
80 on a phone. Below 34rem eleven squares no longer fit, so explore,
81 bookmarks, snippets, new repository, settings, admin and log out
82 move into a More menu and the strip keeps dashboard, search,
83 notifications, More and your account.
84- Snippets are on the rail. The link on a profile page stays.
85- =/new= creates an organization as well as a repository; the form
86 leaves the profile page.
87- =repo fork= takes =--owner=, and the web's Fork button opens a form
88 with an owner and a name. The owner is you or an organization you
89 administer, checked as =repo create= checks it; an organization's
90 fork is not counted against your repository quota. A name of your
91 own is how you fork the same repository twice.
92- The dashboard's pinned key reads above the list rather than under
93 it, its build dot sits on the row's centre line, and a pinned chip
94 on the second row has its bottom border back.
95- The activity feed drops its state dot.
96- The SSH key and account tables keep each row on one line and scroll
97 sideways, rather than wrapping a fingerprint or a timestamp.
98- =.pathbar= actions sit on the centre line with the ref and the
99 crumbs.
100
77* v1.30.0 — 2026-09-19 101* v1.30.0 — 2026-09-19
78 102
79Every command runs on every surface, markup previews before it is 103Every command runs on every surface, markup previews before it is
cmd/gitbay/main.go +1 −1
@@ -435,7 +435,7 @@ func repoCmd() *cobra.Command {
435 pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}), 435 pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}),
436 pass("rename", "rename a repository: <new-name> (clone URLs change)", passOpts{server: []string{"repo", "rename"}, needsRepo: true}), 436 pass("rename", "rename a repository: <new-name> (clone URLs change)", passOpts{server: []string{"repo", "rename"}, needsRepo: true}),
437 pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}), 437 pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}),
438 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), 438 pass("fork", "fork a repository to you or an organization", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
439 pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}), 439 pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}),
440 pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}), 440 pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}),
441 pass("diff", "the patch between two refs: <base> <head>", passOpts{server: []string{"repo", "diff"}, needsRepo: true}), 441 pass("diff", "the patch between two refs: <base> <head>", passOpts{server: []string{"repo", "diff"}, needsRepo: true}),
e2e/dashboard_test.go +4 −6
@@ -356,9 +356,10 @@ func TestDashboardQueues(t *testing.T) {
356 } 356 }
357} 357}
358 358
359// D04/D05: two jobs on one commit fold into a single feed line, marked 359// D04/D05: two jobs on one commit fold into a single feed line, shown
360// with the worse of the two outcomes, and shown with a relative time 360// with a relative time carrying the exact UTC time in its title. The
361// carrying the exact UTC time in its title. 361// folded line's state is the worse of the two, which the feed no longer
362// draws; TestFeedLinesFoldsBuildRun covers that rule.
362func TestDashboardFeedFoldsBuildRun(t *testing.T) { 363func TestDashboardFeedFoldsBuildRun(t *testing.T) {
363 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") 364 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
364 inst.runner = buildRunner(t) 365 inst.runner = buildRunner(t)
@@ -392,9 +393,6 @@ func TestDashboardFeedFoldsBuildRun(t *testing.T) {
392 if !strings.Contains(body, "ran 2 jobs on") { 393 if !strings.Contains(body, "ran 2 jobs on") {
393 t.Fatalf("feed did not fold the two jobs into one run:\n%s", body) 394 t.Fatalf("feed did not fold the two jobs into one run:\n%s", body)
394 } 395 }
395 if !strings.Contains(body, `class="dot bad"`) {
396 t.Fatalf("feed did not mark the run with the worse (failure) status:\n%s", body)
397 }
398 if !strings.Contains(body, sha[:10]) { 396 if !strings.Contains(body, sha[:10]) {
399 t.Fatalf("feed missing the short sha %q:\n%s", sha[:10], body) 397 t.Fatalf("feed missing the short sha %q:\n%s", sha[:10], body)
400 } 398 }
e2e/forkweb_test.go +24 −1
@@ -69,10 +69,33 @@ func TestForkWeb(t *testing.T) {
69 t.Fatalf("merge request not opened from the fork:\n%s", out) 69 t.Fatalf("merge request not opened from the fork:\n%s", out)
70 } 70 }
71 71
72 // Forking twice collides on the name, and says so on the page rather 72 // Forking twice collides on the name, and says so on the form rather
73 // than failing silently. 73 // than failing silently.
74 _, body := browserPost(t, bob, base+"/fork", url.Values{}) 74 _, body := browserPost(t, bob, base+"/fork", url.Values{})
75 if !strings.Contains(body, `class="error"`) { 75 if !strings.Contains(body, `class="error"`) {
76 t.Errorf("a second fork reported nothing:\n%s", body) 76 t.Errorf("a second fork reported nothing:\n%s", body)
77 } 77 }
78
79 // A fork can land in an organization the forker administers, under a
80 // name of its own — which is also how you fork the same repository
81 // twice.
82 inst.ssh(t, bobKey, "", "org", "create", "bobco")
83 if _, page := browserGet(t, bob, base+"/fork"); !strings.Contains(page, `<option value="bobco"`) {
84 t.Fatalf("the fork form does not offer the organization:\n%s", page)
85 }
86 if status, body := browserPost(t, bob, base+"/fork", url.Values{
87 "owner": {"bobco"}, "name": {"app2"}}); status != 200 || !strings.Contains(body, "bobco/app2") {
88 t.Fatalf("fork into the organization did not land: %d\n%s", status, body)
89 }
90 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "bobco/app2", "--json"); !strings.Contains(out, `"fork_of":"alice/app"`) {
91 t.Fatalf("the organization's fork does not name its parent:\n%s", out)
92 }
93
94 // An organization you are only a member of is not somewhere you can
95 // put one: the owner is checked as it is on repo create.
96 inst.ssh(t, aliceKey, "", "org", "create", "aliceco")
97 inst.ssh(t, aliceKey, "", "org", "members", "add", "aliceco", "bob")
98 if _, _, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/app", "--owner", "aliceco"); code != 4 {
99 t.Errorf("fork into an org bob does not administer exited %d, want 4", code)
100 }
78} 101}
e2e/orgweb_test.go +7 −6
@@ -162,15 +162,16 @@ func TestOrgLifecycleWeb(t *testing.T) {
162 alice := loginBrowser(t, inst, aliceKey) 162 alice := loginBrowser(t, inst, aliceKey)
163 bob := loginBrowser(t, inst, bobKey) 163 bob := loginBrowser(t, inst, bobKey)
164 164
165 // The create form is on your own page and nobody else's. 165 // The create form is on /new, beside the repository form, and no
166 if _, body := browserGet(t, alice, inst.base()+"/alice"); !strings.Contains(body, `value="org-create"`) { 166 // profile page carries it.
167 t.Fatalf("no create form on your own page:\n%s", body) 167 if _, body := browserGet(t, alice, inst.base()+"/new"); !strings.Contains(body, `value="org-create"`) {
168 t.Fatalf("no create form on /new:\n%s", body)
168 } 169 }
169 if _, body := browserGet(t, bob, inst.base()+"/alice"); strings.Contains(body, `value="org-create"`) { 170 if _, body := browserGet(t, alice, inst.base()+"/alice"); strings.Contains(body, `value="org-create"`) {
170 t.Fatal("create form on someone else's page") 171 t.Fatal("create form still on the profile page")
171 } 172 }
172 173
173 if status, _ := browserPost(t, alice, inst.base()+"/alice", url.Values{ 174 if status, _ := browserPost(t, alice, inst.base()+"/new", url.Values{
174 "field": {"org-create"}, "name": {"acmeco"}}); status != 200 { 175 "field": {"org-create"}, "name": {"acmeco"}}); status != 200 {
175 t.Fatal("org create failed") 176 t.Fatal("org create failed")
176 } 177 }
internal/control/mr.go +20 −9
@@ -20,7 +20,7 @@ import (
20func init() { 20func init() {
21 register(Command{Path: []string{"repo", "fork"}, 21 register(Command{Path: []string{"repo", "fork"},
22 Summary: "fork a repository under your account", 22 Summary: "fork a repository under your account",
23 Usage: "repo fork <owner/name> [--name <n>]", Run: runRepoFork}) 23 Usage: "repo fork <owner/name> [--owner <o>] [--name <n>]", Run: runRepoFork})
24 register(Command{Path: []string{"repo", "settings", "require-approvals"}, 24 register(Command{Path: []string{"repo", "settings", "require-approvals"},
25 Summary: "require N fresh approvals to merge", 25 Summary: "require N fresh approvals to merge",
26 Usage: "repo settings require-approvals <owner/name> <n> (0 = off)", Run: runRequireApprovals}) 26 Usage: "repo settings require-approvals <owner/name> <n> (0 = off)", Run: runRequireApprovals})
@@ -102,11 +102,11 @@ type ForkOut struct {
102} 102}
103 103
104func runRepoFork(c *Ctx, args []string) int { 104func runRepoFork(c *Ctx, args []string) int {
105 f, err := parseFlags(args, flagSpec{Values: []string{"--name"}, MaxPos: 1, Usage: "repo fork <owner/name> [--name <n>]"}) 105 f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--owner"}, MaxPos: 1, Usage: "repo fork <owner/name> [--owner <o>] [--name <n>]"})
106 if err != nil { 106 if err != nil {
107 return c.fail(protocol.ExitUsage, "%v", err) 107 return c.fail(protocol.ExitUsage, "%v", err)
108 } 108 }
109 path, name := f.pos(0), f.Value("--name") 109 path, name, owner := f.pos(0), f.Value("--name"), f.Value("--owner")
110 if path == "" { 110 if path == "" {
111 return c.usage() 111 return c.usage()
112 } 112 }
@@ -120,17 +120,28 @@ func runRepoFork(c *Ctx, args []string) int {
120 if err := policy.ValidateName(name); err != nil { 120 if err := policy.ValidateName(name); err != nil {
121 return c.failInput(err) 121 return c.failInput(err)
122 } 122 }
123 repoCreateMu.Lock() 123 if owner == "" {
124 if code := checkRepoQuota(c); code >= 0 { 124 owner = c.User.Username
125 repoCreateMu.Unlock() 125 }
126 ownerKind, ownerID, code := resolveNewRepoOwner(c, owner)
127 if code >= 0 {
126 return code 128 return code
127 } 129 }
128 id, err := c.Store.CreateFork("user", c.User.ID, name, src.Visibility, src.ID) 130 repoCreateMu.Lock()
131 // An organization's repositories are not counted against the quota,
132 // the same as repo create.
133 if ownerKind == "user" {
134 if code := checkRepoQuota(c); code >= 0 {
135 repoCreateMu.Unlock()
136 return code
137 }
138 }
139 id, err := c.Store.CreateFork(ownerKind, ownerID, name, src.Visibility, src.ID)
129 repoCreateMu.Unlock() 140 repoCreateMu.Unlock()
130 if err != nil { 141 if err != nil {
131 return c.fail(protocol.ExitFailure, "%v", err) 142 return c.fail(protocol.ExitFailure, "%v", err)
132 } 143 }
133 dstDir := RepoDir(c.Cfg.Server.Root, c.User.Username, name) 144 dstDir := RepoDir(c.Cfg.Server.Root, owner, name)
134 srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name) 145 srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name)
135 if err := gitutil.InitBare(dstDir, "main", HooksDir(c.Cfg.Server.Root)); err != nil { 146 if err := gitutil.InitBare(dstDir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
136 c.Store.DeleteRepo(id) 147 c.Store.DeleteRepo(id)
@@ -146,7 +157,7 @@ func runRepoFork(c *Ctx, args []string) int {
146 return c.fail(protocol.ExitFailure, "copying refs: %v", err) 157 return c.fail(protocol.ExitFailure, "copying refs: %v", err)
147 } 158 }
148 } 159 }
149 forkPath := c.User.Username + "/" + name 160 forkPath := owner + "/" + name
150 return c.emit(ForkOut{Path: forkPath, ForkOf: src.Path()}, func(w io.Writer) { 161 return c.emit(ForkOut{Path: forkPath, ForkOf: src.Path()}, func(w io.Writer) {
151 fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath) 162 fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath)
152 }) 163 })
internal/control/repo.go +24 −14
@@ -186,20 +186,9 @@ func runRepoCreate(c *Ctx, args []string) int {
186 if err := policyValidateRepoName(name); err != nil { 186 if err := policyValidateRepoName(name); err != nil {
187 return c.failInput(err) 187 return c.failInput(err)
188 } 188 }
189 ownerKind, ownerID := "user", c.User.ID 189 ownerKind, ownerID, code := resolveNewRepoOwner(c, owner)
190 if owner != c.User.Username { 190 if code >= 0 {
191 org, err := c.Store.OrgByName(owner) 191 return code
192 if err != nil {
193 return c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
194 }
195 role, err := c.Store.OrgRole(org.ID, c.User.ID)
196 if err != nil {
197 return c.fail(protocol.ExitFailure, "%v", err)
198 }
199 if role != "admin" {
200 return c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
201 }
202 ownerKind, ownerID = "org", org.ID
203 } 192 }
204 repoCreateMu.Lock() 193 repoCreateMu.Lock()
205 if ownerKind == "user" { 194 if ownerKind == "user" {
@@ -234,6 +223,27 @@ func runRepoCreate(c *Ctx, args []string) int {
234 }) 223 })
235} 224}
236 225
226// resolveNewRepoOwner answers who a new repository belongs to: the
227// caller, or an organization they administer. The returned code is -1
228// when the owner is good, and the exit code to return otherwise.
229func resolveNewRepoOwner(c *Ctx, owner string) (kind string, id int64, code int) {
230 if owner == c.User.Username {
231 return "user", c.User.ID, -1
232 }
233 org, err := c.Store.OrgByName(owner)
234 if err != nil {
235 return "", 0, c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
236 }
237 role, err := c.Store.OrgRole(org.ID, c.User.ID)
238 if err != nil {
239 return "", 0, c.fail(protocol.ExitFailure, "%v", err)
240 }
241 if role != "admin" {
242 return "", 0, c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
243 }
244 return "org", org.ID, -1
245}
246
237func policyValidateRepoName(name string) error { return policy.ValidateName(name) } 247func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
238 248
239func hostOf(siteURL string) string { 249func hostOf(siteURL string) string {
internal/httpd/accounts.go +48 −7
@@ -200,7 +200,19 @@ func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.Use
200 s.renderNewRepo(w, u, "") 200 s.renderNewRepo(w, u, "")
201} 201}
202 202
203func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 203// newSubmit creates a repository or an organization: /new carries both
204// forms, told apart by the org form's field. An organization's page is
205// the redirect, the same as org-create from anywhere else.
206func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
207 if r.FormValue("field") == "org-create" {
208 name := strings.TrimSpace(r.FormValue("name"))
209 if _, msg, ok := s.runControl(u, []string{"org", "create", name}); !ok {
210 s.renderNewRepo(w, u, msg)
211 return
212 }
213 http.Redirect(w, r, "/"+name, http.StatusSeeOther)
214 return
215 }
204 owner := r.FormValue("owner") 216 owner := r.FormValue("owner")
205 if owner == "" { 217 if owner == "" {
206 owner = u.Username 218 owner = u.Username
@@ -260,19 +272,48 @@ func (s *Server) bookmarksPage(w http.ResponseWriter, r *http.Request, u store.U
260 }{s.baseFor(u), "bookmarks", rows}) 272 }{s.baseFor(u), "bookmarks", rows})
261} 273}
262 274
263// forkSubmit forks the repository under the viewer's account and sends 275// renderFork draws the fork form: where the copy lands and what it is
276// called. owner and name are what the field should hold, which after a
277// refusal is what was submitted.
278func (s *Server) renderFork(w http.ResponseWriter, u store.User, repo store.Repo, owner, name, errMsg string) {
279 s.render(w, "fork.html", struct {
280 basePage
281 Repo store.Repo
282 Orgs []string
283 Owner string
284 Name string
285 Error string
286 }{s.baseFor(u), repo, s.adminOrgs(u), owner, name, errMsg})
287}
288
289func (s *Server) forkForm(w http.ResponseWriter, r *http.Request, u store.User) {
290 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
291 if !ok {
292 return
293 }
294 s.renderFork(w, u, repo, u.Username, repo.Name, "")
295}
296
297// forkSubmit forks the repository to the owner the form picked and sends
264// them to it. The command decides everything that matters — read access, 298// them to it. The command decides everything that matters — read access,
265// quota, name collisions — so a refusal comes back as its own message on 299// the right to create under that owner, quota, name collisions — so a
266// the page the button was pressed from (#174). 300// refusal comes back as its own message on the form (#174).
267func (s *Server) forkSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 301func (s *Server) forkSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
268 repo, ok := s.repoForUser(w, r, u, policy.CanRead) 302 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
269 if !ok { 303 if !ok {
270 return 304 return
271 } 305 }
306 owner, name := r.FormValue("owner"), r.FormValue("name")
307 if owner == "" {
308 owner = u.Username
309 }
310 if name == "" {
311 name = repo.Name
312 }
272 var fork control.ForkOut 313 var fork control.ForkOut
273 if msg, ok := s.runControlInto(u, []string{"repo", "fork", repo.Path()}, &fork); !ok { 314 argv := []string{"repo", "fork", repo.Path(), "--owner", owner, "--name", name}
274 s.setFlash(w, msg) 315 if msg, ok := s.runControlInto(u, argv, &fork); !ok {
275 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) 316 s.renderFork(w, u, repo, owner, name, msg)
276 return 317 return
277 } 318 }
278 http.Redirect(w, r, "/"+fork.Path, http.StatusSeeOther) 319 http.Redirect(w, r, "/"+fork.Path, http.StatusSeeOther)
internal/httpd/routes.go +2 −1
@@ -142,7 +142,7 @@ func (s *Server) Routes() []Route {
142 } 142 }
143 routes = append(routes, 143 routes = append(routes,
144 Route{Method: "POST", Pattern: "/new", Mutating: true, 144 Route{Method: "POST", Pattern: "/new", Mutating: true,
145 Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))}, 145 Handler: s.checkOrigin(s.requireUser(s.newSubmit))},
146 Route{Method: "POST", Pattern: "/{owner}/{repo}/pin", Mutating: true, 146 Route{Method: "POST", Pattern: "/{owner}/{repo}/pin", Mutating: true,
147 Handler: s.checkOrigin(s.requireUser(s.pinToggle))}, 147 Handler: s.checkOrigin(s.requireUser(s.pinToggle))},
148 Route{Method: "POST", Pattern: "/{owner}/{repo}/watch", Mutating: true, 148 Route{Method: "POST", Pattern: "/{owner}/{repo}/watch", Mutating: true,
@@ -182,6 +182,7 @@ func (s *Server) Routes() []Route {
182 Handler: s.checkOrigin(s.requireUser(s.snippetFileRemoveSubmit))}, 182 Handler: s.checkOrigin(s.requireUser(s.snippetFileRemoveSubmit))},
183 Route{Method: "POST", Pattern: "/{owner}/{repo}/bookmark", Mutating: true, 183 Route{Method: "POST", Pattern: "/{owner}/{repo}/bookmark", Mutating: true,
184 Handler: s.checkOrigin(s.requireUser(s.bookmarkToggle))}, 184 Handler: s.checkOrigin(s.requireUser(s.bookmarkToggle))},
185 Route{Method: "GET", Pattern: "/{owner}/{repo}/fork", Handler: s.requireUser(s.forkForm)},
185 Route{Method: "POST", Pattern: "/{owner}/{repo}/fork", Mutating: true, 186 Route{Method: "POST", Pattern: "/{owner}/{repo}/fork", Mutating: true,
186 Handler: s.checkOrigin(s.requireUser(s.forkSubmit))}, 187 Handler: s.checkOrigin(s.requireUser(s.forkSubmit))},
187 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, 188 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true,
internal/web/static/style.css +61 −8
@@ -120,7 +120,7 @@
120 --sp-7: 48px; 120 --sp-7: 48px;
121 121
122 /* the rail: a column the width of one hit target, a row one tall */ 122 /* the rail: a column the width of one hit target, a row one tall */
123 --rail-hit: 40px; 123 --rail-hit: 44px;
124 --rail-strip-h: 48px; 124 --rail-strip-h: 48px;
125 --rail-mark: 24px; 125 --rail-mark: 24px;
126 --rail-mark-box: 32px; 126 --rail-mark-box: 32px;
@@ -264,6 +264,9 @@ a.skip {
264 position: absolute; 264 position: absolute;
265 left: var(--sp-3); top: -3rem; 265 left: var(--sp-3); top: -3rem;
266 z-index: 50; 266 z-index: 50;
267 display: flex;
268 align-items: center;
269 min-height: var(--rail-hit);
267 background: var(--fill); 270 background: var(--fill);
268 color: var(--fill-fg); 271 color: var(--fill-fg);
269 padding: var(--sp-2) var(--sp-4); 272 padding: var(--sp-2) var(--sp-4);
@@ -365,6 +368,44 @@ a.railuser {
365 height: var(--rail-strip-h); 368 height: var(--rail-strip-h);
366} 369}
367a.railuser:hover { text-decoration: none; } 370a.railuser:hover { text-decoration: none; }
371/* the phone rail's overflow: every square .railopt hides, and log out,
372 as a labelled list under the rail. Shown only below 34rem. */
373.railmore { display: none; position: relative; }
374.railmore > summary { list-style: none; cursor: pointer; }
375.railmore > summary::-webkit-details-marker { display: none; }
376.railmore[open] > summary { background: var(--shell-hover); }
377.raildrop {
378 position: absolute;
379 top: 100%;
380 right: 0;
381 z-index: 20;
382 min-width: 12rem;
383 padding: var(--sp-1) 0;
384 background: var(--shell-bg);
385 border: 1px solid var(--shell-line);
386 border-radius: var(--r-card);
387 box-shadow: 0 6px 24px rgb(0 0 0 / 0.35);
388}
389.raildrop a, .raildrop button {
390 display: flex;
391 align-items: center;
392 /* a button's own default is centred; the rows read as one list */
393 justify-content: flex-start;
394 gap: var(--sp-3);
395 width: 100%;
396 min-height: var(--rail-hit);
397 padding: 0 var(--sp-4);
398 border: 0;
399 background: none;
400 font: inherit;
401 font-size: var(--fs-2);
402 text-align: left;
403 color: var(--shell-fg);
404 cursor: pointer;
405}
406.raildrop a:hover, .raildrop button:hover { background: var(--shell-hover); text-decoration: none; }
407.raildrop form { margin: 0; }
408.raildrop svg { flex: none; }
368a.railuser .avatar { width: var(--rail-mark); height: var(--rail-mark); } 409a.railuser .avatar { width: var(--rail-mark); height: var(--rail-mark); }
369.avatar { 410.avatar {
370 display: inline-grid; 411 display: inline-grid;
@@ -846,6 +887,9 @@ table.keys, table.assets { background: none; border: 0; border-radius: 0; }
846table.keys th, table.assets th, table.keys tr.cols th { background: transparent; padding-left: 0; } 887table.keys th, table.assets th, table.keys tr.cols th { background: transparent; padding-left: 0; }
847table.keys td { padding-left: 0; } 888table.keys td { padding-left: 0; }
848table.keys td.act, table.assets td.size { text-align: right; } 889table.keys td.act, table.assets td.size { text-align: right; }
890/* a fingerprint or an account row is read across, not down: the cells
891 keep one line each and .tablewrap scrolls what does not fit */
892table.keys.nowrap th, table.keys.nowrap td { white-space: nowrap; }
849/* The account list types a username to confirm, which is longer than 893/* The account list types a username to confirm, which is longer than
850 the key prefixes the confirm field is sized for elsewhere. */ 894 the key prefixes the confirm field is sized for elsewhere. */
851table.accounts input[name="confirm"] { width: 11rem; } 895table.accounts input[name="confirm"] { width: 11rem; }
@@ -1075,7 +1119,7 @@ pre.message {
1075.pins a { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 1119.pins a { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1076.pins a:hover { color: var(--link); } 1120.pins a:hover { color: var(--link); }
1077.pins .owner { color: var(--muted); } 1121.pins .owner { color: var(--muted); }
1078.pins .n { margin-left: auto; flex: none; display: flex; gap: var(--sp-2); font-size: var(--fs-1); color: var(--muted); font-variant-numeric: tabular-nums; } 1122.pins .n { margin-left: auto; flex: none; display: flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-1); color: var(--muted); font-variant-numeric: tabular-nums; }
1079.pins .n b { font-weight: 600; color: var(--fg); } 1123.pins .n b { font-weight: 600; color: var(--fg); }
1080.pins .n b.wants { color: var(--warn); } 1124.pins .n b.wants { color: var(--warn); }
1081.pins .dot { margin: 0; } 1125.pins .dot { margin: 0; }
@@ -1384,7 +1428,7 @@ a.memberchip .role { color: var(--muted); }
1384 1428
1385.pathbar { display: flex; align-items: center; gap: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-3); flex-wrap: wrap; font-size: var(--fs-2); } 1429.pathbar { display: flex; align-items: center; gap: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-3); flex-wrap: wrap; font-size: var(--fs-2); }
1386.pathbar .spacer { flex: 1; } 1430.pathbar .spacer { flex: 1; }
1387.pathbar .actions { display: flex; gap: var(--sp-2); } 1431.pathbar .actions { display: flex; align-items: center; gap: var(--sp-2); }
1388/* history · blame · raw were 21px tall and "raw" 22px wide (#232) */ 1432/* history · blame · raw were 21px tall and "raw" 22px wide (#232) */
1389.pathbar .actions a { display: inline-block; padding: 6px 4px; } 1433.pathbar .actions a { display: inline-block; padding: 6px 4px; }
1390.pathbar .acts { display: flex; gap: var(--sp-2); } 1434.pathbar .acts { display: flex; gap: var(--sp-2); }
@@ -1670,7 +1714,9 @@ svg.icon { vertical-align: -0.125em; }
1670 .dashgrid > .dashpins { grid-column: 1 / -1; position: static; max-height: none; overflow: visible; } 1714 .dashgrid > .dashpins { grid-column: 1 / -1; position: static; max-height: none; overflow: visible; }
1671 /* a pinned block that is not a column is the chip row (spec rule 7) */ 1715 /* a pinned block that is not a column is the chip row (spec rule 7) */
1672 .pins { display: flex; flex-wrap: wrap; gap: var(--sp-2); } 1716 .pins { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
1673 .pins li { border: 1px solid var(--line); border-radius: var(--r-ctl); padding: var(--sp-1) var(--sp-3); } 1717 /* the shorthand loses to .pins li:last-child, which drops the bottom
1718 border of the last chip -- and so of the second row */
1719 .pins li, .pins li:last-child { border: 1px solid var(--line); border-radius: var(--r-ctl); padding: var(--sp-1) var(--sp-3); }
1674 .pins .n, .dashpins .meta { display: none; } 1720 .pins .n, .dashpins .meta { display: none; }
1675} 1721}
1676 1722
@@ -1743,8 +1789,15 @@ svg.icon { vertical-align: -0.125em; }
1743 ul.loglist .commitside { width: 100%; justify-content: flex-start; } 1789 ul.loglist .commitside { width: 100%; justify-content: flex-start; }
1744} 1790}
1745 1791
1746/* eleven squares at 40px overrun a phone; 32px keeps the row whole and 1792/* Eleven 44px squares and the mark are 524px, so below 34rem the rail
1747 the 48px height keeps the target big enough to hit */ 1793 keeps five — dashboard, search, notifications, More, your avatar — and
1748@media (max-width: 30rem) { 1794 the More menu holds the rest. Shrinking the squares instead is what
1749 :root { --rail-hit: 32px; } 1795 this replaces: 32px was under the target every phone guideline asks
1796 for, and the count only grows. */
1797@media (max-width: 34rem) {
1798 .railopt { display: none; }
1799 .railmore { display: block; }
1800 .railicon svg { width: 20px; height: 20px; }
1801 /* the mark is a link home too, so it is a target like the rest */
1802 a.brand { width: var(--rail-hit); }
1750} 1803}
internal/web/templates/account.html +3 −3
@@ -50,8 +50,8 @@ Write it with a push, or edit it here.</p>
50<p class="meta">Your keys are your identity here. A <code>full</code> key can run 50<p class="meta">Your keys are your identity here. A <code>full</code> key can run
51commands and push; a <code>git</code> key can only move git data, which is what 51commands and push; a <code>git</code> key can only move git data, which is what
52a CI checkout wants.</p> 52a CI checkout wants.</p>
53{{if .Keys}}<div class="tablewrap"><table class="keys"> 53{{if .Keys}}<div class="tablewrap"><table class="keys nowrap">
54<tr class="cols"><th scope="col">label</th><th scope="col">fingerprint</th><th scope="col">type</th><th scope="col">scope</th><th scope="col"></th></tr> 54<tr class="cols"><th scope="col">label</th><th scope="col">fingerprint</th><th scope="col">type</th><th scope="col">scope</th><th scope="col"><span class="vh">actions</span></th></tr>
55{{range .Keys}}<tr> 55{{range .Keys}}<tr>
56 <td>{{.Label}}</td> 56 <td>{{.Label}}</td>
57 <td class="mono">{{.Fingerprint}}</td> 57 <td class="mono">{{.Fingerprint}}</td>
@@ -111,7 +111,7 @@ account, and where notifications go.</p>
111<p class="meta">Only needed if you sign commits with GPG. SSH signing 111<p class="meta">Only needed if you sign commits with GPG. SSH signing
112(<code>gpg.format = ssh</code>) uses the keys above.</p> 112(<code>gpg.format = ssh</code>) uses the keys above.</p>
113{{if .PGP}}<div class="tablewrap"><table class="keys"> 113{{if .PGP}}<div class="tablewrap"><table class="keys">
114<tr class="cols"><th scope="col">fingerprint</th><th scope="col">identities</th><th scope="col"></th></tr> 114<tr class="cols"><th scope="col">fingerprint</th><th scope="col">identities</th><th scope="col"><span class="vh">actions</span></th></tr>
115{{range .PGP}}<tr> 115{{range .PGP}}<tr>
116 <td class="mono">{{.Fingerprint}}</td> 116 <td class="mono">{{.Fingerprint}}</td>
117 <td>{{range .UIDs}}{{.}}<br>{{end}}{{if .Revoked}}<span class="badge badge-bad_signature">revoked</span>{{else if .Expired}}<span class="badge badge-signed_key_expired">expired</span>{{end}}</td> 117 <td>{{range .UIDs}}{{.}}<br>{{end}}{{if .Revoked}}<span class="badge badge-bad_signature">revoked</span>{{else if .Expired}}<span class="badge badge-signed_key_expired">expired</span>{{end}}</td>
internal/web/templates/adminusers.html +1 −1
@@ -13,7 +13,7 @@
13 <a {{if eq .State "admin"}}class="active" aria-current="page" {{end}}href="?state=admin">admins</a> 13 <a {{if eq .State "admin"}}class="active" aria-current="page" {{end}}href="?state=admin">admins</a>
14</nav> 14</nav>
15 15
16{{if .Users}}<div class="tablewrap"><table class="keys accounts"> 16{{if .Users}}<div class="tablewrap"><table class="keys accounts nowrap">
17<thead><tr class="cols"><th>Account</th><th>State</th><th>Created</th><th>Last seen</th><th>Actions</th></tr></thead> 17<thead><tr class="cols"><th>Account</th><th>State</th><th>Created</th><th>Last seen</th><th>Actions</th></tr></thead>
18<tbody> 18<tbody>
19{{range .Users}}<tr> 19{{range .Users}}<tr>
internal/web/templates/dashboard.html +3 −3
@@ -22,10 +22,10 @@
22 22
23<aside class="dashpins" aria-label="Pinned repositories"> 23<aside class="dashpins" aria-label="Pinned repositories">
24 <h2 class="colhead">Pinned</h2> 24 <h2 class="colhead">Pinned</h2>
25 {{if .Pins}}<ul class="pins"> 25 {{if .Pins}}<p class="meta">issues · merge requests · last build</p>
26 <ul class="pins">
26 {{range .Pins}}<li><a href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a><span class="n" title="{{.Issues}} open issue{{if ne .Issues 1}}s{{end}}, {{.MRs}} open merge request{{if ne .MRs 1}}s{{end}}{{with .Build}}, last build {{.}}{{end}}"><b{{if .Issues}} class="wants"{{end}}>{{.Issues}} <span class="vh">open issues</span></b> <b{{if .MRs}} class="wants"{{end}}>{{.MRs}} <span class="vh">open merge requests</span></b> <span class="dot{{if eq .Build "success"}} ok{{else if or (eq .Build "failure") (eq .Build "error")}} bad{{else if or (eq .Build "pending") (eq .Build "running")}} pend{{end}}"><span class="vh">{{with .Build}}last build {{.}}{{else}}no builds{{end}}</span></span></span></li> 27 {{range .Pins}}<li><a href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a><span class="n" title="{{.Issues}} open issue{{if ne .Issues 1}}s{{end}}, {{.MRs}} open merge request{{if ne .MRs 1}}s{{end}}{{with .Build}}, last build {{.}}{{end}}"><b{{if .Issues}} class="wants"{{end}}>{{.Issues}} <span class="vh">open issues</span></b> <b{{if .MRs}} class="wants"{{end}}>{{.MRs}} <span class="vh">open merge requests</span></b> <span class="dot{{if eq .Build "success"}} ok{{else if or (eq .Build "failure") (eq .Build "error")}} bad{{else if or (eq .Build "pending") (eq .Build "running")}} pend{{end}}"><span class="vh">{{with .Build}}last build {{.}}{{else}}no builds{{end}}</span></span></span></li>
27 {{end}}</ul> 28 {{end}}</ul>
28 <p class="meta">issues · merge requests · last build</p>
29 {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}} 29 {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}}
30</aside> 30</aside>
31 31
@@ -46,7 +46,7 @@
46 46
47<aside class="feedcol" aria-label="Recent activity"> 47<aside class="feedcol" aria-label="Recent activity">
48 <h2 class="colhead">Recent activity</h2> 48 <h2 class="colhead">Recent activity</h2>
49 {{range .Feed}}<p class="feedline">{{if eq .State "failure"}}<span class="dot bad"></span>{{else if eq .State "success"}}<span class="dot ok"></span>{{else if .State}}<span class="dot pend"></span>{{end}}<a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p> 49 {{range .Feed}}<p class="feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
50 {{else}}<p class="none">No activity yet</p>{{end}} 50 {{else}}<p class="none">No activity yet</p>{{end}}
51</aside> 51</aside>
52 52
internal/web/templates/fork.html added +18
@@ -0,0 +1,18 @@
1{{define "width"}}bounded{{end}}
2{{define "title"}}fork {{.Repo.OwnerName}}/{{.Repo.Name}} · {{.Site}}{{end}}
3{{define "content"}}
4<h1>Fork {{.Repo.OwnerName}}/{{.Repo.Name}}</h1>
5{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
6<p class="meta">A fork is a copy of every branch, owned by you or by an
7organization you administer. Its issues, merge requests and settings are its
8own.</p>
9<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/fork">
10<p><label>Owner <select name="owner">
11 <option value="{{.Viewer}}">{{.Viewer}}</option>
12 {{range .Orgs}}<option value="{{.}}"{{if eq . $.Owner}} selected{{end}}>{{.}}</option>{{end}}
13</select></label>
14<label>/ Name <input name="name" required maxlength="63" pattern="[a-z0-9][a-z0-9._\-]{0,62}" value="{{.Name}}" aria-describedby="forkhint"></label>
15<span class="hint" id="forkhint">Defaults to the source name; change it to fork twice into the same owner.</span></p>
16<p><button type="submit">Create fork</button></p>
17</form>
18{{end}}
internal/web/templates/layout.html +33 −11
@@ -14,22 +14,38 @@
14 14
15<nav class="rail" aria-label="Site"> 15<nav class="rail" aria-label="Site">
16 <a class="brand" href="/" aria-label="{{.Site}} home">{{template "mark"}}<span class="vh">{{.Site}}</span></a> 16 <a class="brand" href="/" aria-label="{{.Site}} home">{{template "mark"}}<span class="vh">{{.Site}}</span></a>
17 {{/* A square is .railopt when the phone rail drops it into the More
18 menu below. The menu repeats those destinations, so the two lists
19 are kept in step by hand: add a square there and add it here. */}}
17 <ul class="raillist"> 20 <ul class="raillist">
18 {{if .Viewer}}<li><a class="railicon" {{if eq (str . "Tab") "dashboard"}}aria-current="page" {{end}}href="/" aria-label="Dashboard" title="Dashboard">{{template "icon" "home"}}<span class="vh">Dashboard</span></a></li>{{end}} 21 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/" "Icon" "home" "Name" "Dashboard" "Current" (eq (str . "Tab") "dashboard")}}</li>{{end}}
19 <li><a class="railicon" {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore" aria-label="Explore" title="Explore">{{template "icon" "compass"}}<span class="vh">Explore</span></a></li> 22 <li class="railopt">{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li>
20 <li><a class="railicon" {{if eq (str . "Tab") "sitesearch"}}aria-current="page" {{end}}href="/search" aria-label="Search" title="Search">{{template "icon" "search"}}<span class="vh">Search</span></a></li> 23 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li>
21 {{if .Viewer}}<li><a class="railicon" {{if eq (str . "Tab") "notifications"}}aria-current="page" {{end}}href="/notifications" aria-label="Notifications" title="Notifications">{{template "icon" "bell"}}<span class="vh">Notifications</span>{{with .Rail.Unread}}<b class="count">{{.}}</b>{{end}}</a></li> 24 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li>
22 <li><a class="railicon" {{if eq (str . "Tab") "bookmarks"}}aria-current="page" {{end}}href="/bookmarks" aria-label="Bookmarks" title="Bookmarks">{{template "icon" "bookmark"}}<span class="vh">Bookmarks</span></a></li> 25 <li class="railopt">{{template "raillink" dict "Href" "/bookmarks" "Icon" "bookmark" "Name" "Bookmarks" "Current" (eq (str . "Tab") "bookmarks")}}</li>
23 <li><a class="railicon" href="/new" aria-label="New repository" title="New repository">{{template "icon" "plus"}}<span class="vh">New repository</span></a></li>{{end}} 26 <li class="railopt">{{template "raillink" dict "Href" (printf "/%s/-/snippets" .Viewer) "Icon" "snippet" "Name" "Snippets" "Current" (eq (str . "Tab") "snippets")}}</li>
27 <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}}
24 </ul> 28 </ul>
25 <span class="railgap"></span> 29 <span class="railgap"></span>
26 <ul class="raillist"> 30 <ul class="raillist">
27 {{if .Viewer}}<li><a class="railicon" {{if eq (str . "Tab") "account"}}aria-current="page" {{end}}href="/settings" aria-label="Settings" title="Settings">{{template "icon" "gear"}}<span class="vh">Settings</span></a></li>{{end}} 31 {{if .Viewer}}<li class="railopt">{{template "raillink" dict "Href" "/settings" "Icon" "gear" "Name" "Settings" "Current" (eq (str . "Tab") "account")}}</li>{{end}}
28 {{if .Admin}}<li><a class="railicon" {{if eq (str . "Tab") "admin"}}aria-current="page" {{end}}href="/admin" aria-label="Admin" title="Admin">{{template "icon" "shield"}}<span class="vh">Admin</span></a></li>{{end}} 32 {{if .Admin}}<li class="railopt">{{template "raillink" dict "Href" "/admin" "Icon" "shield" "Name" "Admin" "Current" (eq (str . "Tab") "admin")}}</li>{{end}}
29 </ul> 33 </ul>
30 <div class="railfoot"> 34 <div class="railfoot">
31 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}" aria-label="Your profile" title="{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span></a> 35 {{if .Viewer}}<details class="railmore">
32 <form method="post" action="/logout"><button type="submit" class="railicon" aria-label="Log out" title="Log out">{{template "icon" "signout"}}<span class="vh">Log out</span></button></form> 36 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary>
37 <div class="raildrop">
38 <a href="/explore">{{template "icon" "compass"}} Explore</a>
39 <a href="/bookmarks">{{template "icon" "bookmark"}} Bookmarks</a>
40 <a href="/{{.Viewer}}/-/snippets">{{template "icon" "snippet"}} Snippets</a>
41 <a href="/new">{{template "icon" "plus"}} New repository</a>
42 <a href="/settings">{{template "icon" "gear"}} Settings</a>
43 {{if .Admin}}<a href="/admin">{{template "icon" "shield"}} Admin</a>{{end}}
44 <form method="post" action="/logout"><button type="submit">{{template "icon" "signout"}} Log out</button></form>
45 </div>
46 </details>
47 <a class="railuser" href="/{{.Viewer}}" aria-label="Your profile" title="{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span></a>
48 <form class="railopt" method="post" action="/logout"><button type="submit" class="railicon" aria-label="Log out" title="Log out">{{template "icon" "signout"}}<span class="vh">Log out</span></button></form>
33 {{else}}<a class="railicon" href="/login" aria-label="Sign in" title="Sign in">{{template "icon" "person"}}<span class="vh">Sign in</span></a>{{end}} 49 {{else}}<a class="railicon" href="/login" aria-label="Sign in" title="Sign in">{{template "icon" "person"}}<span class="vh">Sign in</span></a>{{end}}
34 </div> 50 </div>
35</nav> 51</nav>
@@ -51,7 +67,7 @@
51 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}" title="Pinned repositories show on your dashboard"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form> 67 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}" title="Pinned repositories show on your dashboard"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form>
52 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}" title="Watching sends every issue, request and build to your inbox">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form> 68 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}" title="Watching sends every issue, request and build to your inbox">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>
53 <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}" title="Bookmarked lists it under Bookmarks">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form> 69 <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}" title="Bookmarked lists it under Bookmarks">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form>
54 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}} 70 <a class="button btn" href="/{{.OwnerName}}/{{.Name}}/fork">Fork</a>{{end}}
55 </div> 71 </div>
56 {{if eq $top "code"}}{{if field $ "Mirrors"}}<p class="repometa">{{range $i, $m := field $ "Mirrors"}}{{if $i}} · {{end}}{{if eq $m.Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{$m.URL}}" rel="nofollow">{{$m.Target}}</a>{{if $m.Error}}, <span class="bad">sync error: {{$m.Error}}</span>{{else if $m.Synced}}, synced {{$m.Synced}}{{end}}{{end}}</p>{{end}}{{end}} 72 {{if eq $top "code"}}{{if field $ "Mirrors"}}<p class="repometa">{{range $i, $m := field $ "Mirrors"}}{{if $i}} · {{end}}{{if eq $m.Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{$m.URL}}" rel="nofollow">{{$m.Target}}</a>{{if $m.Error}}, <span class="bad">sync error: {{$m.Error}}</span>{{else if $m.Synced}}, synced {{$m.Synced}}{{end}}{{end}}</p>{{end}}{{end}}
57 <nav class="tabs" aria-label="Repository"> 73 <nav class="tabs" aria-label="Repository">
@@ -93,9 +109,15 @@
93{{- else if eq . "gear"}}<path d="M6.63 1.13L9.37 1.13L9.51 3.02L10.45 3.41L11.89 2.18L13.82 4.11L12.59 5.55L12.98 6.49L14.87 6.63L14.87 9.37L12.98 9.51L12.59 10.45L13.82 11.89L11.89 13.82L10.45 12.59L9.51 12.98L9.37 14.87L6.63 14.87L6.49 12.98L5.55 12.59L4.11 13.82L2.18 11.89L3.41 10.45L3.02 9.51L1.13 9.37L1.13 6.63L3.02 6.49L3.41 5.55L2.18 4.11L4.11 2.18L5.55 3.41L6.49 3.02Z"/><circle cx="8" cy="8" r="2.2"/> 109{{- else if eq . "gear"}}<path d="M6.63 1.13L9.37 1.13L9.51 3.02L10.45 3.41L11.89 2.18L13.82 4.11L12.59 5.55L12.98 6.49L14.87 6.63L14.87 9.37L12.98 9.51L12.59 10.45L13.82 11.89L11.89 13.82L10.45 12.59L9.51 12.98L9.37 14.87L6.63 14.87L6.49 12.98L5.55 12.59L4.11 13.82L2.18 11.89L3.41 10.45L3.02 9.51L1.13 9.37L1.13 6.63L3.02 6.49L3.41 5.55L2.18 4.11L4.11 2.18L5.55 3.41L6.49 3.02Z"/><circle cx="8" cy="8" r="2.2"/>
94{{- else if eq . "shield"}}<path d="M8 1.75 13 3.5v4.25c0 3.1-2 5.4-5 6.5-3-1.1-5-3.4-5-6.5V3.5Z"/><path d="m5.9 7.75 1.6 1.6 2.85-3.1"/> 110{{- else if eq . "shield"}}<path d="M8 1.75 13 3.5v4.25c0 3.1-2 5.4-5 6.5-3-1.1-5-3.4-5-6.5V3.5Z"/><path d="m5.9 7.75 1.6 1.6 2.85-3.1"/>
95{{- else if eq . "signout"}}<path d="M6.5 2.25H3.25a1 1 0 0 0-1 1v9.5a1 1 0 0 0 1 1H6.5"/><path d="M7.75 8h6M11.25 5.5 13.75 8l-2.5 2.5"/> 111{{- else if eq . "signout"}}<path d="M6.5 2.25H3.25a1 1 0 0 0-1 1v9.5a1 1 0 0 0 1 1H6.5"/><path d="M7.75 8h6M11.25 5.5 13.75 8l-2.5 2.5"/>
112{{- else if eq . "ellipsis"}}<circle cx="3.25" cy="8" r="1.1" fill="currentColor" stroke="none"/><circle cx="8" cy="8" r="1.1" fill="currentColor" stroke="none"/><circle cx="12.75" cy="8" r="1.1" fill="currentColor" stroke="none"/>
113{{- else if eq . "snippet"}}<path d="M3.25 2.75h6L12.75 6v7.25a.5.5 0 0 1-.5.5H3.75a.5.5 0 0 1-.5-.5Z"/><path d="M9 2.75V6h3.75"/><path d="m6.75 8.75-1.5 1.5 1.5 1.5M9.75 8.75l1.5 1.5-1.5 1.5"/>
96{{- else if eq . "person"}}<circle cx="8" cy="5.5" r="2.75"/><path d="M2.75 14a5.25 5.25 0 0 1 10.5 0"/> 114{{- else if eq . "person"}}<circle cx="8" cy="5.5" r="2.75"/><path d="M2.75 14a5.25 5.25 0 0 1 10.5 0"/>
97{{- end}}</svg>{{end}} 115{{- end}}</svg>{{end}}
98 116
117{{/* raillink draws one square in the rail: the icon, the name for a
118 screen reader and in the tooltip, and the bell's unread badge. */}}
119{{define "raillink"}}<a class="railicon" {{if .Current}}aria-current="page" {{end}}href="{{.Href}}" aria-label="{{.Name}}" title="{{.Name}}">{{template "icon" .Icon}}<span class="vh">{{.Name}}</span>{{with .Count}}<b class="count">{{.}}</b>{{end}}</a>{{end}}
120
99{{define "mark"}}<svg class="mark" width="19" height="19" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="currentColor"/><rect x="7.5" y="18" width="9" height="3" fill="currentColor"/></svg>{{end}} 121{{define "mark"}}<svg class="mark" width="19" height="19" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="currentColor"/><rect x="7.5" y="18" width="9" height="3" fill="currentColor"/></svg>{{end}}
100{{define "width"}}reading{{end}} 122{{define "width"}}reading{{end}}
101 123
internal/web/templates/new.html +12 −1
@@ -1,7 +1,8 @@
1{{define "width"}}bounded{{end}} 1{{define "width"}}bounded{{end}}
2{{define "title"}}new repository · {{.Site}}{{end}} 2{{define "title"}}new repository · {{.Site}}{{end}}
3{{define "content"}} 3{{define "content"}}
4<h1>New repository</h1> 4<h1>New</h1>
5<h2>Repository</h2>
5{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} 6{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
6<form method="post" action="/new"> 7<form method="post" action="/new">
7<p><label>Owner <select name="owner"> 8<p><label>Owner <select name="owner">
@@ -19,4 +20,14 @@
19</fieldset> 20</fieldset>
20<p><button type="submit">Create repository</button></p> 21<p><button type="submit">Create repository</button></p>
21</form> 22</form>
23
24<h2 id="org">Organization</h2>
25<p class="meta">An organization owns repositories, labels and milestones, and
26grants access through teams. You are its first admin.</p>
27<form method="post" action="/new">
28<input type="hidden" name="field" value="org-create">
29<p><label>Name <input name="name" required maxlength="63" pattern="[a-z0-9][a-z0-9._\-]{0,62}" aria-describedby="orghint"></label>
30<span class="hint" id="orghint">The same rules as a repository name, and it becomes a top-level path.</span></p>
31<p><button type="submit">Create organization</button></p>
32</form>
22{{end}} 33{{end}}
internal/web/templates/owner.html +1 −15
@@ -26,25 +26,11 @@
26</ul> 26</ul>
27{{if or .Snippets .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets">snippets{{if .Snippets}} <span class="count">{{.Snippets}}</span>{{end}}</a></p>{{end}} 27{{if or .Snippets .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets">snippets{{if .Snippets}} <span class="count">{{.Snippets}}</span>{{end}}</a></p>{{end}}
28 28
29{{if .Self}}
30<h2>organizations</h2>
31{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
32<details class="editbox">
33 <summary>New organization</summary>
34 <form method="post" action="/{{.Owner}}" class="setform">
35 <input type="hidden" name="field" value="org-create">
36 <label for="orgname">Name</label>
37 <input type="text" id="orgname" name="name" required>
38 <button type="submit" class="btn">Create</button>
39 </form>
40</details>
41{{end}}
42
43{{if .CanAdmin}}{{$org := .Owner}} 29{{if .CanAdmin}}{{$org := .Owner}}
44<h2>people <span class="count">{{len .Members}}</span></h2> 30<h2>people <span class="count">{{len .Members}}</span></h2>
45{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 31{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
46<div class="tablewrap"><table class="keys"> 32<div class="tablewrap"><table class="keys">
47<tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"></th></tr> 33<tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"><span class="vh">actions</span></th></tr>
48{{range .Members}}<tr> 34{{range .Members}}<tr>
49 <td><a href="/{{.Name}}">{{.Name}}</a></td> 35 <td><a href="/{{.Name}}">{{.Name}}</a></td>
50 <td>{{.Role}}</td> 36 <td>{{.Role}}</td>
internal/web/web_test.go +31 −7
@@ -90,7 +90,7 @@ func TestWhenNamesTheZone(t *testing.T) {
90// a per-view define instead of a fixed one. 90// a per-view define instead of a fixed one.
91func TestMainWidthClass(t *testing.T) { 91func TestMainWidthClass(t *testing.T) {
92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true} 92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true}
93 bounded := map[string]bool{"landing.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "adminusers.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true} 93 bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "adminusers.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} 94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`}
95 for _, name := range Pages() { 95 for _, name := range Pages() {
96 src, err := TemplateSource(name) 96 src, err := TemplateSource(name)
@@ -137,9 +137,32 @@ func TestRailIconsAreLabelled(t *testing.T) {
137 glyph := func(s string) bool { 137 glyph := func(s string) bool {
138 return strings.Contains(s, `{{template "icon" `) || strings.Contains(s, "<svg") 138 return strings.Contains(s, `{{template "icon" `) || strings.Contains(s, "<svg")
139 } 139 }
140 tagRe := regexp.MustCompile(`(?s)<a class="railicon".*?</a>|<button [^>]*class="railicon".*?</button>`) 140 // Every square in the strip comes from the raillink partial, so the
141 // rule is checked once there and once for each control written out
142 // in full: the More summary, Log out and Sign in.
143 link := src[strings.Index(src, `{{define "raillink"}}`):]
144 link = link[:strings.Index(link, "\n")] // the partial is one line
145 for _, want := range []string{`aria-label="{{.Name}}"`, `<span class="vh">{{.Name}}</span>`} {
146 if !strings.Contains(link, want) {
147 t.Errorf("raillink partial missing %s", want)
148 }
149 }
150 if !strings.Contains(link, `{{template "icon" .Icon}}`) {
151 t.Error("raillink partial draws no glyph")
152 }
153 // A call site that names no icon or no name would render a square
154 // with neither, which the partial alone cannot catch.
155 for _, call := range regexp.MustCompile(`{{template "raillink" dict [^}]*}}`).FindAllString(src, -1) {
156 for _, want := range []string{`"Icon" `, `"Name" `} {
157 if !strings.Contains(call, want) {
158 t.Errorf("raillink call missing %s: %.90s", want, call)
159 }
160 }
161 }
162
163 tagRe := regexp.MustCompile(`(?s)<a class="railicon".*?</a>|<summary class="railicon".*?</summary>|<button [^>]*class="railicon".*?</button>`)
141 controls := tagRe.FindAllString(src, -1) 164 controls := tagRe.FindAllString(src, -1)
142 if len(controls) < 7 { 165 if len(controls) < 3 {
143 t.Fatalf("found %d railicon controls in layout.html, want the rail's full set", len(controls)) 166 t.Fatalf("found %d railicon controls in layout.html, want the rail's full set", len(controls))
144 } 167 }
145 for _, c := range controls { 168 for _, c := range controls {
@@ -153,13 +176,14 @@ func TestRailIconsAreLabelled(t *testing.T) {
153 } 176 }
154 } 177 }
155 178
156 // Every button in the rail's foot is an icon button under the same 179 // An icon button in the rail's foot is under the same rule: Log out
157 // rule: the Log out form's submit is the only one today. 180 // is the only one today. The More menu's own rows are not icon
181 // buttons — their visible text is their name.
158 foot := src[strings.Index(src, `<div class="railfoot">`):] 182 foot := src[strings.Index(src, `<div class="railfoot">`):]
159 foot = foot[:strings.Index(foot, "</nav>")] 183 foot = foot[:strings.Index(foot, "</nav>")]
160 buttons := regexp.MustCompile(`(?s)<button.*?</button>`).FindAllString(foot, -1) 184 buttons := regexp.MustCompile(`(?s)<button [^>]*class="railicon".*?</button>`).FindAllString(foot, -1)
161 if len(buttons) == 0 { 185 if len(buttons) == 0 {
162 t.Fatal("no button in the rail foot") 186 t.Fatal("no icon button in the rail foot")
163 } 187 }
164 for _, b := range buttons { 188 for _, b := range buttons {
165 for _, want := range []string{`aria-label="`, `<span class="vh">`} { 189 for _, want := range []string{`aria-label="`, `<span class="vh">`} {