fix(ui): themed toggles and radios, profile READMEs, tighter repo header
318df4d
Browse files
- style input.switch and radios as custom theme-aware controls (repo settings, release form, new-repo visibility) - render the <user>/<user> repository README on the profile page - size the repo avatar to the title line so it stays on the name row and no longer inflates with the expanded description - symmetric repo banner padding (top gap == bottom gap in all states)
R
rin
·
2026-10-16 18:54 UTC
7 files changed
+261
-13
assets/input.css
+89
-1
| ... | ... | @@ -598,6 +598,86 @@ html[data-sidebar="collapsed"] .sidebar-inner { |
| 598 | 598 | .switch input:checked ~ .switch-thumb { |
| 599 | 599 | transform: translateX(0.95rem); |
| 600 | 600 | } |
| 601 | +/* Toggle applied straight to the <input> (repo settings, release form) */ | |
| 602 | +input.switch { | |
| 603 | + appearance: none; | |
| 604 | + -webkit-appearance: none; | |
| 605 | + margin: 0; | |
| 606 | + padding: 0; | |
| 607 | + transition: background-color 0.15s ease, border-color 0.15s ease; | |
| 608 | +} | |
| 609 | +input.switch::after { | |
| 610 | + content: ""; | |
| 611 | + position: absolute; | |
| 612 | + top: 50%; | |
| 613 | + left: 0.16rem; | |
| 614 | + width: 0.85rem; | |
| 615 | + height: 0.85rem; | |
| 616 | + border-radius: 9999px; | |
| 617 | + background-color: hsl(var(--muted-foreground)); | |
| 618 | + transform: translateY(-50%); | |
| 619 | + transition: left 0.15s ease, background-color 0.15s ease; | |
| 620 | +} | |
| 621 | +input.switch:checked { | |
| 622 | + background-color: hsl(var(--primary)); | |
| 623 | + border-color: hsl(var(--primary)); | |
| 624 | +} | |
| 625 | +input.switch:checked::after { | |
| 626 | + left: calc(100% - 0.85rem - 0.16rem); | |
| 627 | + background-color: hsl(var(--primary-foreground)); | |
| 628 | +} | |
| 629 | +input.switch:focus-visible { | |
| 630 | + outline: 2px solid hsl(var(--ring) / 0.4); | |
| 631 | + outline-offset: 2px; | |
| 632 | +} | |
| 633 | +input.switch:disabled { | |
| 634 | + opacity: 0.5; | |
| 635 | + cursor: not-allowed; | |
| 636 | +} | |
| 637 | +input.switch:disabled::after { | |
| 638 | + opacity: 0.7; | |
| 639 | +} | |
| 640 | + | |
| 641 | +/* Radio */ | |
| 642 | +input[type="radio"] { | |
| 643 | + appearance: none; | |
| 644 | + -webkit-appearance: none; | |
| 645 | + position: relative; | |
| 646 | + display: inline-block; | |
| 647 | + width: 0.95rem; | |
| 648 | + height: 0.95rem; | |
| 649 | + margin: 0; | |
| 650 | + flex-shrink: 0; | |
| 651 | + cursor: pointer; | |
| 652 | + border: 1px solid hsl(var(--input)); | |
| 653 | + border-radius: 9999px; | |
| 654 | + background-color: hsl(var(--background)); | |
| 655 | + vertical-align: middle; | |
| 656 | + transition: border-color 0.15s ease, background-color 0.15s ease; | |
| 657 | +} | |
| 658 | +input[type="radio"]::after { | |
| 659 | + content: ""; | |
| 660 | + position: absolute; | |
| 661 | + inset: 0; | |
| 662 | + margin: auto; | |
| 663 | + width: 0.42rem; | |
| 664 | + height: 0.42rem; | |
| 665 | + border-radius: 9999px; | |
| 666 | + background-color: hsl(var(--background)); | |
| 667 | + transform: scale(0); | |
| 668 | + transition: transform 0.12s ease; | |
| 669 | +} | |
| 670 | +input[type="radio"]:checked { | |
| 671 | + border-color: hsl(var(--foreground)); | |
| 672 | + background-color: hsl(var(--foreground)); | |
| 673 | +} | |
| 674 | +input[type="radio"]:checked::after { | |
| 675 | + transform: scale(1); | |
| 676 | +} | |
| 677 | +input[type="radio"]:focus-visible { | |
| 678 | + outline: 2px solid hsl(var(--ring) / 0.4); | |
| 679 | + outline-offset: 2px; | |
| 680 | +} | |
| 601 | 681 | |
| 602 | 682 | /* Cards */ |
| 603 | 683 | .card { |
| ... | ... | @@ -1290,7 +1370,7 @@ kbd, .kbd { |
| 1290 | 1370 | |
| 1291 | 1371 | .repo-banner-main { |
| 1292 | 1372 | display: flex; |
| 1293 | - align-items: center; | |
| 1373 | + align-items: flex-start; | |
| 1294 | 1374 | gap: 0.7rem; |
| 1295 | 1375 | min-width: 0; |
| 1296 | 1376 | flex: 1 1 20rem; |
| ... | ... | @@ -1337,7 +1417,15 @@ kbd, .kbd { |
| 1337 | 1417 | overflow: visible; |
| 1338 | 1418 | overflow-wrap: anywhere; |
| 1339 | 1419 | } |
| 1420 | +.repo-avatar-link { | |
| 1421 | + display: flex; | |
| 1422 | + align-items: center; | |
| 1423 | + flex-shrink: 0; | |
| 1424 | +} | |
| 1340 | 1425 | .repo-avatar { |
| 1426 | + width: 1.65rem; | |
| 1427 | + height: 1.65rem; | |
| 1428 | + font-size: 0.68rem; | |
| 1341 | 1429 | transition: width 0.2s ease, height 0.2s ease, font-size 0.2s ease; |
| 1342 | 1430 | } |
| 1343 | 1431 |
internal/web/handlers/profile_readme_test.go
+49
-0
| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "net/http" | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// TestProfileReadme checks that a repository named after its owner | |
| 11 | +// (<user>/<user>) has its README rendered on the profile page, GitHub style. | |
| 12 | +func TestProfileReadme(t *testing.T) { | |
| 13 | + app, _, userSvc, repoSvc := setupTestApp(t) | |
| 14 | + ctx := context.Background() | |
| 15 | + | |
| 16 | + alice, err := userSvc.Register(ctx, "alice", "alice@example.com", "strongpassword", true) | |
| 17 | + if err != nil { | |
| 18 | + t.Fatalf("register alice: %v", err) | |
| 19 | + } | |
| 20 | + if _, err := repoSvc.Create(ctx, alice, "alice", "Profile repo", false); err != nil { | |
| 21 | + t.Fatalf("create profile repo: %v", err) | |
| 22 | + } | |
| 23 | + path, _ := repoSvc.DiskPath("alice", "alice") | |
| 24 | + pushCommit(t, path, "main", "README.md", "# hi alice\n\nProfile **readme** body\n") | |
| 25 | + | |
| 26 | + status, body := getPage(t, app, "/alice") | |
| 27 | + if status != http.StatusOK { | |
| 28 | + t.Fatalf("profile status %d", status) | |
| 29 | + } | |
| 30 | + if !strings.Contains(body, "Profile <strong>readme</strong> body") { | |
| 31 | + t.Errorf("profile page missing rendered <user>/<user> README") | |
| 32 | + } | |
| 33 | + | |
| 34 | + // A user without a <user>/<user> repository gets no README card. | |
| 35 | + bob, err := userSvc.Register(ctx, "bob", "bob@example.com", "strongpassword", true) | |
| 36 | + if err != nil { | |
| 37 | + t.Fatalf("register bob: %v", err) | |
| 38 | + } | |
| 39 | + if _, err := repoSvc.Create(ctx, bob, "engine", "Not a profile repo", false); err != nil { | |
| 40 | + t.Fatalf("create bob repo: %v", err) | |
| 41 | + } | |
| 42 | + status, body = getPage(t, app, "/bob") | |
| 43 | + if status != http.StatusOK { | |
| 44 | + t.Fatalf("bob profile status %d", status) | |
| 45 | + } | |
| 46 | + if strings.Contains(body, "Profile <strong>readme</strong> body") { | |
| 47 | + t.Errorf("bob profile must not render alice's README") | |
| 48 | + } | |
| 49 | +} |
internal/web/handlers/repo.go
+13
-0
| ... | ... | @@ -425,6 +425,19 @@ func (h *Handler) UserProfilePage(c *fiber.Ctx) error { |
| 425 | 425 | data["PublicRepoCount"] = publicCount |
| 426 | 426 | data["PrivateRepoCount"] = privateCount |
| 427 | 427 | data["ShowPrivateCount"] = includePrivate |
| 428 | + | |
| 429 | + // GitHub-style profile README: a repository named after its owner | |
| 430 | + // (<user>/<user>) has its README rendered on the profile page. | |
| 431 | + if profileRepo, err := h.repoSvc.GetByOwnerAndName(c.UserContext(), profileUser.Username, profileUser.Username); err == nil && profileRepo != nil { | |
| 432 | + if !profileRepo.IsPrivate || includePrivate { | |
| 433 | + if diskPath, err := h.repoSvc.DiskPath(profileUser.Username, profileUser.Username); err == nil { | |
| 434 | + if blob, err := git.FindReadme(c.UserContext(), diskPath, profileRepo.DefaultBranch, ""); err == nil && blob != nil && strings.TrimSpace(string(blob.Data)) != "" { | |
| 435 | + data["ProfileReadmeHTML"] = views.RenderMarkdown(blob.Data) | |
| 436 | + } | |
| 437 | + } | |
| 438 | + } | |
| 439 | + } | |
| 440 | + | |
| 428 | 441 | return h.render(c, fiber.StatusOK, "profile", data) |
| 429 | 442 | } |
| 430 | 443 |
internal/web/static/css/app.css
+89
-1
| ... | ... | @@ -598,6 +598,86 @@ html[data-sidebar="collapsed"] .sidebar-inner { |
| 598 | 598 | .switch input:checked ~ .switch-thumb { |
| 599 | 599 | transform: translateX(0.95rem); |
| 600 | 600 | } |
| 601 | +/* Toggle applied straight to the <input> (repo settings, release form) */ | |
| 602 | +input.switch { | |
| 603 | + appearance: none; | |
| 604 | + -webkit-appearance: none; | |
| 605 | + margin: 0; | |
| 606 | + padding: 0; | |
| 607 | + transition: background-color 0.15s ease, border-color 0.15s ease; | |
| 608 | +} | |
| 609 | +input.switch::after { | |
| 610 | + content: ""; | |
| 611 | + position: absolute; | |
| 612 | + top: 50%; | |
| 613 | + left: 0.16rem; | |
| 614 | + width: 0.85rem; | |
| 615 | + height: 0.85rem; | |
| 616 | + border-radius: 9999px; | |
| 617 | + background-color: hsl(var(--muted-foreground)); | |
| 618 | + transform: translateY(-50%); | |
| 619 | + transition: left 0.15s ease, background-color 0.15s ease; | |
| 620 | +} | |
| 621 | +input.switch:checked { | |
| 622 | + background-color: hsl(var(--primary)); | |
| 623 | + border-color: hsl(var(--primary)); | |
| 624 | +} | |
| 625 | +input.switch:checked::after { | |
| 626 | + left: calc(100% - 0.85rem - 0.16rem); | |
| 627 | + background-color: hsl(var(--primary-foreground)); | |
| 628 | +} | |
| 629 | +input.switch:focus-visible { | |
| 630 | + outline: 2px solid hsl(var(--ring) / 0.4); | |
| 631 | + outline-offset: 2px; | |
| 632 | +} | |
| 633 | +input.switch:disabled { | |
| 634 | + opacity: 0.5; | |
| 635 | + cursor: not-allowed; | |
| 636 | +} | |
| 637 | +input.switch:disabled::after { | |
| 638 | + opacity: 0.7; | |
| 639 | +} | |
| 640 | + | |
| 641 | +/* Radio */ | |
| 642 | +input[type="radio"] { | |
| 643 | + appearance: none; | |
| 644 | + -webkit-appearance: none; | |
| 645 | + position: relative; | |
| 646 | + display: inline-block; | |
| 647 | + width: 0.95rem; | |
| 648 | + height: 0.95rem; | |
| 649 | + margin: 0; | |
| 650 | + flex-shrink: 0; | |
| 651 | + cursor: pointer; | |
| 652 | + border: 1px solid hsl(var(--input)); | |
| 653 | + border-radius: 9999px; | |
| 654 | + background-color: hsl(var(--background)); | |
| 655 | + vertical-align: middle; | |
| 656 | + transition: border-color 0.15s ease, background-color 0.15s ease; | |
| 657 | +} | |
| 658 | +input[type="radio"]::after { | |
| 659 | + content: ""; | |
| 660 | + position: absolute; | |
| 661 | + inset: 0; | |
| 662 | + margin: auto; | |
| 663 | + width: 0.42rem; | |
| 664 | + height: 0.42rem; | |
| 665 | + border-radius: 9999px; | |
| 666 | + background-color: hsl(var(--background)); | |
| 667 | + transform: scale(0); | |
| 668 | + transition: transform 0.12s ease; | |
| 669 | +} | |
| 670 | +input[type="radio"]:checked { | |
| 671 | + border-color: hsl(var(--foreground)); | |
| 672 | + background-color: hsl(var(--foreground)); | |
| 673 | +} | |
| 674 | +input[type="radio"]:checked::after { | |
| 675 | + transform: scale(1); | |
| 676 | +} | |
| 677 | +input[type="radio"]:focus-visible { | |
| 678 | + outline: 2px solid hsl(var(--ring) / 0.4); | |
| 679 | + outline-offset: 2px; | |
| 680 | +} | |
| 601 | 681 | |
| 602 | 682 | /* Cards */ |
| 603 | 683 | .card { |
| ... | ... | @@ -1290,7 +1370,7 @@ kbd, .kbd { |
| 1290 | 1370 | |
| 1291 | 1371 | .repo-banner-main { |
| 1292 | 1372 | display: flex; |
| 1293 | - align-items: center; | |
| 1373 | + align-items: flex-start; | |
| 1294 | 1374 | gap: 0.7rem; |
| 1295 | 1375 | min-width: 0; |
| 1296 | 1376 | flex: 1 1 20rem; |
| ... | ... | @@ -1337,7 +1417,15 @@ kbd, .kbd { |
| 1337 | 1417 | overflow: visible; |
| 1338 | 1418 | overflow-wrap: anywhere; |
| 1339 | 1419 | } |
| 1420 | +.repo-avatar-link { | |
| 1421 | + display: flex; | |
| 1422 | + align-items: center; | |
| 1423 | + flex-shrink: 0; | |
| 1424 | +} | |
| 1340 | 1425 | .repo-avatar { |
| 1426 | + width: 1.65rem; | |
| 1427 | + height: 1.65rem; | |
| 1428 | + font-size: 0.68rem; | |
| 1341 | 1429 | transition: width 0.2s ease, height 0.2s ease, font-size 0.2s ease; |
| 1342 | 1430 | } |
| 1343 | 1431 |
internal/web/static/js/app.js
+11
-10
| ... | ... | @@ -465,23 +465,24 @@ |
| 465 | 465 | }); |
| 466 | 466 | |
| 467 | 467 | // Repo header: the description stays on the title line and expands onto its |
| 468 | - // own line when clicked. The avatar grows to match the name + description | |
| 469 | - // block instead of staying centered. | |
| 468 | + // own line when clicked. The avatar is sized to the title line so it always | |
| 469 | + // sits on the same row as the repository name, at a modest size. | |
| 470 | 470 | var repoAvatarTimer = null; |
| 471 | 471 | |
| 472 | 472 | function sizeRepoAvatars() { |
| 473 | 473 | var changed = false; |
| 474 | 474 | document.querySelectorAll('.repo-banner').forEach(function (banner) { |
| 475 | 475 | var avatar = banner.querySelector('.repo-avatar'); |
| 476 | - var block = banner.querySelector('.repo-head-block'); | |
| 477 | - if (!avatar || !block) return; | |
| 478 | - var h = Math.round(block.getBoundingClientRect().height); | |
| 476 | + var title = banner.querySelector('.repo-head-row nav'); | |
| 477 | + if (!avatar || !title) return; | |
| 478 | + var h = Math.round(title.getBoundingClientRect().height); | |
| 479 | 479 | if (h <= 0) return; |
| 480 | - var size = h + 'px'; | |
| 481 | - if (avatar.style.width === size) return; | |
| 482 | - avatar.style.width = size; | |
| 483 | - avatar.style.height = size; | |
| 484 | - avatar.style.fontSize = Math.min(22, Math.max(9, Math.round(h * 0.4))) + 'px'; | |
| 480 | + var size = Math.min(30, Math.max(18, h)); | |
| 481 | + var sizePx = size + 'px'; | |
| 482 | + if (avatar.style.width === sizePx) return; | |
| 483 | + avatar.style.width = sizePx; | |
| 484 | + avatar.style.height = sizePx; | |
| 485 | + avatar.style.fontSize = Math.min(20, Math.max(9, Math.round(size * 0.42))) + 'px'; | |
| 485 | 486 | changed = true; |
| 486 | 487 | }); |
| 487 | 488 | return changed; |
internal/web/views/pages/profile.html
+9
-0
| ... | ... | @@ -43,6 +43,15 @@ |
| 43 | 43 | </div> |
| 44 | 44 | </section> |
| 45 | 45 | |
| 46 | + {{if .ProfileReadmeHTML}} | |
| 47 | + <!-- Profile README from the <user>/<user> repository --> | |
| 48 | + <section class="card mb-4"> | |
| 49 | + <div class="card-body markdown-body"> | |
| 50 | + {{.ProfileReadmeHTML}} | |
| 51 | + </div> | |
| 52 | + </section> | |
| 53 | + {{end}} | |
| 54 | + | |
| 46 | 55 | <!-- Repositories --> |
| 47 | 56 | <div class="flex items-center justify-between flex-wrap gap-2 mb-3"> |
| 48 | 57 | <h2 class="text-base font-semibold" style="margin:0;"> |
internal/web/views/partials/components.html
+1
-1
| ... | ... | @@ -45,7 +45,7 @@ |
| 45 | 45 | {{define "repo_header"}} |
| 46 | 46 | <div class="repo-banner mb-3{{if .Repo.ForkedFromRepoID}} has-subrow{{end}}"> |
| 47 | 47 | <div class="repo-banner-main"> |
| 48 | - <a href="/{{.Repo.OwnerUsername}}" class="flex-shrink-0">{{avatar .Repo.OwnerUsername .Repo.OwnerAvatarURL "repo-avatar"}}</a> | |
| 48 | + <a href="/{{.Repo.OwnerUsername}}" class="repo-avatar-link">{{avatar .Repo.OwnerUsername .Repo.OwnerAvatarURL "repo-avatar"}}</a> | |
| 49 | 49 | <div class="min-w-0 repo-head-block"> |
| 50 | 50 | <div class="repo-head-row"> |
| 51 | 51 | <nav class="breadcrumb" aria-label="Repository breadcrumb"> |