tenseleyflow/shithub / 981e3af

Browse files

Align profile overview UI

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
981e3af457779a3819619f828a4c2eae3aec5401
Parents
ec17e0b
Tree
979d35f

6 changed files

StatusFile+-
M docs/internal/profile.md 24 2
M internal/web/handlers/profile/overview.go 2 0
M internal/web/handlers/profile/profile_test.go 27 1
M internal/web/static/css/shithub.css 518 0
M internal/web/templates/_profile_tabs.html 2 2
M internal/web/templates/profile/view.html 152 54
docs/internal/profile.mdmodified
@@ -1,6 +1,6 @@
1
-# User profile (read-only)
1
+# User profile
22
 
3
-S09 ships the public `/{username}` page and the `/avatars/{username}` route. Edit-profile UI lands in S10; pinned repos in S26; profile README and contribution graph post-MVP.
3
+S09 shipped the public `/{username}` page and the `/avatars/{username}` route. Later sprints added edit-profile UI, pinned repositories, a profile README card, and a local contribution calendar so the overview follows GitHub's profile layout.
44
 
55
 ## Routes
66
 
@@ -79,9 +79,31 @@ S10 will write the upload path, including resize-to-variants (`avatars/<owner>/<
7979
 The profile renders only fields the user explicitly set (or that the system computed publicly):
8080
 
8181
 - Username, display name, bio, location, company, website, pronouns, joined date, avatar.
82
+- Organizations the viewer can already discover through membership/profile navigation.
83
+- Public pinned repositories and visible repository activity.
8284
 - **Email addresses NEVER appear on the profile page.** Post-MVP opt-in only.
8385
 - Verified-email status is exposed via the API (`/api/v1/user`), not the public page.
8486
 
87
+## Profile README
88
+
89
+The overview looks for a visible repository owned by the user whose name matches the username, then renders a root-level `README*` file in a GitHub-style bordered card:
90
+
91
+- Markdown is rendered only through `internal/markdown.RenderDocumentHTML`.
92
+- Plain non-Markdown README files are HTML-escaped and shown in a `<pre>`.
93
+- Relative links and images are rewritten to the matching repository `blob` or `raw` route on the repository default branch.
94
+- The self-view edit pencil links to the actual repository default branch, not a hard-coded `trunk`.
95
+- Visibility is checked with `policy.IsVisibleTo`; private profile READMEs only render to actors who can already see that repository.
96
+
97
+## Contribution calendar
98
+
99
+The overview contribution calendar is computed from local Git history for repositories visible to the viewer:
100
+
101
+- The window is the last 365 days, rendered as a 53-week GitHub-style grid.
102
+- Only visible repositories are scanned, capped at 80 repos and 2,000 commits per repo for request-time safety.
103
+- When the user has verified email addresses, commits are counted only if the author email matches one of them.
104
+- If no verified email exists, shithub falls back to visible user-owned repository commits as a best-effort local signal.
105
+- Private repository contributions appear only when the viewer can already see those repositories.
106
+
85107
 ## Self-view enrichment
86108
 
87109
 When the viewer's session matches the profile's user (`viewer.ID == user.ID`):
internal/web/handlers/profile/overview.gomodified
@@ -43,6 +43,7 @@ type profileOrgBadge struct {
4343
 type profileReadme struct {
4444
 	Owner string
4545
 	Repo  string
46
+	Ref   string
4647
 	Path  string
4748
 	HTML  template.HTML
4849
 }
@@ -174,6 +175,7 @@ func (h *Handlers) profileReadme(ctx context.Context, user usersdb.User, viewer
174175
 		return profileReadme{
175176
 			Owner: user.Username,
176177
 			Repo:  repo.Name,
178
+			Ref:   repo.DefaultBranch,
177179
 			Path:  entry.Name,
178180
 			HTML:  template.HTML(html), //nolint:gosec // sanitized by markdown renderer or escaped above.
179181
 		}, true
internal/web/handlers/profile/profile_test.gomodified
@@ -45,7 +45,7 @@ func setupProfileEnvWithStore(t *testing.T, objectStore storage.ObjectStore) *pr
4545
 	tmplFS := fstest.MapFS{
4646
 		"_layout.html":           {Data: []byte(`{{ define "layout" }}<html><head><title>{{ .Title }}</title></head><body>{{ template "page" . }}</body></html>{{ end }}`)},
4747
 		"hello.html":             {Data: []byte(`{{ define "page" }}home{{ end }}`)},
48
-		"profile/view.html":      {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }} BIO={{.User.Bio}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)},
48
+		"profile/view.html":      {Data: []byte(`{{ define "page" }}USER={{.User.Username}} DISPLAY={{.User.DisplayName}}{{ if .IsSelf }} SELF=1{{ end }} BIO={{.User.Bio}} VISIBLE={{.VisibleRepoCount}} ORGS={{len .Orgs}} README={{.HasProfileReadme}} CONTRIB={{.Contributions.Total}} WEEKS={{len .Contributions.Weeks}} YEARS={{len .Contributions.Years}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)},
4949
 		"profile/suspended.html": {Data: []byte(`{{ define "page" }}SUSPENDED={{.Username}}{{ end }}`)},
5050
 		"orgs/profile.html":      {Data: []byte(`{{ define "page" }}ORG={{.Org.Slug}} REPOS={{len .Repos}} PINS={{len .PinnedRepos}} PINNAMES={{range .PinnedRepos}}{{.Name}};{{end}} CANDIDATES={{len .PinCandidates}} SELECTED={{range .PinCandidates}}{{if .IsPinned}}{{.Name}};{{end}}{{end}} MEMBERS={{.MemberCount}} PEOPLE={{len .People}} NAMES={{range .Repos}}{{.Name}};{{end}} LANGS={{range .TopLanguages}}{{.Name}}={{.Count}};{{end}} TOPICS={{range .TopTopics}}{{.Name}}={{.Count}};{{end}} VIEWAS={{.ViewAs}}{{ if .CanCustomizePins }} CUSTOMIZE=1{{ end }}{{ end }}`)},
5151
 		"orgs/repositories.html": {Data: []byte(`{{ define "page" }}ORGREPOS={{.Org.Slug}} ACTIVE={{.ActiveOrgNav}} TOTAL={{.RepoCount}} FILTERED={{.FilteredCount}} PAGE={{.Page}}/{{.PageCount}} TYPE={{.SelectedType}} LANG={{.SelectedLanguage}} SORT={{.SelectedSort}} PREV={{.PrevHref}} NEXT={{.NextHref}} NAMES={{range .Repos}}{{.Name}};{{end}}{{range .PaginationPages}} P{{.Number}}={{.Current}}{{end}}{{ end }}`)},
@@ -278,6 +278,32 @@ func TestProfile_RendersForExistingUser(t *testing.T) {
278278
 	}
279279
 }
280280
 
281
+func TestProfile_OverviewDataUsesVisibleReposAndOrganizations(t *testing.T) {
282
+	t.Parallel()
283
+	env := setupProfileEnv(t)
284
+	alice := env.insertUser(t, "alice", "Alice Anderson", "Hi.")
285
+	env.insertOrg(t, "acme", "Acme", "", alice)
286
+	env.insertUserRepo(t, alice.ID, "public-repo", "visible", "public", "Go", 0, 0)
287
+	env.insertUserRepo(t, alice.ID, "private-repo", "hidden", "private", "Rust", 0, 0)
288
+
289
+	body := env.getAs(t, "/alice", usersdb.User{})
290
+	for _, want := range []string{
291
+		"VISIBLE=1",
292
+		"ORGS=1",
293
+		"README=false",
294
+		"CONTRIB=0",
295
+		"WEEKS=53",
296
+		"YEARS=4",
297
+	} {
298
+		if !strings.Contains(body, want) {
299
+			t.Errorf("missing %q in body: %s", want, body)
300
+		}
301
+	}
302
+	if strings.Contains(body, "private-repo") {
303
+		t.Fatalf("anonymous profile overview leaked private repo data: %s", body)
304
+	}
305
+}
306
+
281307
 func TestProfile_UnknownUser404(t *testing.T) {
282308
 	t.Parallel()
283309
 	env := setupProfileEnv(t)
internal/web/static/css/shithub.cssmodified
@@ -973,6 +973,524 @@ code {
973973
 .shithub-empty { color: var(--fg-muted); font-style: italic; padding: 1rem; background: var(--canvas-subtle); border-radius: 6px; }
974974
 .shithub-profile-unavailable h1 { color: var(--fg-muted); }
975975
 
976
+/* GitHub-parity user profile overview. */
977
+.shithub-user-profile {
978
+  margin: 0;
979
+  padding: 0 0 3rem;
980
+}
981
+.shithub-profile-tabs-shell {
982
+  border-bottom: 1px solid var(--border-default);
983
+  background: var(--canvas-default);
984
+}
985
+.shithub-profile-tabs-shell .shithub-profile-tabs {
986
+  max-width: 1280px;
987
+  margin: 0 auto;
988
+  padding: 0 2rem;
989
+  border-bottom: 0;
990
+}
991
+.shithub-user-profile-container {
992
+  max-width: 1280px;
993
+  margin: 0 auto;
994
+  padding: 2rem;
995
+  display: grid;
996
+  grid-template-columns: 296px minmax(0, 1fr);
997
+  gap: 2rem;
998
+  align-items: start;
999
+}
1000
+.shithub-user-profile-sidebar {
1001
+  min-width: 0;
1002
+}
1003
+.shithub-profile-avatar-link {
1004
+  display: block;
1005
+  color: inherit;
1006
+}
1007
+.shithub-user-profile .shithub-profile-avatar {
1008
+  width: 100%;
1009
+  height: auto;
1010
+  aspect-ratio: 1;
1011
+  border-radius: 50%;
1012
+  display: block;
1013
+  object-fit: cover;
1014
+  background: var(--canvas-subtle);
1015
+  border: 1px solid var(--border-default);
1016
+  box-shadow: 0 0 0 1px rgba(255,255,255,0.02);
1017
+}
1018
+.shithub-profile-names {
1019
+  margin: 1rem 0 0.75rem;
1020
+}
1021
+.shithub-profile-names h1 {
1022
+  margin: 0;
1023
+  display: grid;
1024
+  gap: 0.1rem;
1025
+}
1026
+.shithub-profile-name {
1027
+  font-size: 1.5rem;
1028
+  line-height: 1.25;
1029
+  font-weight: 600;
1030
+  color: var(--fg-default);
1031
+  overflow-wrap: anywhere;
1032
+}
1033
+.shithub-profile-names .shithub-profile-handle {
1034
+  font-size: 1.25rem;
1035
+  line-height: 1.2;
1036
+  color: var(--fg-muted);
1037
+  font-weight: 300;
1038
+}
1039
+.shithub-user-profile .shithub-profile-pronouns {
1040
+  display: block;
1041
+  margin-top: 0.25rem;
1042
+  color: var(--fg-muted);
1043
+  font-size: 0.875rem;
1044
+}
1045
+.shithub-user-profile .shithub-profile-bio {
1046
+  margin: 0 0 1rem;
1047
+  color: var(--fg-default);
1048
+  font-size: 1rem;
1049
+  line-height: 1.5;
1050
+  white-space: pre-wrap;
1051
+}
1052
+.shithub-button-block {
1053
+  width: 100%;
1054
+  justify-content: center;
1055
+  text-align: center;
1056
+}
1057
+.shithub-profile-follow-counts {
1058
+  display: flex;
1059
+  align-items: center;
1060
+  flex-wrap: wrap;
1061
+  gap: 0.35rem;
1062
+  margin: 1rem 0;
1063
+  color: var(--fg-muted);
1064
+  font-size: 0.875rem;
1065
+}
1066
+.shithub-profile-follow-counts svg,
1067
+.shithub-profile-vcard svg {
1068
+  color: var(--fg-muted);
1069
+  flex: 0 0 auto;
1070
+}
1071
+.shithub-profile-follow-counts strong {
1072
+  color: var(--fg-default);
1073
+}
1074
+.shithub-profile-dot {
1075
+  color: var(--fg-muted);
1076
+}
1077
+.shithub-profile-vcard {
1078
+  list-style: none;
1079
+  margin: 0.75rem 0 0;
1080
+  padding: 0;
1081
+  display: grid;
1082
+  gap: 0.45rem;
1083
+  color: var(--fg-default);
1084
+  font-size: 0.875rem;
1085
+}
1086
+.shithub-profile-vcard li {
1087
+  display: grid;
1088
+  grid-template-columns: 16px minmax(0, 1fr);
1089
+  gap: 0.55rem;
1090
+  align-items: center;
1091
+  min-width: 0;
1092
+}
1093
+.shithub-profile-vcard span,
1094
+.shithub-profile-vcard a {
1095
+  overflow-wrap: anywhere;
1096
+}
1097
+.shithub-profile-sidebar-section {
1098
+  margin-top: 1.5rem;
1099
+  padding-top: 1.25rem;
1100
+  border-top: 1px solid var(--border-muted, var(--border-default));
1101
+}
1102
+.shithub-profile-sidebar-section h2 {
1103
+  margin: 0 0 0.75rem;
1104
+  font-size: 1rem;
1105
+  font-weight: 600;
1106
+}
1107
+.shithub-profile-orgs {
1108
+  display: flex;
1109
+  flex-wrap: wrap;
1110
+  gap: 0.35rem;
1111
+}
1112
+.shithub-profile-orgs a,
1113
+.shithub-profile-orgs img {
1114
+  display: block;
1115
+  width: 32px;
1116
+  height: 32px;
1117
+  border-radius: 6px;
1118
+}
1119
+.shithub-profile-orgs img {
1120
+  border: 1px solid var(--border-default);
1121
+  background: var(--canvas-subtle);
1122
+}
1123
+.shithub-user-profile-main {
1124
+  min-width: 0;
1125
+}
1126
+.shithub-profile-readme-card {
1127
+  border: 1px solid var(--border-default);
1128
+  border-radius: 6px;
1129
+  background: var(--canvas-default);
1130
+  margin-bottom: 1.5rem;
1131
+}
1132
+.shithub-profile-readme-card header {
1133
+  display: flex;
1134
+  align-items: flex-start;
1135
+  justify-content: space-between;
1136
+  gap: 1rem;
1137
+  padding: 1.5rem 1.5rem 0;
1138
+}
1139
+.shithub-profile-readme-title {
1140
+  font-family: ui-monospace, SFMono-Regular, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
1141
+  font-size: 0.8125rem;
1142
+  color: var(--fg-muted);
1143
+}
1144
+.shithub-profile-readme-title a,
1145
+.shithub-profile-readme-title strong {
1146
+  color: var(--fg-default);
1147
+  text-decoration: none;
1148
+  font-weight: 600;
1149
+}
1150
+.shithub-profile-readme-card article {
1151
+  padding: 1rem 1.5rem 1.5rem;
1152
+}
1153
+.shithub-icon-link {
1154
+  display: inline-flex;
1155
+  align-items: center;
1156
+  justify-content: center;
1157
+  color: var(--fg-muted);
1158
+  text-decoration: none;
1159
+  border-radius: 6px;
1160
+  padding: 0.25rem;
1161
+}
1162
+.shithub-icon-link:hover {
1163
+  color: var(--fg-default);
1164
+  background: var(--canvas-subtle);
1165
+  text-decoration: none;
1166
+}
1167
+.shithub-user-profile .shithub-profile-pinned,
1168
+.shithub-user-profile .shithub-profile-contributions {
1169
+  margin: 1.5rem 0 0;
1170
+}
1171
+.shithub-user-profile .shithub-profile-section-head {
1172
+  border-bottom: 0;
1173
+  padding-bottom: 0.75rem;
1174
+}
1175
+.shithub-user-profile .shithub-profile-section-head h2,
1176
+.shithub-user-profile .shithub-profile-contributions h2,
1177
+.shithub-profile-activity h2 {
1178
+  margin: 0;
1179
+  padding: 0;
1180
+  border: 0;
1181
+  font-size: 1rem;
1182
+  font-weight: 400;
1183
+}
1184
+.shithub-user-profile .shithub-profile-pinned-grid {
1185
+  margin-top: 0;
1186
+}
1187
+.shithub-user-profile .shithub-org-pin-card {
1188
+  min-height: 118px;
1189
+}
1190
+.shithub-profile-contrib-head {
1191
+  display: flex;
1192
+  align-items: center;
1193
+  justify-content: space-between;
1194
+  gap: 1rem;
1195
+  margin-bottom: 0.75rem;
1196
+}
1197
+.shithub-profile-contrib-settings {
1198
+  position: relative;
1199
+  color: var(--fg-muted);
1200
+  font-size: 0.875rem;
1201
+}
1202
+.shithub-profile-contrib-settings summary {
1203
+  list-style: none;
1204
+  cursor: pointer;
1205
+  color: var(--fg-muted);
1206
+}
1207
+.shithub-profile-contrib-settings summary::-webkit-details-marker {
1208
+  display: none;
1209
+}
1210
+.shithub-profile-contrib-settings summary svg {
1211
+  width: 12px;
1212
+  height: 12px;
1213
+  vertical-align: -2px;
1214
+}
1215
+.shithub-profile-contrib-settings[open] > div {
1216
+  position: absolute;
1217
+  z-index: 20;
1218
+  right: 0;
1219
+  top: calc(100% + 0.45rem);
1220
+  width: min(360px, calc(100vw - 2rem));
1221
+  padding: 1rem;
1222
+  border: 1px solid var(--border-default);
1223
+  border-radius: 12px;
1224
+  background: var(--canvas-overlay, var(--canvas-default));
1225
+  color: var(--fg-default);
1226
+  box-shadow: 0 16px 32px rgba(1,4,9,0.45);
1227
+}
1228
+.shithub-profile-contrib-settings strong {
1229
+  display: block;
1230
+  margin-bottom: 0.35rem;
1231
+}
1232
+.shithub-profile-contrib-settings p {
1233
+  margin: 0 0 1rem;
1234
+  color: var(--fg-muted);
1235
+  line-height: 1.45;
1236
+}
1237
+.shithub-profile-contrib-settings p:last-child {
1238
+  margin-bottom: 0;
1239
+}
1240
+.shithub-profile-contrib-layout {
1241
+  display: grid;
1242
+  grid-template-columns: minmax(0, 1fr) 112px;
1243
+  gap: 2rem;
1244
+  align-items: start;
1245
+}
1246
+.shithub-profile-calendar {
1247
+  border: 1px solid var(--border-default);
1248
+  border-radius: 6px;
1249
+  padding: 1rem;
1250
+  min-width: 0;
1251
+}
1252
+.shithub-contrib-months {
1253
+  display: grid;
1254
+  grid-template-columns: repeat(53, 13px);
1255
+  gap: 3px;
1256
+  margin-left: 32px;
1257
+  margin-bottom: 0.25rem;
1258
+  color: var(--fg-default);
1259
+  font-size: 0.75rem;
1260
+  line-height: 1;
1261
+}
1262
+.shithub-contrib-months span {
1263
+  min-height: 1rem;
1264
+  white-space: nowrap;
1265
+}
1266
+.shithub-contrib-grid-wrap {
1267
+  display: flex;
1268
+  gap: 0.5rem;
1269
+  min-width: 0;
1270
+}
1271
+.shithub-contrib-weekdays {
1272
+  display: grid;
1273
+  grid-template-rows: repeat(7, 10px);
1274
+  gap: 3px;
1275
+  width: 24px;
1276
+  color: var(--fg-default);
1277
+  font-size: 0.75rem;
1278
+  line-height: 10px;
1279
+}
1280
+.shithub-contrib-weeks {
1281
+  display: flex;
1282
+  gap: 3px;
1283
+  overflow-x: auto;
1284
+  padding-bottom: 0.25rem;
1285
+}
1286
+.shithub-contrib-week {
1287
+  display: grid;
1288
+  grid-template-rows: repeat(7, 10px);
1289
+  gap: 3px;
1290
+}
1291
+.shithub-contrib-day,
1292
+.shithub-contrib-legend i {
1293
+  width: 10px;
1294
+  height: 10px;
1295
+  display: block;
1296
+  border-radius: 2px;
1297
+  box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03);
1298
+}
1299
+.shithub-contrib-day.level-0,
1300
+.shithub-contrib-legend .level-0 {
1301
+  background: #161b22;
1302
+}
1303
+.shithub-contrib-day.level-1,
1304
+.shithub-contrib-legend .level-1 {
1305
+  background: #0e4429;
1306
+}
1307
+.shithub-contrib-day.level-2,
1308
+.shithub-contrib-legend .level-2 {
1309
+  background: #006d32;
1310
+}
1311
+.shithub-contrib-day.level-3,
1312
+.shithub-contrib-legend .level-3 {
1313
+  background: #26a641;
1314
+}
1315
+.shithub-contrib-day.level-4,
1316
+.shithub-contrib-legend .level-4 {
1317
+  background: #39d353;
1318
+}
1319
+.shithub-contrib-day.is-future,
1320
+.shithub-contrib-day.is-outside {
1321
+  opacity: 0.35;
1322
+}
1323
+.shithub-profile-calendar-foot {
1324
+  display: flex;
1325
+  justify-content: space-between;
1326
+  align-items: center;
1327
+  gap: 1rem;
1328
+  margin-top: 0.75rem;
1329
+  color: var(--fg-muted);
1330
+  font-size: 0.75rem;
1331
+}
1332
+.shithub-profile-calendar-foot a {
1333
+  color: var(--fg-muted);
1334
+  text-decoration: none;
1335
+}
1336
+.shithub-profile-calendar-foot a:hover {
1337
+  color: var(--accent-fg);
1338
+  text-decoration: underline;
1339
+}
1340
+.shithub-contrib-legend {
1341
+  display: inline-flex;
1342
+  align-items: center;
1343
+  gap: 0.25rem;
1344
+  white-space: nowrap;
1345
+}
1346
+.shithub-profile-years {
1347
+  list-style: none;
1348
+  margin: 0;
1349
+  padding: 0;
1350
+  display: grid;
1351
+  gap: 0.5rem;
1352
+}
1353
+.shithub-profile-years span {
1354
+  display: block;
1355
+  padding: 0.65rem 1rem;
1356
+  border-radius: 6px;
1357
+  color: var(--fg-muted);
1358
+}
1359
+.shithub-profile-years .is-active {
1360
+  color: #fff;
1361
+  background: var(--accent-emphasis);
1362
+  font-weight: 600;
1363
+}
1364
+.shithub-profile-activity {
1365
+  margin-top: 2rem;
1366
+}
1367
+.shithub-profile-activity h2 {
1368
+  margin-bottom: 1.5rem;
1369
+}
1370
+.shithub-profile-activity-row {
1371
+  display: grid;
1372
+  grid-template-columns: 96px minmax(0, 1fr);
1373
+  gap: 1.5rem;
1374
+  align-items: start;
1375
+  margin-bottom: 1.5rem;
1376
+}
1377
+.shithub-profile-activity-month {
1378
+  color: var(--fg-muted);
1379
+  font-weight: 600;
1380
+  font-size: 0.875rem;
1381
+  padding-top: 0.15rem;
1382
+}
1383
+.shithub-profile-activity-item {
1384
+  position: relative;
1385
+  display: flex;
1386
+  align-items: center;
1387
+  gap: 0.75rem;
1388
+  min-height: 2.25rem;
1389
+  color: var(--fg-default);
1390
+}
1391
+.shithub-profile-activity-item::before {
1392
+  content: "";
1393
+  position: absolute;
1394
+  left: 15px;
1395
+  top: -1.25rem;
1396
+  bottom: -1.25rem;
1397
+  width: 2px;
1398
+  background: var(--border-default);
1399
+}
1400
+.shithub-profile-activity-icon {
1401
+  position: relative;
1402
+  z-index: 1;
1403
+  width: 32px;
1404
+  height: 32px;
1405
+  display: inline-flex;
1406
+  align-items: center;
1407
+  justify-content: center;
1408
+  border-radius: 50%;
1409
+  border: 1px solid var(--border-default);
1410
+  background: var(--canvas-subtle);
1411
+  color: var(--fg-muted);
1412
+  flex: 0 0 auto;
1413
+}
1414
+@media (max-width: 980px) {
1415
+  .shithub-profile-tabs-shell .shithub-profile-tabs {
1416
+    padding: 0 1rem;
1417
+    overflow-x: auto;
1418
+  }
1419
+  .shithub-user-profile-container {
1420
+    grid-template-columns: 1fr;
1421
+    padding: 1.25rem 1rem 2rem;
1422
+    gap: 1.5rem;
1423
+  }
1424
+  .shithub-user-profile-sidebar {
1425
+    display: grid;
1426
+    grid-template-columns: 96px minmax(0, 1fr);
1427
+    gap: 1rem;
1428
+    align-items: start;
1429
+  }
1430
+  .shithub-profile-avatar-link {
1431
+    grid-row: span 4;
1432
+  }
1433
+  .shithub-profile-names {
1434
+    margin-top: 0;
1435
+  }
1436
+  .shithub-user-profile .shithub-profile-avatar {
1437
+    width: 96px;
1438
+  }
1439
+  .shithub-profile-vcard,
1440
+  .shithub-profile-sidebar-section {
1441
+    grid-column: 1 / -1;
1442
+  }
1443
+  .shithub-profile-contrib-layout {
1444
+    grid-template-columns: 1fr;
1445
+    gap: 1rem;
1446
+  }
1447
+  .shithub-profile-years {
1448
+    display: flex;
1449
+    overflow-x: auto;
1450
+  }
1451
+  .shithub-profile-years span {
1452
+    min-width: 5rem;
1453
+    text-align: center;
1454
+  }
1455
+}
1456
+@media (max-width: 700px) {
1457
+  .shithub-user-profile-sidebar {
1458
+    grid-template-columns: 72px minmax(0, 1fr);
1459
+  }
1460
+  .shithub-user-profile .shithub-profile-avatar {
1461
+    width: 72px;
1462
+  }
1463
+  .shithub-profile-name {
1464
+    font-size: 1.25rem;
1465
+  }
1466
+  .shithub-profile-names .shithub-profile-handle {
1467
+    font-size: 1rem;
1468
+  }
1469
+  .shithub-user-profile .shithub-profile-pinned-grid {
1470
+    grid-template-columns: 1fr;
1471
+  }
1472
+  .shithub-profile-readme-card header,
1473
+  .shithub-profile-readme-card article {
1474
+    padding-left: 1rem;
1475
+    padding-right: 1rem;
1476
+  }
1477
+  .shithub-profile-contrib-head,
1478
+  .shithub-profile-calendar-foot,
1479
+  .shithub-profile-activity-row {
1480
+    align-items: stretch;
1481
+    flex-direction: column;
1482
+    display: flex;
1483
+    gap: 0.75rem;
1484
+  }
1485
+  .shithub-profile-contrib-settings[open] > div {
1486
+    left: 0;
1487
+    right: auto;
1488
+  }
1489
+  .shithub-profile-activity-item::before {
1490
+    display: none;
1491
+  }
1492
+}
1493
+
9761494
 /* ----- settings shell (S10) ----- */
9771495
 .shithub-settings-page {
9781496
   max-width: 64rem;
internal/web/templates/_profile_tabs.htmlmodified
@@ -1,10 +1,10 @@
11
 {{ define "profile-tabs" -}}
22
 <nav class="shithub-profile-tabs" aria-label="Profile sections">
33
   <a href="/{{ .User.Username }}" class="shithub-profile-tab{{ if eq .ActiveTab "overview" }} is-active{{ end }}">
4
-    {{ octicon "shithub" }} Overview
4
+    {{ octicon "book" }} Overview
55
   </a>
66
   <a href="/{{ .User.Username }}?tab=repositories" class="shithub-profile-tab{{ if eq .ActiveTab "repositories" }} is-active{{ end }}">
7
-    {{ octicon "directory" }} Repositories
7
+    {{ octicon "repo" }} Repositories
88
     {{ with .Tabs.repositories }}<span class="shithub-tab-count">{{ . }}</span>{{ end }}
99
   </a>
1010
   <a href="/{{ .User.Username }}?tab=stars" class="shithub-profile-tab{{ if eq .ActiveTab "stars" }} is-active{{ end }}">
internal/web/templates/profile/view.htmlmodified
@@ -1,68 +1,166 @@
11
 {{ define "page" -}}
2
-<section class="shithub-profile">
3
-  <header class="shithub-profile-header">
4
-    <img class="shithub-profile-avatar" src="{{ .AvatarURL }}" alt="" width="200" height="200">
5
-    <div class="shithub-profile-id">
6
-      <h1 class="shithub-profile-name">
7
-        {{ if .User.DisplayName }}{{ .User.DisplayName }}{{ else }}{{ .User.Username }}{{ end }}
8
-        {{ if .IsSelf }}<span class="shithub-profile-you" title="this is you">you</span>{{ end }}
9
-      </h1>
10
-      <p class="shithub-profile-handle">@{{ .User.Username }}</p>
11
-      {{ if .User.Pronouns }}<p class="shithub-profile-pronouns">{{ .User.Pronouns }}</p>{{ end }}
2
+<section class="shithub-user-profile">
3
+  <div class="shithub-profile-tabs-shell">
4
+    {{ template "profile-tabs" . }}
5
+  </div>
6
+
7
+  <div class="shithub-user-profile-container">
8
+    <aside class="shithub-user-profile-sidebar" aria-label="{{ .User.Username }} profile">
9
+      <a class="shithub-profile-avatar-link" href="{{ .AvatarURL }}">
10
+        <img class="shithub-profile-avatar" src="{{ .AvatarURL }}" alt="@{{ .User.Username }}" width="296" height="296">
11
+      </a>
12
+      <div class="shithub-profile-names">
13
+        <h1>
14
+          <span class="shithub-profile-name">{{ .DisplayName }}</span>
15
+          <span class="shithub-profile-handle">{{ .User.Username }}</span>
16
+        </h1>
17
+        {{ if .User.Pronouns }}<span class="shithub-profile-pronouns">{{ .User.Pronouns }}</span>{{ end }}
18
+      </div>
19
+
20
+      {{ if .User.Bio }}<div class="shithub-profile-bio">{{ .User.Bio }}</div>{{ end }}
21
+
1222
       {{ if .IsSelf }}
13
-        <a href="/settings/profile" class="shithub-button">Edit profile</a>
23
+        <a href="/settings/profile" class="shithub-button shithub-button-block">Edit profile</a>
24
+      {{ else }}
25
+        <button type="button" class="shithub-button shithub-button-block" disabled>Follow</button>
1426
       {{ end }}
15
-    </div>
16
-  </header>
1727
 
18
-  {{ template "profile-tabs" . }}
28
+      <p class="shithub-profile-follow-counts">
29
+        {{ octicon "people" }}
30
+        <span><strong>0</strong> followers</span>
31
+        <span class="shithub-profile-dot" aria-hidden="true">·</span>
32
+        <span><strong>0</strong> following</span>
33
+      </p>
1934
 
20
-  {{ if .User.Bio }}<p class="shithub-profile-bio">{{ .User.Bio }}</p>{{ end }}
35
+      <ul class="shithub-profile-vcard">
36
+        {{ if .User.Company }}<li>{{ octicon "organization" }} <span>{{ .User.Company }}</span></li>{{ end }}
37
+        {{ if .User.Location }}<li>{{ octicon "location" }} <span>{{ .User.Location }}</span></li>{{ end }}
38
+        {{ if .WebsiteSafe }}<li>{{ octicon "link" }} <a href="{{ .WebsiteSafe }}" rel="nofollow noopener">{{ .User.Website }}</a></li>{{ end }}
39
+        <li>{{ octicon "calendar" }} <span>Joined {{ .JoinedFormatted }}</span></li>
40
+      </ul>
2141
 
22
-  <dl class="shithub-profile-meta">
23
-    {{ if .User.Company }}<dt>Company</dt><dd>{{ .User.Company }}</dd>{{ end }}
24
-    {{ if .User.Location }}<dt>Location</dt><dd>{{ .User.Location }}</dd>{{ end }}
25
-    {{ if .WebsiteSafe }}<dt>Website</dt><dd><a href="{{ .WebsiteSafe }}" rel="nofollow noopener">{{ .User.Website }}</a></dd>{{ end }}
26
-    <dt>Joined</dt><dd>{{ .JoinedFormatted }}</dd>
27
-  </dl>
42
+      {{ if .Orgs }}
43
+      <section class="shithub-profile-sidebar-section" aria-labelledby="profile-orgs-heading">
44
+        <h2 id="profile-orgs-heading">Organizations</h2>
45
+        <div class="shithub-profile-orgs">
46
+          {{ range .Orgs }}
47
+          <a href="/{{ .Slug }}" aria-label="{{ .DisplayName }}">
48
+            <img src="{{ .AvatarURL }}" alt="" width="32" height="32">
49
+          </a>
50
+          {{ end }}
51
+        </div>
52
+      </section>
53
+      {{ end }}
54
+    </aside>
2855
 
29
-  <section class="shithub-profile-pinned" id="pinned" aria-labelledby="pinned-h">
30
-    <div class="shithub-profile-section-head">
31
-      <h2 id="pinned-h">Pinned repositories</h2>
32
-      {{ if .CanCustomizePins }}<button type="button" class="shithub-link-button" data-pins-open>Customize pins</button>{{ end }}
33
-    </div>
34
-    {{ if .PinnedRepos }}
35
-    <ol class="shithub-profile-pinned-grid">
36
-      {{ range .PinnedRepos }}
37
-      <li class="shithub-org-pin-card">
38
-        <div class="shithub-org-pin-title">
39
-          <span class="shithub-org-pin-icon">{{ octicon "repo" }}</span>
40
-          <a href="/{{ .OwnerSlug }}/{{ .Name }}">{{ .Name }}</a>
41
-          {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">Private</span>{{ else }}<span class="shithub-pill">Public</span>{{ end }}
56
+    <main class="shithub-user-profile-main">
57
+      {{ if .HasProfileReadme }}
58
+      <section class="shithub-profile-readme-card markdown-body" aria-label="Profile README">
59
+        <header>
60
+          <div class="shithub-profile-readme-title">
61
+            <a href="/{{ .ProfileReadme.Owner }}/{{ .ProfileReadme.Repo }}">{{ .ProfileReadme.Owner }}</a>
62
+            <span>/</span>
63
+            <strong>{{ .ProfileReadme.Path }}</strong>
64
+          </div>
65
+          {{ if .IsSelf }}
66
+          <a class="shithub-icon-link" href="/{{ .ProfileReadme.Owner }}/{{ .ProfileReadme.Repo }}/edit/{{ .ProfileReadme.Ref }}/{{ .ProfileReadme.Path }}" aria-label="Edit profile README">{{ octicon "pencil" }}</a>
67
+          {{ end }}
68
+        </header>
69
+        <article>{{ .ProfileReadme.HTML }}</article>
70
+      </section>
71
+      {{ end }}
72
+
73
+      <section class="shithub-profile-pinned" id="pinned" aria-labelledby="pinned-h">
74
+        <div class="shithub-profile-section-head">
75
+          <h2 id="pinned-h">Pinned</h2>
76
+          {{ if .CanCustomizePins }}<button type="button" class="shithub-link-button" data-pins-open>Customize your pins</button>{{ end }}
4277
         </div>
43
-        {{ if .Description }}<p>{{ .Description }}</p>{{ else }}<p class="shithub-muted">No description provided.</p>{{ end }}
44
-        <div class="shithub-org-repo-meta">
45
-          {{ if .PrimaryLanguage }}<span><span class="shithub-language-dot" style="background-color: {{ .PrimaryLanguageColor }};"></span>{{ .PrimaryLanguage }}</span>{{ end }}
46
-          <span>{{ octicon "star" }} {{ .StarCount }}</span>
47
-          <span>{{ octicon "repo-forked" }} {{ .ForkCount }}</span>
78
+        {{ if .PinnedRepos }}
79
+        <ol class="shithub-profile-pinned-grid">
80
+          {{ range .PinnedRepos }}
81
+          <li class="shithub-org-pin-card">
82
+            <div class="shithub-org-pin-title">
83
+              <span class="shithub-org-pin-icon">{{ octicon "repo" }}</span>
84
+              <a href="/{{ .OwnerSlug }}/{{ .Name }}">{{ if ne .OwnerSlug $.User.Username }}{{ .OwnerSlug }}/{{ end }}{{ .Name }}</a>
85
+              {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">Private</span>{{ else }}<span class="shithub-pill">Public</span>{{ end }}
86
+            </div>
87
+            {{ if .Description }}<p>{{ .Description }}</p>{{ else }}<p class="shithub-muted">No description provided.</p>{{ end }}
88
+            <div class="shithub-org-repo-meta">
89
+              {{ if .PrimaryLanguage }}<span><span class="shithub-language-dot" style="background-color: {{ .PrimaryLanguageColor }};"></span>{{ .PrimaryLanguage }}</span>{{ end }}
90
+              {{ if .StarCount }}<span>{{ octicon "star" }} {{ .StarCount }}</span>{{ end }}
91
+              {{ if .ForkCount }}<span>{{ octicon "repo-forked" }} {{ .ForkCount }}</span>{{ end }}
92
+            </div>
93
+          </li>
94
+          {{ end }}
95
+        </ol>
96
+        {{ else }}
97
+        <p class="shithub-empty">Nothing pinned yet.</p>
98
+        {{ end }}
99
+      </section>
100
+
101
+      <section class="shithub-profile-contributions" aria-labelledby="contrib-h">
102
+        <div class="shithub-profile-contrib-head">
103
+          <h2 id="contrib-h">{{ .Contributions.Total }} contribution{{ pluralize .Contributions.Total "" "s" }} in the last year</h2>
104
+          <details class="shithub-profile-contrib-settings">
105
+            <summary>Contribution settings {{ octicon "triangle-down" }}</summary>
106
+            <div>
107
+              <strong>Private contributions</strong>
108
+              <p>Private contribution counts are shown only when those repositories are visible to you.</p>
109
+              <strong>Activity overview</strong>
110
+              <p>Activity is summarized from visible repositories on this shithub instance.</p>
111
+            </div>
112
+          </details>
48113
         </div>
49
-      </li>
50
-      {{ end }}
51
-    </ol>
52
-    {{ else }}
53
-    <p class="shithub-empty">Nothing pinned yet.</p>
54
-    {{ end }}
55
-  </section>
56114
 
57
-  <section class="shithub-profile-contributions" aria-labelledby="contrib-h">
58
-    <h2 id="contrib-h">Contributions</h2>
59
-    <p class="shithub-empty">Contribution graph coming soon.</p>
60
-  </section>
115
+        <div class="shithub-profile-contrib-layout">
116
+          <div class="shithub-profile-calendar" role="img" aria-label="{{ .Contributions.Total }} contributions in the last year">
117
+            <div class="shithub-contrib-months" aria-hidden="true">
118
+              {{ range .Contributions.Weeks }}<span>{{ .MonthLabel }}</span>{{ end }}
119
+            </div>
120
+            <div class="shithub-contrib-grid-wrap">
121
+              <div class="shithub-contrib-weekdays" aria-hidden="true">
122
+                <span></span><span>Mon</span><span></span><span>Wed</span><span></span><span>Fri</span><span></span>
123
+              </div>
124
+              <div class="shithub-contrib-weeks">
125
+                {{ range .Contributions.Weeks }}
126
+                <div class="shithub-contrib-week">
127
+                  {{ range .Days }}
128
+                  <span class="shithub-contrib-day level-{{ .Level }}{{ if .IsFuture }} is-future{{ end }}{{ if not .IsInWindow }} is-outside{{ end }}" title="{{ .Title }}" aria-label="{{ .Title }}"></span>
129
+                  {{ end }}
130
+                </div>
131
+                {{ end }}
132
+              </div>
133
+            </div>
134
+            <div class="shithub-profile-calendar-foot">
135
+              <a href="/docs">Learn how we count contributions</a>
136
+              <span class="shithub-contrib-legend">Less <i class="level-0"></i><i class="level-1"></i><i class="level-2"></i><i class="level-3"></i><i class="level-4"></i> More</span>
137
+            </div>
138
+          </div>
139
+          <ol class="shithub-profile-years" aria-label="Contribution years">
140
+            {{ range .Contributions.Years }}
141
+            <li><span class="{{ if eq . $.Contributions.CurrentYear }}is-active{{ end }}">{{ . }}</span></li>
142
+            {{ end }}
143
+          </ol>
144
+        </div>
145
+      </section>
61146
 
62
-  <section class="shithub-profile-readme" aria-labelledby="readme-h">
63
-    <h2 id="readme-h">Profile README</h2>
64
-    <p class="shithub-empty">No profile README yet.</p>
65
-  </section>
147
+      <section class="shithub-profile-activity" aria-labelledby="activity-h">
148
+        <h2 id="activity-h">Contribution activity</h2>
149
+        <div class="shithub-profile-activity-row">
150
+          <div class="shithub-profile-activity-month">{{ .Contributions.MonthLabel }}</div>
151
+          <div class="shithub-profile-activity-item">
152
+            <span class="shithub-profile-activity-icon">{{ octicon "repo" }}</span>
153
+            {{ if .Contributions.MonthCommitCount }}
154
+            <strong>Created {{ .Contributions.MonthCommitCount }} commit{{ pluralize .Contributions.MonthCommitCount "" "s" }} in {{ .Contributions.MonthRepoCount }} repositor{{ pluralize .Contributions.MonthRepoCount "y" "ies" }}</strong>
155
+            {{ else }}
156
+            <strong>No public activity yet this month</strong>
157
+            {{ end }}
158
+          </div>
159
+        </div>
160
+        <button type="button" class="shithub-button shithub-button-block" disabled>Show more activity</button>
161
+      </section>
162
+    </main>
163
+  </div>
66164
 </section>
67165
 {{ template "pins-modal" . }}
68166
 {{- end }}