fabrica

hanna/fabrica

feat(web): two-column profile, markdown prose, and repo-view polish

094742f · hanna committed on 2026-07-25

Rebuild the profile as two columns — an info sidebar (avatar, name, handle,
pronouns, markdown bio, links, join date) beside filterable repositories.
Render bios and READMEs through a shared .markdown style with clear fenced
code blocks and inline-code chips. Replace the sparse file-tree table with a
bordered card of rows (dirs first, hover, folder-accent icons), move the
clone URL to the repo overview only, and give the repo tabs icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
9 files changed · +313 −96UnifiedSplit
assets/base.css +176 −32
@@ -456,48 +456,73 @@ select:focus {
456456 height: auto;
457457 border-radius: 10px;
458458 }
459-.profile-head {
459+/* Two-column profile: info sidebar on the left, repositories on the right. */
460+.profile-layout {
460461 display: flex;
461- gap: 1.25rem;
462+ gap: 1.5rem;
462463 align-items: flex-start;
463- margin-bottom: 1.5rem;
464- padding-bottom: 1.5rem;
465- border-bottom: 1px solid var(--fb-border);
466464 }
467-.profile-meta {
465+.profile-sidebar {
466+ flex: none;
467+ width: 19rem;
468+}
469+.profile-main {
470+ flex: 1;
468471 min-width: 0;
469472 }
470-.profile-meta h1 {
471- margin-bottom: 0.15rem;
473+.profile-card {
474+ display: flex;
475+ flex-direction: column;
476+ gap: 0.5rem;
472477 }
473-.profile-sub {
474- display: inline-flex;
475- align-items: center;
476- gap: 0.4rem;
478+.profile-card .avatar-xl {
479+ align-self: center;
480+}
481+.profile-name {
482+ margin: 0.5rem 0 0;
483+ font-size: 1.5rem;
484+ text-align: center;
485+}
486+.profile-handle {
487+ margin: 0;
488+ text-align: center;
477489 }
478490 .profile-pronouns {
479- margin-left: 0.5rem;
480491 padding: 0.05rem 0.5rem;
481492 border: 1px solid var(--fb-border);
482493 border-radius: 999px;
483494 font-size: 0.82em;
484495 }
485496 .profile-bio {
486- margin: 0.6rem 0 0;
487- white-space: pre-wrap;
497+ margin: 0.5rem 0;
498+ padding-top: 0.75rem;
499+ border-top: 1px solid var(--fb-border);
500+}
501+.profile-bio :first-child {
502+ margin-top: 0;
503+}
504+.profile-bio :last-child {
505+ margin-bottom: 0;
488506 }
489507 .profile-links {
490508 list-style: none;
491- margin: 0.6rem 0 0;
492- padding: 0;
509+ margin: 0;
510+ padding: 0.75rem 0 0;
511+ border-top: 1px solid var(--fb-border);
493512 display: flex;
494- flex-wrap: wrap;
495- gap: 0.25rem 1.25rem;
513+ flex-direction: column;
514+ gap: 0.4rem;
496515 }
497516 .profile-links li {
498517 display: inline-flex;
499518 align-items: center;
500- gap: 0.4rem;
519+ gap: 0.5rem;
520+}
521+.profile-joined {
522+ margin: 0;
523+ padding-top: 0.75rem;
524+ border-top: 1px solid var(--fb-border);
525+ font-size: 0.9em;
501526 }
502527 .avatar-settings {
503528 display: flex;
@@ -535,6 +560,93 @@ pre.code {
535560 border-radius: var(--fb-radius);
536561 }
537562
563+/* ---- Rendered markdown (README, bios) ---- */
564+.markdown {
565+ overflow-wrap: break-word;
566+}
567+.markdown > :first-child {
568+ margin-top: 0;
569+}
570+.markdown > :last-child {
571+ margin-bottom: 0;
572+}
573+.markdown h1,
574+.markdown h2 {
575+ padding-bottom: 0.3em;
576+ border-bottom: 1px solid var(--fb-border);
577+ margin: 1.5em 0 1rem;
578+}
579+.markdown h3,
580+.markdown h4 {
581+ margin: 1.4em 0 0.8rem;
582+}
583+.markdown p,
584+.markdown ul,
585+.markdown ol,
586+.markdown blockquote,
587+.markdown table,
588+.markdown pre {
589+ margin: 0 0 1rem;
590+}
591+.markdown ul,
592+.markdown ol {
593+ padding-left: 1.5rem;
594+}
595+.markdown a {
596+ text-decoration: underline;
597+}
598+.markdown blockquote {
599+ padding: 0 1rem;
600+ color: var(--fb-fg-muted);
601+ border-left: 3px solid var(--fb-border);
602+}
603+/* Inline code: a subtle chip. */
604+.markdown code {
605+ padding: 0.15em 0.4em;
606+ font-size: 0.9em;
607+ background: var(--fb-bg-inset);
608+ border: 1px solid var(--fb-border);
609+ border-radius: 5px;
610+}
611+/* Fenced blocks: a clearly delimited panel; the inner code resets the chip. */
612+.markdown pre {
613+ padding: 0.85rem 1rem;
614+ overflow-x: auto;
615+ background: var(--fb-bg-inset);
616+ border: 1px solid var(--fb-border);
617+ border-radius: var(--fb-radius);
618+ line-height: 1.45;
619+}
620+.markdown pre code {
621+ padding: 0;
622+ font-size: 0.9em;
623+ background: none;
624+ border: none;
625+ border-radius: 0;
626+}
627+.markdown table {
628+ border-collapse: collapse;
629+}
630+.markdown th,
631+.markdown td {
632+ padding: 0.4rem 0.75rem;
633+ border: 1px solid var(--fb-border);
634+}
635+.markdown th {
636+ background: var(--fb-bg-inset);
637+}
638+.markdown img {
639+ max-width: 100%;
640+}
641+.markdown hr {
642+ border: none;
643+ border-top: 1px solid var(--fb-border);
644+ margin: 1.5rem 0;
645+}
646+.readme {
647+ margin-top: 1rem;
648+}
649+
538650 /* Highlight classes — coloured entirely by the theme's --fb-hl-* tokens. */
539651 .hl-keyword { color: var(--fb-hl-keyword); }
540652 .hl-function { color: var(--fb-hl-function); }
@@ -610,15 +722,23 @@ pre.code {
610722 .subnav {
611723 justify-content: center;
612724 }
613-.ref-row,
614-.clone-row {
725+.ref-row {
615726 display: flex;
616727 gap: 0.5rem;
617728 align-items: center;
618729 margin-top: 0.5rem;
619730 }
731+.clone-row {
732+ display: flex;
733+ gap: 0.5rem;
734+ align-items: center;
735+ margin: 0 0 1rem;
736+}
737+.clone-label {
738+ font-weight: 600;
739+}
620740 .clone-row input {
621- max-width: 30rem;
741+ max-width: 26rem;
622742 font-family: var(--fb-font-mono);
623743 font-size: 0.85em;
624744 }
@@ -627,17 +747,38 @@ pre.code {
627747 font-family: var(--fb-font-mono);
628748 }
629749
630-table.tree td {
631- padding: 0.3rem 0.6rem;
750+/* File tree: a bordered card of compact rows. */
751+.file-tree {
752+ padding: 0;
753+ overflow: hidden;
754+}
755+.file-row {
756+ display: flex;
757+ align-items: center;
758+ gap: 0.6rem;
759+ padding: 0.4rem 0.85rem;
760+ color: var(--fb-fg);
632761 border-bottom: 1px solid var(--fb-border);
633762 }
634-.tree-icon {
635- width: 1.5rem;
636- text-align: center;
763+.file-row:last-child {
764+ border-bottom: none;
765+}
766+.file-row:hover {
767+ background: var(--fb-bg-inset);
768+ text-decoration: none;
769+}
770+.file-icon {
771+ display: inline-flex;
637772 color: var(--fb-fg-muted);
638773 }
639-.tree-icon .icon {
640- vertical-align: -0.15em;
774+.file-row.is-dir .file-icon {
775+ color: var(--fb-accent);
776+}
777+.file-name {
778+ min-width: 0;
779+ overflow: hidden;
780+ text-overflow: ellipsis;
781+ white-space: nowrap;
641782 }
642783
643784 .blob-header {
@@ -848,8 +989,11 @@ td.diff-empty {
848989 display: block;
849990 overflow-x: auto;
850991 }
851- /* Stack the profile header on narrow screens. */
852- .profile-head {
992+ /* Stack the profile columns on narrow screens. */
993+ .profile-layout {
853994 flex-direction: column;
854995 }
996+ .profile-sidebar {
997+ width: 100%;
998+ }
855999 }
crates/web/src/repo.rs +131 −62
@@ -138,13 +138,22 @@ async fn resolve(
138138 }))
139139 }
140140
141-/// `GET /{owner}` — the user's page: their groups and repositories the viewer can
142-/// see.
141+/// A listing filter query (`?q=`), shared by the profile and explore views.
142+#[derive(Debug, Default, Deserialize)]
143+pub struct ListQuery {
144+ /// Case-insensitive substring filter over repository paths.
145+ #[serde(default)]
146+ pub q: Option<String>,
147+}
148+
149+/// `GET /{owner}` — the user's profile: an info sidebar on the left, their groups
150+/// and repositories (filterable) on the right.
143151 pub async fn user_page(
144152 State(state): State<AppState>,
145153 MaybeUser(viewer): MaybeUser,
146154 jar: CookieJar,
147155 uri: Uri,
156+ Query(lq): Query<ListQuery>,
148157 Path(owner_name): Path<String>,
149158 ) -> AppResult<Response> {
150159 let Some(owner) = state.store.user_by_username(&owner_name).await? else {
@@ -153,26 +162,43 @@ pub async fn user_page(
153162 let repos = visible_repos(&state, &owner, viewer.as_ref()).await?;
154163 let groups = state.store.groups_by_owner(&owner.id).await?;
155164
165+ let q = lq.q.unwrap_or_default();
166+ let needle = q.trim().to_lowercase();
167+ let entries: Vec<RepoEntry> = repos
168+ .iter()
169+ .filter(|r| needle.is_empty() || r.path.to_lowercase().contains(&needle))
170+ .map(|r| RepoEntry::owned(&owner.username, r))
171+ .collect();
172+
156173 let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string());
157174 let body = html! {
158- (profile_header(&owner))
159- @if !groups.is_empty() {
160- section class="listing" {
161- h2 { "Groups" }
162- ul class="repo-list card" {
163- @for group in &groups {
164- li {
165- a class="repo-row" href={ "/" (owner.username) "/" (group.path) } {
166- span class="repo-row-name" {
167- (icon(Icon::Folder)) span { (group.path) }
175+ div class="profile-layout" {
176+ aside class="profile-sidebar" { (profile_sidebar(&owner)) }
177+ div class="profile-main" {
178+ form class="search-row" method="get" {
179+ input type="search" name="q" value=(q)
180+ placeholder="Search repositories…" aria-label="Search repositories";
181+ button class="btn btn-primary" type="submit" { "Search" }
182+ }
183+ @if !groups.is_empty() {
184+ section class="listing" {
185+ h2 { "Groups" }
186+ ul class="repo-list card" {
187+ @for group in &groups {
188+ li {
189+ a class="repo-row" href={ "/" (owner.username) "/" (group.path) } {
190+ span class="repo-row-name" {
191+ (icon(Icon::Folder)) span { (group.path) }
192+ }
193+ }
168194 }
169195 }
170196 }
171197 }
172198 }
199+ (repo_card("Repositories", "No repositories.", &entries))
173200 }
174201 }
175- (owned_repo_card(&owner.username, &repos))
176202 };
177203 Ok((jar, page(&chrome, &owner.username, body)).into_response())
178204 }
@@ -241,7 +267,7 @@ async fn dispatch_view(
241267 "branches" => branches_view(state, viewer, jar, uri, ctx).await,
242268 "tags" => tags_view(state, viewer, jar, uri, ctx).await,
243269 "search" => {
244- let header = repo_header(state, &ctx, Tab::Search, &ctx.repo.default_branch);
270+ let header = repo_header(&ctx, Tab::Search, &ctx.repo.default_branch);
245271 let raw_query = dq.q.clone().unwrap_or_default();
246272 crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await
247273 }
@@ -297,10 +323,11 @@ async fn repo_home(
297323 .await?;
298324 let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;
299325 html! {
300- (repo_header(state, &ctx, Tab::Code, &rev_name))
326+ (repo_header(&ctx, Tab::Code, &rev_name))
327+ (clone_widget(state, &ctx))
301328 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, "", &entries))
302329 @if let Some(rendered) = readme {
303- div class="card" style="margin-top:1rem" { (rendered) }
330+ div class="card markdown readme" { (rendered) }
304331 }
305332 }
306333 }
@@ -341,7 +368,7 @@ async fn load_readme(
341368 /// The "empty repository" landing with clone instructions.
342369 fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup {
343370 html! {
344- (repo_header(state, ctx, Tab::Code, &ctx.repo.default_branch))
371+ (repo_header(ctx, Tab::Code, &ctx.repo.default_branch))
345372 div class="card" {
346373 h2 { "This repository is empty" }
347374 p class="muted" { "Push a commit to get started:" }
@@ -377,7 +404,7 @@ async fn tree_view(
377404 .await?;
378405
379406 let body = html! {
380- (repo_header(state, &ctx, Tab::Code, &rev_name))
407+ (repo_header(&ctx, Tab::Code, &rev_name))
381408 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
382409 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries))
383410 };
@@ -423,7 +450,7 @@ async fn blob_view(
423450
424451 let content_body = render_blob(&ctx, &rev_name, &path, &blob, attr_lang.as_deref());
425452 let body = html! {
426- (repo_header(state, &ctx, Tab::Code, &rev_name))
453+ (repo_header(&ctx, Tab::Code, &rev_name))
427454 (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path))
428455 (content_body)
429456 };
@@ -547,7 +574,7 @@ async fn branches_view(
547574 ) -> AppResult<Response> {
548575 let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?;
549576 let body = html! {
550- (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch))
577+ (repo_header(&ctx, Tab::Branches, &ctx.repo.default_branch))
551578 h2 { "Branches" }
552579 table class="list" {
553580 thead { tr { th { "Branch" } th { "Ahead" } th { "Behind" } th { "Updated" } } }
@@ -582,7 +609,7 @@ async fn tags_view(
582609 ) -> AppResult<Response> {
583610 let tags = git_read(state, &ctx.repo.id, git::Repo::tags).await?;
584611 let body = html! {
585- (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch))
612+ (repo_header(&ctx, Tab::Tags, &ctx.repo.default_branch))
586613 h2 { "Tags" }
587614 table class="list" {
588615 thead { tr { th { "Tag" } th { "Commit" } th { "Kind" } } }
@@ -640,7 +667,7 @@ async fn commits_view(
640667 .await?;
641668
642669 let body = html! {
643- (repo_header(state, &ctx, Tab::Code, &rev_name))
670+ (repo_header(&ctx, Tab::Code, &rev_name))
644671 h2 { "Commits" }
645672 ul class="repo-list" {
646673 @for (commit, sig) in commits.iter().zip(states.iter()) {
@@ -691,7 +718,7 @@ async fn commit_view(
691718 let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
692719
693720 let body = html! {
694- (repo_header(state, &ctx, Tab::Code, &ctx.repo.default_branch))
721+ (repo_header(&ctx, Tab::Code, &ctx.repo.default_branch))
695722 (commit_meta(&detail, &sig))
696723 div class="diff-toolbar" {
697724 span {
@@ -1063,31 +1090,30 @@ fn owned_repo_card(owner: &str, repos: &[Repo]) -> Markup {
10631090 repo_card("Repositories", "No repositories.", &entries)
10641091 }
10651092
1066-/// The profile header: avatar, name, `User · username`, pronouns, bio, and any
1067-/// location and social links.
1068-fn profile_header(owner: &User) -> Markup {
1093+/// The profile sidebar card: avatar, name, handle, pronouns, markdown bio,
1094+/// location/social links, and the join date.
1095+fn profile_sidebar(owner: &User) -> Markup {
10691096 let name = owner.display_name.as_deref().unwrap_or(&owner.username);
10701097 html! {
1071- header class="profile-head" {
1098+ div class="card profile-card" {
10721099 img class="avatar avatar-xl" src={ "/avatar/" (owner.username) } alt="";
1073- div class="profile-meta" {
1074- h1 { (name) }
1075- p class="profile-sub muted" {
1076- (icon(Icon::User)) span { "User · " (owner.username) }
1077- @if let Some(pronouns) = pronouns(owner) {
1078- span class="profile-pronouns" { (pronouns) }
1079- }
1100+ h1 class="profile-name" { (name) }
1101+ p class="profile-handle muted" {
1102+ "@" (owner.username)
1103+ @if let Some(pronouns) = pronouns(owner) {
1104+ " · " (pronouns)
10801105 }
1081- @if let Some(bio) = non_empty(owner.bio.as_ref()) {
1082- p class="profile-bio" { (bio) }
1083- }
1084- @let links = profile_links(owner);
1085- @if !links.is_empty() {
1086- ul class="profile-links muted" {
1087- @for link in &links { (link) }
1088- }
1106+ }
1107+ @if let Some(bio) = non_empty(owner.bio.as_ref()) {
1108+ div class="profile-bio markdown" { (markdown::render(bio)) }
1109+ }
1110+ @let links = profile_links(owner);
1111+ @if !links.is_empty() {
1112+ ul class="profile-links muted" {
1113+ @for link in &links { (link) }
10891114 }
10901115 }
1116+ p class="profile-joined muted" { "Joined on " (fmt_joined(owner.created_at)) }
10911117 }
10921118 }
10931119 }
@@ -1155,11 +1181,13 @@ fn mastodon_link(handle: &str) -> Markup {
11551181 }
11561182
11571183 /// The repo sub-header: slug, tabs, and clone URLs.
1158-fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
1184+fn repo_header(ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
11591185 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
1160- let tab = |t: Tab, label: &str, href: String| {
1186+ let tab = |t: Tab, label: &str, glyph: Icon, href: String| {
11611187 html! {
1162- a href=(href) aria-current=[(active == t).then_some("page")] { (label) }
1188+ a href=(href) aria-current=[(active == t).then_some("page")] {
1189+ (icon(glyph)) span { (label) }
1190+ }
11631191 }
11641192 };
11651193 html! {
@@ -1171,22 +1199,29 @@ fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Marku
11711199 }
11721200 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }
11731201 nav class="tabs repo-tabs" {
1174- (tab(Tab::Code, "Code", base.clone()))
1175- (tab(Tab::Branches, "Branches", format!("{base}/-/branches")))
1176- (tab(Tab::Tags, "Tags", format!("{base}/-/tags")))
1177- (tab(Tab::Search, "Search", format!("{base}/-/search")))
1202+ (tab(Tab::Code, "Code", Icon::File, base.clone()))
1203+ (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches")))
1204+ (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags")))
1205+ (tab(Tab::Search, "Search", Icon::Search, format!("{base}/-/search")))
11781206 }
11791207 div class="ref-row" {
11801208 // Current ref, linking to the branch picker.
11811209 a class="badge" href=(format!("{base}/-/branches")) { (rev) }
11821210 a href=(format!("{base}/-/commits/{rev}")) { "Commits" }
11831211 }
1184- div class="clone-row" {
1185- input type="text" readonly value=(clone_https(state, ctx))
1186- aria-label="HTTPS clone URL";
1187- button class="btn" type="button" data-clipboard=(clone_https(state, ctx)) {
1188- (icon(Icon::Copy)) span { "Copy" }
1189- }
1212+ }
1213+ }
1214+}
1215+
1216+/// The HTTPS clone widget, shown only on the repo overview.
1217+fn clone_widget(state: &AppState, ctx: &RepoCtx) -> Markup {
1218+ html! {
1219+ div class="clone-row" {
1220+ span class="muted clone-label" { "Clone" }
1221+ input type="text" readonly value=(clone_https(state, ctx))
1222+ aria-label="HTTPS clone URL";
1223+ button class="btn" type="button" data-clipboard=(clone_https(state, ctx)) {
1224+ (icon(Icon::Copy)) span { "Copy" }
11901225 }
11911226 }
11921227 }
@@ -1250,14 +1285,22 @@ fn tree_table(
12501285 };
12511286 format!("/{owner}/{repo_path}/-/{view}/{rev}/{sub}")
12521287 };
1288+ // Directories first, then files, each alphabetical (case-insensitive).
1289+ let mut sorted: Vec<&git::TreeEntry> = entries.iter().collect();
1290+ sorted.sort_by(|a, b| {
1291+ let is_file = |e: &git::TreeEntry| e.kind != git::EntryKind::Directory;
1292+ is_file(a)
1293+ .cmp(&is_file(b))
1294+ .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
1295+ });
12531296 html! {
1254- table class="list tree" {
1255- tbody {
1256- @for entry in entries {
1257- tr {
1258- td class="tree-icon" { (icon(entry_icon(entry.kind))) }
1259- td { a href=(href(&entry.name, entry.kind)) { (entry.name) } }
1260- }
1297+ div class="file-tree card" {
1298+ @for entry in sorted {
1299+ @let is_dir = entry.kind == git::EntryKind::Directory;
1300+ a class=(if is_dir { "file-row is-dir" } else { "file-row" })
1301+ href=(href(&entry.name, entry.kind)) {
1302+ span class="file-icon" { (icon(entry_icon(entry.kind))) }
1303+ span class="file-name" { (entry.name) }
12611304 }
12621305 }
12631306 }
@@ -1319,6 +1362,32 @@ fn is_image(filename: &str) -> bool {
13191362 )
13201363 }
13211364
1365+/// Format an epoch-millisecond timestamp as a human join date, e.g.
1366+/// `Jul 11, 2026`.
1367+fn fmt_joined(ms: i64) -> String {
1368+ let secs = ms.div_euclid(1000);
1369+ match time::OffsetDateTime::from_unix_timestamp(secs) {
1370+ Ok(dt) => {
1371+ let month = match dt.month() {
1372+ time::Month::January => "Jan",
1373+ time::Month::February => "Feb",
1374+ time::Month::March => "Mar",
1375+ time::Month::April => "Apr",
1376+ time::Month::May => "May",
1377+ time::Month::June => "Jun",
1378+ time::Month::July => "Jul",
1379+ time::Month::August => "Aug",
1380+ time::Month::September => "Sep",
1381+ time::Month::October => "Oct",
1382+ time::Month::November => "Nov",
1383+ time::Month::December => "Dec",
1384+ };
1385+ format!("{month} {}, {}", dt.day(), dt.year())
1386+ }
1387+ Err(_) => "—".to_string(),
1388+ }
1389+}
1390+
13221391 /// Format an epoch-millisecond timestamp as a `YYYY-MM-DD` date.
13231392 fn fmt_date(ms: i64) -> String {
13241393 let secs = ms.div_euclid(1000);
crates/web/src/tests.rs +3 −2
@@ -273,10 +273,11 @@ async fn profile_page_renders_with_identity() {
273273 let res = app.oneshot(get("/ada")).await.unwrap();
274274 assert_eq!(res.status(), StatusCode::OK);
275275 let html = body_string(res).await;
276- // Display name in the heading and the "User · username" subtitle.
276+ // Display name in the heading and the "@username" handle line.
277277 assert!(html.contains("Ada"), "display name shown");
278- assert!(html.contains("User · ada"), "profile subtitle shown");
278+ assert!(html.contains("@ada"), "profile handle shown");
279279 assert!(html.contains("/avatar/ada"), "avatar image referenced");
280+ assert!(html.contains("Joined on"), "join date shown");
280281 }
281282
282283 #[tokio::test]
data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git/logs/HEAD +1 −0
@@ -0,0 +1 @@
1+0000000000000000000000000000000000000000 f09bd3e7f54bc32b967fdffb126b533e1a487f4d hanna <me@hanna.lol> 1784962339 -0400 push
data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git/logs/refs/heads/main +1 −0
@@ -0,0 +1 @@
1+0000000000000000000000000000000000000000 f09bd3e7f54bc32b967fdffb126b533e1a487f4d hanna <me@hanna.lol> 1784962339 -0400 push
data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git/objects/pack/pack-ddd254cc0e78f5c08fac66608567bd270756bebf.idx +0 −0

Binary file not shown.

data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git/objects/pack/pack-ddd254cc0e78f5c08fac66608567bd270756bebf.pack +0 −0

Binary file not shown.

data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git/objects/pack/pack-ddd254cc0e78f5c08fac66608567bd270756bebf.rev +0 −0

Binary file not shown.

data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git/refs/heads/main +1 −0
@@ -0,0 +1 @@
1+f09bd3e7f54bc32b967fdffb126b533e1a487f4d