Tahinli/forge
public0 watching
★ 0
💄 feat(ui): light theme by default, profile links, wider layout
bfa240da84c134ff7b2e81c937ad48226e988ae4
Author Ahmet Kaan Gümüş ·
Parent: 9093535
24 changed files +362 -131
- crates/forge-core/migrations/0036_default_light_theme.sql +5 -0
- crates/forge-core/src/store/account_tokens.rs +2 -2
- crates/forge-core/src/store/migrate.rs +4 -0
- crates/forge-core/src/store/mod.rs +6 -6
- crates/forge-core/src/store/ui_prefs.rs +1 -1
- crates/forge-core/src/store/user_delete.rs +3 -3
- crates/forge-web/src/pages/discussion_view.rs +13 -6
- crates/forge-web/src/pages/discussions.rs +2 -2
- crates/forge-web/src/pages/issue_parts.rs +18 -4
- crates/forge-web/src/pages/issue_view.rs +3 -2
- crates/forge-web/src/pages/issues_list.rs +3 -3
- crates/forge-web/src/pages/pull_view.rs +13 -3
- crates/forge-web/src/pages/pulls_list.rs +3 -3
- crates/forge-web/src/pages/releases.rs +3 -3
- crates/forge-web/src/pages/repo.rs +5 -3
- crates/forge-web/src/pages/settings_appearance.rs +15 -1
- crates/forge-web/src/theme.rs +50 -17
- crates/forge-web/src/views/review_thread.rs +7 -3
- crates/forge-web/tests/community_i18n_e2e.rs +1 -1
- crates/forge-web/tests/i18n_e2e.rs +5 -5
- crates/forge-web/tests/ui_prefs_e2e.rs +43 -26
- style/_repo.scss +7 -26
- style/_shell.scss +1 -1
- style/main.scss +149 -10
crates/forge-core/migrations/0036_default_light_theme.sql| @@ -0,0 +1,5 @@ | ||
| 1 | +-- The product default theme is now 'light'. The deployment is new, so no user chose 'auto' | |
| 2 | +-- deliberately: move every stored 'auto' to 'light'. The column default stays in the 0030 shape | |
| 3 | +-- (SQLite cannot change a default without rebuilding `users`, which its foreign keys make | |
| 4 | +-- unsafe), so new rows get 'light' from the insert sites in store/. | |
| 5 | +UPDATE users SET theme = 'light' WHERE theme = 'auto'; |
crates/forge-core/src/store/account_tokens.rs| @@ -300,14 +300,14 @@ impl Store { | ||
| 300 | 300 | about: String::new(), |
| 301 | 301 | timezone: "UTC".to_owned(), |
| 302 | 302 | avatar_hash: None, |
| 303 | - theme: "auto".into(), | |
| 303 | + theme: "light".into(), | |
| 304 | 304 | language: "auto".into(), |
| 305 | 305 | kind: AccountKind::User, |
| 306 | 306 | email_notifications: true, |
| 307 | 307 | }; |
| 308 | 308 | tx.execute( |
| 309 | 309 | "INSERT INTO users (id, username, username_folded, email, password_hash, is_admin, \ |
| 310 | - created_at, email_verified_at) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?6)", | |
| 310 | + created_at, email_verified_at, theme) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?6, 'light')", | |
| 311 | 311 | ( |
| 312 | 312 | user.id.clone(), |
| 313 | 313 | user.username.clone(), |
crates/forge-core/src/store/migrate.rs| @@ -51,6 +51,10 @@ pub const MIGRATIONS: &[(u32, &str)] = &[ | ||
| 51 | 51 | 35, |
| 52 | 52 | include_str!("../../migrations/0035_email_notifications.sql"), |
| 53 | 53 | ), |
| 54 | + ( | |
| 55 | + 36, | |
| 56 | + include_str!("../../migrations/0036_default_light_theme.sql"), | |
| 57 | + ), | |
| 54 | 58 | ]; |
| 55 | 59 | |
| 56 | 60 | /// The highest migration version this binary knows about. |
crates/forge-core/src/store/mod.rs| @@ -174,15 +174,15 @@ impl Store { | ||
| 174 | 174 | about: String::new(), |
| 175 | 175 | timezone: "UTC".to_string(), |
| 176 | 176 | avatar_hash: None, |
| 177 | - theme: "auto".into(), | |
| 177 | + theme: "light".into(), | |
| 178 | 178 | language: "auto".into(), |
| 179 | 179 | kind: AccountKind::User, |
| 180 | 180 | email_notifications: true, |
| 181 | 181 | }; |
| 182 | 182 | let conn = self.inner.conn.lock().await; |
| 183 | 183 | conn.execute( |
| 184 | - "INSERT INTO users (id, username, username_folded, email, password_hash, is_admin, created_at) \ | |
| 185 | - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", | |
| 184 | + "INSERT INTO users (id, username, username_folded, email, password_hash, is_admin, created_at, theme) \ | |
| 185 | + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'light')", | |
| 186 | 186 | ( |
| 187 | 187 | user.id.clone(), |
| 188 | 188 | user.username.clone(), |
| @@ -216,7 +216,7 @@ impl Store { | ||
| 216 | 216 | about: String::new(), |
| 217 | 217 | timezone: "UTC".to_string(), |
| 218 | 218 | avatar_hash: None, |
| 219 | - theme: "auto".into(), | |
| 219 | + theme: "light".into(), | |
| 220 | 220 | language: "auto".into(), |
| 221 | 221 | kind: AccountKind::User, |
| 222 | 222 | email_notifications: true, |
| @@ -224,8 +224,8 @@ impl Store { | ||
| 224 | 224 | let conn = self.inner.conn.lock().await; |
| 225 | 225 | let inserted = conn |
| 226 | 226 | .execute( |
| 227 | - "INSERT INTO users (id, username, username_folded, email, password_hash, is_admin, created_at) \ | |
| 228 | - SELECT ?1, ?2, ?3, ?4, ?5, 1, ?6 WHERE NOT EXISTS (SELECT 1 FROM users)", | |
| 227 | + "INSERT INTO users (id, username, username_folded, email, password_hash, is_admin, created_at, theme) \ | |
| 228 | + SELECT ?1, ?2, ?3, ?4, ?5, 1, ?6, 'light' WHERE NOT EXISTS (SELECT 1 FROM users)", | |
| 229 | 229 | ( |
| 230 | 230 | user.id.clone(), |
| 231 | 231 | user.username.clone(), |
crates/forge-core/src/store/ui_prefs.rs| @@ -97,7 +97,7 @@ mod tests { | ||
| 97 | 97 | .unwrap(); |
| 98 | 98 | assert_eq!( |
| 99 | 99 | store.user_by_id(&user.id).await.unwrap().unwrap().theme, |
| 100 | - "auto" | |
| 100 | + "light" | |
| 101 | 101 | ); |
| 102 | 102 | |
| 103 | 103 | store.set_ui_prefs(&user.id, "dark", "tr").await.unwrap(); |
crates/forge-core/src/store/user_delete.rs| @@ -162,14 +162,14 @@ impl Store { | ||
| 162 | 162 | about: String::new(), |
| 163 | 163 | timezone: "UTC".to_string(), |
| 164 | 164 | avatar_hash: None, |
| 165 | - theme: "auto".into(), | |
| 165 | + theme: "light".into(), | |
| 166 | 166 | language: "auto".into(), |
| 167 | 167 | kind: AccountKind::User, |
| 168 | 168 | email_notifications: true, |
| 169 | 169 | }; |
| 170 | 170 | conn.execute( |
| 171 | - "INSERT INTO users (id, username, username_folded, email, password_hash, is_admin, created_at) \ | |
| 172 | - VALUES (?1, ?2, ?3, NULL, ?4, 0, ?5)", | |
| 171 | + "INSERT INTO users (id, username, username_folded, email, password_hash, is_admin, created_at, theme) \ | |
| 172 | + VALUES (?1, ?2, ?3, NULL, ?4, 0, ?5, 'light')", | |
| 173 | 173 | ( |
| 174 | 174 | user.id.clone(), |
| 175 | 175 | user.username.clone(), |
crates/forge-web/src/pages/discussion_view.rs| @@ -24,7 +24,7 @@ use crate::pages::repo_ctx::{RepoCtx, load_repo}; | ||
| 24 | 24 | use crate::pages::tree::link_context; |
| 25 | 25 | use crate::render::markdown::{LinkContext, render_markdown}; |
| 26 | 26 | use crate::state::AppState; |
| 27 | -use crate::views::{avatar_url, display_name, now_unix, shell}; | |
| 27 | +use crate::views::{avatar_url, display_name, now_unix, shell, user_href, user_link}; | |
| 28 | 28 | |
| 29 | 29 | path_param!(owner); |
| 30 | 30 | path_param!(repo); |
| @@ -35,6 +35,8 @@ pub(crate) struct CommentView { | ||
| 35 | 35 | lang: Lang, |
| 36 | 36 | anchor: String, |
| 37 | 37 | author: String, |
| 38 | + /// The author's username, the link target of the name and avatar. | |
| 39 | + login: String, | |
| 38 | 40 | avatar: String, |
| 39 | 41 | when: When, |
| 40 | 42 | /// Rendered Markdown. Empty when the comment is deleted. |
| @@ -116,6 +118,7 @@ fn comment_view( | ||
| 116 | 118 | lang: frame.lang, |
| 117 | 119 | anchor: format!("comment-{}", c.id), |
| 118 | 120 | author: display_name(frame.lang, &username), |
| 121 | + login: username.clone(), | |
| 119 | 122 | avatar: avatar_url(&username, hash.as_deref()), |
| 120 | 123 | when: When::new(frame.lang, &c.created_at, &frame.tz, frame.now), |
| 121 | 124 | html: if live { |
| @@ -151,6 +154,7 @@ async fn comment_article(item: CommentView) -> Result<impl View> { | ||
| 151 | 154 | lang, |
| 152 | 155 | anchor, |
| 153 | 156 | author, |
| 157 | + login, | |
| 154 | 158 | avatar, |
| 155 | 159 | when, |
| 156 | 160 | html, |
| @@ -167,6 +171,7 @@ async fn comment_article(item: CommentView) -> Result<impl View> { | ||
| 167 | 171 | csrf, |
| 168 | 172 | reactions, |
| 169 | 173 | } = item; |
| 174 | + let profile = user_href(&login); | |
| 170 | 175 | let class = if accepted { |
| 171 | 176 | "issue-item discussion-accepted" |
| 172 | 177 | } else { |
| @@ -178,8 +183,8 @@ async fn comment_article(item: CommentView) -> Result<impl View> { | ||
| 178 | 183 | if deleted { |
| 179 | 184 | <span class="muted">(g::t(lang, K::CommentDeleted))</span> |
| 180 | 185 | } else { |
| 181 | - <img class="avatar avatar-sm" src=(avatar.as_str()) alt="" width="24" height="24"> | |
| 182 | - <strong>(author.as_str())</strong> | |
| 186 | + <a href=(profile.as_str())><img class="avatar avatar-sm" src=(avatar.as_str()) alt="" width="24" height="24"></a> | |
| 187 | + <strong>user_link(shown: author.as_str(), login: login.as_str())</strong> | |
| 183 | 188 | <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time> |
| 184 | 189 | } |
| 185 | 190 | if accepted { |
| @@ -353,6 +358,8 @@ async fn discussion_page( | ||
| 353 | 358 | .unwrap_or_else(|| ("ghost".to_owned(), None)); |
| 354 | 359 | let author = display_name(lang, &author_name); |
| 355 | 360 | let author_avatar = avatar_url(&author_name, author_hash.as_deref()); |
| 361 | + let author_profile = user_href(&author_name); | |
| 362 | + let author_login = author_name.clone(); | |
| 356 | 363 | let opened = When::new(lang, &discussion.created_at, &tz, now); |
| 357 | 364 | let body_source = if discussion.body.trim().is_empty() { |
| 358 | 365 | g::t(lang, K::NoDescription) |
| @@ -404,7 +411,7 @@ async fn discussion_page( | ||
| 404 | 411 | if locked { |
| 405 | 412 | " " <span class="issue-badge">(g::t(lang, K::Locked))</span> |
| 406 | 413 | } |
| 407 | - " " <strong>(author.as_str())</strong> | |
| 414 | + " " <strong>user_link(shown: author.as_str(), login: author_login.as_str())</strong> | |
| 408 | 415 | (g::t(lang, K::OpenedThis)) |
| 409 | 416 | <time datetime=(opened.iso.as_str()) title=(opened.absolute.as_str())>(opened.relative.as_str())</time> |
| 410 | 417 | (g::t(lang, K::OpenedSeparator)) |
| @@ -415,8 +422,8 @@ async fn discussion_page( | ||
| 415 | 422 | <div class="issue-column"> |
| 416 | 423 | <article class="issue-item" id="discussion-body"> |
| 417 | 424 | <header class="issue-item-head"> |
| 418 | - <img class="avatar avatar-sm" src=(author_avatar.as_str()) alt="" width="24" height="24"> | |
| 419 | - <strong>(author.as_str())</strong> | |
| 425 | + <a href=(author_profile.as_str())><img class="avatar avatar-sm" src=(author_avatar.as_str()) alt="" width="24" height="24"></a> | |
| 426 | + <strong>user_link(shown: author.as_str(), login: author_login.as_str())</strong> | |
| 420 | 427 | </header> |
| 421 | 428 | <div class="issue-item-body markdown-body">(Unescaped::new_unchecked(body_html))</div> |
| 422 | 429 | if let Some(bar) = body_bar { |
crates/forge-web/src/pages/discussions.rs| @@ -26,7 +26,7 @@ use crate::pages::repo::repo_head; | ||
| 26 | 26 | use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; |
| 27 | 27 | use crate::render::markdown::mentioned_usernames; |
| 28 | 28 | use crate::state::AppState; |
| 29 | -use crate::views::{alert, csrf_field, now_unix, repo_href, shell}; | |
| 29 | +use crate::views::{alert, csrf_field, now_unix, repo_href, shell, user_link}; | |
| 30 | 30 | |
| 31 | 31 | path_param!(owner); |
| 32 | 32 | path_param!(repo); |
| @@ -327,7 +327,7 @@ async fn discussions_list_page(cx: &Cx) -> Result<impl View> { | ||
| 327 | 327 | </div> |
| 328 | 328 | <div class="issue-meta muted small"> |
| 329 | 329 | <span>(by_before)</span> |
| 330 | - <span>(row.author.as_str())</span> | |
| 330 | + <span>user_link(shown: row.author.as_str(), login: row.author.as_str())</span> | |
| 331 | 331 | <span>(by_middle)</span> |
| 332 | 332 | <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time> |
| 333 | 333 | if !by_after.is_empty() { |
crates/forge-web/src/pages/issue_parts.rs| @@ -28,6 +28,7 @@ use crate::views::review_thread::{ThreadCtx, review_thread_in}; | ||
| 28 | 28 | use crate::views::user_facing_message; |
| 29 | 29 | use crate::views::{ |
| 30 | 30 | alert, avatar_url, display_name, iso_time, now_unix, relative_time, repo_href, short_oid, |
| 31 | + user_href, user_link, | |
| 31 | 32 | }; |
| 32 | 33 | use crate::webhooks::emit::{Sender, emit_comment, emit_item_action}; |
| 33 | 34 | |
| @@ -220,6 +221,8 @@ pub(crate) fn is_hex_oid(s: &str) -> bool { | ||
| 220 | 221 | pub(crate) struct CommentView { |
| 221 | 222 | anchor: String, |
| 222 | 223 | author: String, |
| 224 | + /// The author's username, the link target of the name and avatar. | |
| 225 | + login: String, | |
| 223 | 226 | avatar: String, |
| 224 | 227 | when: When, |
| 225 | 228 | html: String, |
| @@ -269,6 +272,7 @@ pub(crate) struct EventLine { | ||
| 269 | 272 | pub(crate) struct ReviewView { |
| 270 | 273 | anchor: String, |
| 271 | 274 | author: String, |
| 275 | + login: String, | |
| 272 | 276 | avatar: String, |
| 273 | 277 | when: When, |
| 274 | 278 | verb: &'static str, |
| @@ -289,6 +293,8 @@ pub(crate) enum Entry { | ||
| 289 | 293 | pub(crate) struct Discussion { |
| 290 | 294 | /// The item's author, by display name, and when it was opened. |
| 291 | 295 | pub(crate) author: String, |
| 296 | + /// The author's username, for the profile link. | |
| 297 | + pub(crate) author_login: String, | |
| 292 | 298 | pub(crate) opened: When, |
| 293 | 299 | pub(crate) body: CommentView, |
| 294 | 300 | pub(crate) entries: Vec<Entry>, |
| @@ -380,6 +386,7 @@ pub(crate) async fn comment_item(lang: Lang, item: CommentView) -> Result<impl V | ||
| 380 | 386 | let CommentView { |
| 381 | 387 | anchor, |
| 382 | 388 | author, |
| 389 | + login, | |
| 383 | 390 | avatar, |
| 384 | 391 | when, |
| 385 | 392 | html, |
| @@ -389,11 +396,12 @@ pub(crate) async fn comment_item(lang: Lang, item: CommentView) -> Result<impl V | ||
| 389 | 396 | csrf, |
| 390 | 397 | reactions, |
| 391 | 398 | } = item; |
| 399 | + let profile = user_href(&login); | |
| 392 | 400 | Ok(view! { |
| 393 | 401 | <article class="issue-item" id=(anchor.as_str())> |
| 394 | 402 | <header class="issue-item-head"> |
| 395 | - <img class="avatar avatar-sm" src=(avatar.as_str()) alt="" width="24" height="24"> | |
| 396 | - <strong>(author.as_str())</strong> | |
| 403 | + <a href=(profile.as_str())><img class="avatar avatar-sm" src=(avatar.as_str()) alt="" width="24" height="24"></a> | |
| 404 | + <strong>user_link(shown: author.as_str(), login: login.as_str())</strong> | |
| 397 | 405 | <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time> |
| 398 | 406 | if let Some(href) = history_href { |
| 399 | 407 | <a class="issue-edited" href=(href.as_str())>(g::t(lang, K::Edited))</a> |
| @@ -538,6 +546,7 @@ pub(crate) async fn review_item(lang: Lang, review: ReviewView) -> Result<impl V | ||
| 538 | 546 | let ReviewView { |
| 539 | 547 | anchor, |
| 540 | 548 | author, |
| 549 | + login, | |
| 541 | 550 | avatar, |
| 542 | 551 | when, |
| 543 | 552 | verb, |
| @@ -547,6 +556,7 @@ pub(crate) async fn review_item(lang: Lang, review: ReviewView) -> Result<impl V | ||
| 547 | 556 | threads, |
| 548 | 557 | ctx, |
| 549 | 558 | } = review; |
| 559 | + let profile = user_href(&login); | |
| 550 | 560 | let verb_class = if dismissed { |
| 551 | 561 | "review-verb review-verb-dismissed" |
| 552 | 562 | } else { |
| @@ -555,8 +565,8 @@ pub(crate) async fn review_item(lang: Lang, review: ReviewView) -> Result<impl V | ||
| 555 | 565 | Ok(view! { |
| 556 | 566 | <article class="issue-item review-item" id=(anchor.as_str())> |
| 557 | 567 | <header class="issue-item-head"> |
| 558 | - <img class="avatar avatar-sm" src=(avatar.as_str()) alt="" width="24" height="24"> | |
| 559 | - <strong>(author.as_str())</strong> | |
| 568 | + <a href=(profile.as_str())><img class="avatar avatar-sm" src=(avatar.as_str()) alt="" width="24" height="24"></a> | |
| 569 | + <strong>user_link(shown: author.as_str(), login: login.as_str())</strong> | |
| 560 | 570 | <span class=(verb_class)>(verb)</span> |
| 561 | 571 | <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time> |
| 562 | 572 | </header> |
| @@ -679,6 +689,7 @@ pub(crate) async fn load_discussion( | ||
| 679 | 689 | let body = CommentView { |
| 680 | 690 | anchor: body_anchor, |
| 681 | 691 | author: display_name(lang, &author), |
| 692 | + login: author.clone(), | |
| 682 | 693 | avatar: avatar_url(&author, author_hash.as_deref()), |
| 683 | 694 | when: opened.clone(), |
| 684 | 695 | html: render_markdown(body_source, Some(&link)), |
| @@ -712,6 +723,7 @@ pub(crate) async fn load_discussion( | ||
| 712 | 723 | let item_view = CommentView { |
| 713 | 724 | anchor: format!("comment-{}", comment.id), |
| 714 | 725 | author: display_name(lang, &who_name), |
| 726 | + login: who_name.clone(), | |
| 715 | 727 | avatar: avatar_url(&who_name, hash.as_deref()), |
| 716 | 728 | when: When::new(lang, &comment.created_at, &tz, now), |
| 717 | 729 | html: render_markdown(&comment.body, Some(&link)), |
| @@ -751,6 +763,7 @@ pub(crate) async fn load_discussion( | ||
| 751 | 763 | |
| 752 | 764 | Ok(Discussion { |
| 753 | 765 | author: display_name(lang, &author), |
| 766 | + author_login: author, | |
| 754 | 767 | opened, |
| 755 | 768 | body, |
| 756 | 769 | entries, |
| @@ -891,6 +904,7 @@ async fn review_entries( | ||
| 891 | 904 | let view = ReviewView { |
| 892 | 905 | anchor: format!("review-{}", review.id), |
| 893 | 906 | author: display_name(lang, &review.author_name), |
| 907 | + login: review.author_name.clone(), | |
| 894 | 908 | avatar: avatar_url(&review.author_name, None), |
| 895 | 909 | when: When::new(lang, &stamp, tz, now), |
| 896 | 910 | verb, |
crates/forge-web/src/pages/issue_view.rs| @@ -29,7 +29,7 @@ use crate::pages::notifications::notify_box_for; | ||
| 29 | 29 | use crate::pages::repo::repo_head; |
| 30 | 30 | use crate::pages::repo_ctx::{RepoCtx, load_repo}; |
| 31 | 31 | use crate::state::AppState; |
| 32 | -use crate::views::{alert, repo_href, shell, user_facing_message}; | |
| 32 | +use crate::views::{alert, repo_href, shell, user_facing_message, user_link}; | |
| 33 | 33 | use crate::webhooks::emit::{Sender, emit_comment, emit_item_action}; |
| 34 | 34 | |
| 35 | 35 | path_param!(owner); |
| @@ -59,6 +59,7 @@ async fn issue_page( | ||
| 59 | 59 | let pin_action = format!("{issue_href}/pin"); |
| 60 | 60 | let notify = notify_box_for(state, &ctx, lang, &issue.id, &issue_href).await?; |
| 61 | 61 | let author = discussion.author.clone(); |
| 62 | + let author_login = discussion.author_login.clone(); | |
| 62 | 63 | let opened = discussion.opened.clone(); |
| 63 | 64 | |
| 64 | 65 | let csrf = csrf_of(&ctx); |
| @@ -94,7 +95,7 @@ async fn issue_page( | ||
| 94 | 95 | <h1 class="issue-heading">(issue.title.as_str()) " " <span class="issue-number">(number_label.as_str())</span></h1> |
| 95 | 96 | <p class="issue-byline"> |
| 96 | 97 | <span class=(state_class)>(state_text)</span> |
| 97 | - <strong>(author.as_str())</strong> | |
| 98 | + <strong>user_link(shown: author.as_str(), login: author_login.as_str())</strong> | |
| 98 | 99 | (g::t(lang, K::OpenedThis)) |
| 99 | 100 | <time datetime=(opened.iso.as_str()) title=(opened.absolute.as_str())>(opened.relative.as_str())</time> |
| 100 | 101 | (g::opened_after(lang)) |
crates/forge-web/src/pages/issues_list.rs| @@ -26,7 +26,7 @@ use crate::state::AppState; | ||
| 26 | 26 | use crate::templates::{self, Template}; |
| 27 | 27 | use crate::views::{ |
| 28 | 28 | alert, avatar_url, csrf_field, enc, iso_time, now_unix, relative_time, repo_href, shell, |
| 29 | - user_facing_message, | |
| 29 | + user_facing_message, user_link, | |
| 30 | 30 | }; |
| 31 | 31 | use crate::webhooks::emit::{Sender, emit_issue}; |
| 32 | 32 | |
| @@ -489,7 +489,7 @@ async fn issues_list_page(cx: &Cx) -> Result<impl View> { | ||
| 489 | 489 | <div class="issue-meta muted small"> |
| 490 | 490 | <span>(row.number_text.as_str())</span> |
| 491 | 491 | if lang == Lang::Tr { |
| 492 | - <span>(row.author.as_str())</span> | |
| 492 | + <span>user_link(shown: row.author.as_str(), login: row.author.as_str())</span> | |
| 493 | 493 | <span>(g::t(lang, K::By))</span> |
| 494 | 494 | <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time> |
| 495 | 495 | <span>(g::t(lang, K::Opened))</span> |
| @@ -497,7 +497,7 @@ async fn issues_list_page(cx: &Cx) -> Result<impl View> { | ||
| 497 | 497 | <span>(g::t(lang, K::Opened))</span> |
| 498 | 498 | <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time> |
| 499 | 499 | <span>(g::t(lang, K::By))</span> |
| 500 | - <span>(row.author.as_str())</span> | |
| 500 | + <span>user_link(shown: row.author.as_str(), login: row.author.as_str())</span> | |
| 501 | 501 | } |
| 502 | 502 | if let Some(milestone) = row.milestone.clone() { |
| 503 | 503 | <a href=(milestone.href.as_str())>(milestone.title.as_str())</a> |
crates/forge-web/src/pages/pull_view.rs| @@ -41,7 +41,7 @@ use crate::pages::repo_ctx::{RepoCtx, load_repo}; | ||
| 41 | 41 | use crate::state::AppState; |
| 42 | 42 | use crate::views::commit_status::{StatusLang, StatusSummary, checks_summary, commit_status}; |
| 43 | 43 | use crate::views::diff::LIMITS; |
| 44 | -use crate::views::{alert, now_unix, repo_href, shell, short_oid}; | |
| 44 | +use crate::views::{alert, now_unix, repo_href, shell, short_oid, user_link}; | |
| 45 | 45 | |
| 46 | 46 | path_param!(owner); |
| 47 | 47 | path_param!(repo); |
| @@ -108,6 +108,7 @@ pub(crate) struct Frame { | ||
| 108 | 108 | pub(crate) page_title: String, |
| 109 | 109 | pub(crate) href: String, |
| 110 | 110 | author: String, |
| 111 | + author_known: bool, | |
| 111 | 112 | opened: When, |
| 112 | 113 | pub(crate) commit_count: usize, |
| 113 | 114 | pub(crate) file_count: usize, |
| @@ -135,6 +136,7 @@ pub(crate) async fn frame_in( | ||
| 135 | 136 | .store |
| 136 | 137 | .usernames_by_ids(std::slice::from_ref(&pr.issue.author_id)) |
| 137 | 138 | .await?; |
| 139 | + let author_known = users.contains_key(&pr.issue.author_id); | |
| 138 | 140 | let author = users |
| 139 | 141 | .get(&pr.issue.author_id) |
| 140 | 142 | .map(|(display, _)| display.clone()) |
| @@ -165,6 +167,7 @@ pub(crate) async fn frame_in( | ||
| 165 | 167 | page_title: format!("{} #{number} \u{b7} {owner}/{name}", pr.issue.title), |
| 166 | 168 | href: repo_href(&owner, &name, "pulls", &number.to_string()), |
| 167 | 169 | author, |
| 170 | + author_known, | |
| 168 | 171 | opened: When::new(lang, &pr.issue.created_at, ctx.viewer_tz(), now_unix()), |
| 169 | 172 | commit_count, |
| 170 | 173 | file_count, |
| @@ -183,6 +186,7 @@ pub(crate) async fn pull_header(frame: Frame) -> Result<impl View> { | ||
| 183 | 186 | state_class, |
| 184 | 187 | state_text, |
| 185 | 188 | author, |
| 189 | + author_known, | |
| 186 | 190 | opened, |
| 187 | 191 | commit_count, |
| 188 | 192 | base_branch, |
| @@ -203,7 +207,13 @@ pub(crate) async fn pull_header(frame: Frame) -> Result<impl View> { | ||
| 203 | 207 | <h1 class="issue-heading">(title.as_str()) " " <span class="issue-number">(number_label.as_str())</span></h1> |
| 204 | 208 | <p class="issue-byline"> |
| 205 | 209 | <span class=(state_class)>(state_text)</span> |
| 206 | - <strong>(author.as_str())</strong> | |
| 210 | + <strong> | |
| 211 | + if author_known { | |
| 212 | + user_link(shown: author.as_str(), login: author.as_str()) | |
| 213 | + } else { | |
| 214 | + (author.as_str()) | |
| 215 | + } | |
| 216 | + </strong> | |
| 207 | 217 | if turkish { |
| 208 | 218 | (wants) <code>(head_label.as_str())</code> |
| 209 | 219 | (from) <code>(base_branch.as_str())</code> |
| @@ -349,7 +359,7 @@ async fn merged_notice( | ||
| 349 | 359 | Ok(view! { |
| 350 | 360 | <section class="merge-box merge-merged"> |
| 351 | 361 | <p class="merge-headline"> |
| 352 | - (prefix) <strong>(who.as_str())</strong> (middle) | |
| 362 | + (prefix) <strong>user_link(shown: who.as_str(), login: who.as_str())</strong> (middle) | |
| 353 | 363 | <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time> |
| 354 | 364 | if !commit.is_empty() { |
| 355 | 365 | (as_sep) |
crates/forge-web/src/pages/pulls_list.rs| @@ -21,7 +21,7 @@ use crate::pages::issues_list::{ | ||
| 21 | 21 | use crate::pages::repo::repo_head; |
| 22 | 22 | use crate::pages::repo_ctx::load_repo; |
| 23 | 23 | use crate::state::AppState; |
| 24 | -use crate::views::{avatar_url, iso_time, now_unix, relative_time, repo_href, shell}; | |
| 24 | +use crate::views::{avatar_url, iso_time, now_unix, relative_time, repo_href, shell, user_link}; | |
| 25 | 25 | |
| 26 | 26 | path_param!(owner); |
| 27 | 27 | path_param!(repo); |
| @@ -279,14 +279,14 @@ async fn pulls_list_page(cx: &Cx) -> Result<impl View> { | ||
| 279 | 279 | <span>(row.number_text.as_str())</span> |
| 280 | 280 | if lang == Lang::Tr { |
| 281 | 281 | <span>(g::t(lang, K::MetaBeforeTime))</span> |
| 282 | - <span>(row.author.as_str())</span> | |
| 282 | + <span>user_link(shown: row.author.as_str(), login: row.author.as_str())</span> | |
| 283 | 283 | <span>(g::t(lang, K::MetaAfterTime))</span> |
| 284 | 284 | <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time> |
| 285 | 285 | } else { |
| 286 | 286 | <span>(g::t(lang, K::MetaBeforeTime))</span> |
| 287 | 287 | <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time> |
| 288 | 288 | <span>(g::t(lang, K::MetaAfterTime))</span> |
| 289 | - <span>(row.author.as_str())</span> | |
| 289 | + <span>user_link(shown: row.author.as_str(), login: row.author.as_str())</span> | |
| 290 | 290 | } |
| 291 | 291 | if let Some(milestone) = row.milestone.clone() { |
| 292 | 292 | <a href=(milestone.href.as_str())>(milestone.title.as_str())</a> |
crates/forge-web/src/pages/releases.rs| @@ -42,7 +42,7 @@ use crate::pages::repo_mirrors::stamp_in_tz; | ||
| 42 | 42 | use crate::post_update::after_ref_update; |
| 43 | 43 | use crate::render::markdown::render_markdown; |
| 44 | 44 | use crate::state::AppState; |
| 45 | -use crate::views::{alert, enc, shell, user_facing_message}; | |
| 45 | +use crate::views::{alert, enc, shell, user_facing_message, user_link}; | |
| 46 | 46 | use crate::webhooks::emit::{Sender, emit}; |
| 47 | 47 | |
| 48 | 48 | path_param!(owner); |
| @@ -506,7 +506,7 @@ async fn list_body( | ||
| 506 | 506 | <code>(card.tag.as_str())</code> |
| 507 | 507 | " \u{b7} " (card.when.as_str()) |
| 508 | 508 | if !card.author.is_empty() { |
| 509 | - " \u{b7} " (card.author.as_str()) | |
| 509 | + " \u{b7} " user_link(shown: card.author.as_str(), login: card.author.as_str()) | |
| 510 | 510 | } |
| 511 | 511 | </p> |
| 512 | 512 | if !card.body.is_empty() { |
| @@ -838,7 +838,7 @@ async fn release_tag_page(cx: &Cx) -> Result<impl View> { | ||
| 838 | 838 | <code>(card.tag.as_str())</code> |
| 839 | 839 | " \u{b7} " (card.when.as_str()) |
| 840 | 840 | if !card.author.is_empty() { |
| 841 | - " \u{b7} " (card.author.as_str()) | |
| 841 | + " \u{b7} " user_link(shown: card.author.as_str(), login: card.author.as_str()) | |
| 842 | 842 | } |
| 843 | 843 | </p> |
| 844 | 844 | if !body_html.is_empty() { |
crates/forge-web/src/pages/repo.rs| @@ -30,7 +30,7 @@ use crate::pages::tree::{ | ||
| 30 | 30 | entry_rows, last_commits, load_readme, read_dir, readme_card, tree_table, |
| 31 | 31 | }; |
| 32 | 32 | use crate::state::AppState; |
| 33 | -use crate::views::{now_unix, relative_time, repo_href, repo_tabs, shell}; | |
| 33 | +use crate::views::{now_unix, relative_time, repo_href, repo_tabs, shell, user_link}; | |
| 34 | 34 | |
| 35 | 35 | path_param!(owner); |
| 36 | 36 | path_param!(repo); |
| @@ -84,7 +84,9 @@ pub async fn repo_head(head: RepoHead) -> Result<impl View> { | ||
| 84 | 84 | security, |
| 85 | 85 | lang, |
| 86 | 86 | } = head; |
| 87 | - let title = format!("{owner}/{name}"); | |
| 87 | + // The title gets its own copies: the view below moves `owner` and `name` into `repo_tabs`. | |
| 88 | + let title_owner = owner.clone(); | |
| 89 | + let title_name = name.clone(); | |
| 88 | 90 | let badge = badge_class(visibility); |
| 89 | 91 | let vis = i18n::visibility_label(lang, visibility); |
| 90 | 92 | let active = active.to_owned(); |
| @@ -136,7 +138,7 @@ pub async fn repo_head(head: RepoHead) -> Result<impl View> { | ||
| 136 | 138 | <section class="page-head"> |
| 137 | 139 | <div> |
| 138 | 140 | <div class="repo-title-row"> |
| 139 | - <h1 class="repo-title">(title.as_str())</h1> | |
| 141 | + <h1 class="repo-title">user_link(shown: title_owner.as_str(), login: title_owner.as_str()) "/" (title_name.as_str())</h1> | |
| 140 | 142 | <span class=(badge)>(vis)</span> |
| 141 | 143 | </div> |
| 142 | 144 | if let Some((href, label)) = fork_line { |
crates/forge-web/src/pages/settings_appearance.rs| @@ -66,9 +66,14 @@ fn language_label(lang: Lang, code: &str) -> &'static str { | ||
| 66 | 66 | } |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | +/// The label of a theme code. Light is the default for everyone who has not chosen, so it is | |
| 70 | +/// marked as the default. | |
| 69 | 71 | fn theme_label(lang: Lang, code: &str) -> &'static str { |
| 70 | 72 | match code { |
| 71 | - "light" => g::t(lang, K::Light), | |
| 73 | + "light" => match lang { | |
| 74 | + Lang::En => "Light (default)", | |
| 75 | + Lang::Tr => "A\u{e7}\u{131}k (varsay\u{131}lan)", | |
| 76 | + }, | |
| 72 | 77 | "dark" => g::t(lang, K::Dark), |
| 73 | 78 | _ => g::t(lang, K::MatchSystem), |
| 74 | 79 | } |
| @@ -232,6 +237,15 @@ mod tests { | ||
| 232 | 237 | fn labels_follow_the_page_language() { |
| 233 | 238 | assert_eq!(theme_label(Lang::En, "dark"), "Dark"); |
| 234 | 239 | assert_eq!(theme_label(Lang::Tr, "dark"), "Koyu"); |
| 240 | + } | |
| 241 | + | |
| 242 | + #[test] | |
| 243 | + fn light_is_marked_as_the_default() { | |
| 244 | + assert_eq!(theme_label(Lang::En, "light"), "Light (default)"); | |
| 245 | + assert_eq!( | |
| 246 | + theme_label(Lang::Tr, "light"), | |
| 247 | + "A\u{e7}\u{131}k (varsay\u{131}lan)" | |
| 248 | + ); | |
| 235 | 249 | assert_eq!(language_label(Lang::Tr, "tr"), "T\u{fc}rk\u{e7}e"); |
| 236 | 250 | } |
| 237 | 251 | } |
crates/forge-web/src/theme.rs| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | //! The colour theme for a request. A signed-in user's stored preference wins. Anonymous |
| 2 | -//! visitors use the `forge_theme` cookie, which the footer form sets. `auto`, or no choice at | |
| 3 | -//! all, renders no `data-theme`, so the stylesheet follows the operating system. | |
| 2 | +//! visitors use the `forge_theme` cookie, which the footer form sets. The default is light: a | |
| 3 | +//! visitor with no explicit choice gets light whatever their operating system uses. Only an | |
| 4 | +//! explicit `auto` renders no `data-theme`, so the stylesheet follows the operating system. | |
| 4 | 5 | |
| 5 | 6 | use std::time::Duration; |
| 6 | 7 | |
| @@ -15,8 +16,8 @@ pub const THEME_COOKIE: &str = "forge_theme"; | ||
| 15 | 16 | pub const THEME_COOKIE_TTL: Duration = Duration::from_secs(365 * 24 * 60 * 60); |
| 16 | 17 | |
| 17 | 18 | /// The stored colour preference for this request: `"auto"`, `"light"` or `"dark"`. A signed-in |
| 18 | -/// user's stored preference wins. Anonymous visitors use the cookie. Anything else reads as | |
| 19 | -/// `"auto"`. | |
| 19 | +/// user's stored preference wins. Anonymous visitors use the cookie. A missing, empty or | |
| 20 | +/// unknown value reads as `"light"`, the default. Only an explicit `"auto"` follows the system. | |
| 20 | 21 | pub fn preference(cx: &Cx, viewer: Option<&User>) -> &'static str { |
| 21 | 22 | let stored = match viewer { |
| 22 | 23 | Some(user) => user.theme.clone(), |
| @@ -25,10 +26,16 @@ pub fn preference(cx: &Cx, viewer: Option<&User>) -> &'static str { | ||
| 25 | 26 | .map(|cookie| cookie.value().to_owned()) |
| 26 | 27 | .unwrap_or_default(), |
| 27 | 28 | }; |
| 28 | - match stored.as_str() { | |
| 29 | - "light" => "light", | |
| 29 | + resolve_preference(&stored) | |
| 30 | +} | |
| 31 | + | |
| 32 | +/// Maps a stored value to a preference. Only `"auto"` and `"dark"` are kept as they are. Every | |
| 33 | +/// other value, including the empty string, means the default, `"light"`. | |
| 34 | +pub fn resolve_preference(stored: &str) -> &'static str { | |
| 35 | + match stored { | |
| 36 | + "auto" => "auto", | |
| 30 | 37 | "dark" => "dark", |
| 31 | - _ => "auto", | |
| 38 | + _ => "light", | |
| 32 | 39 | } |
| 33 | 40 | } |
| 34 | 41 | |
| @@ -41,13 +48,14 @@ pub fn data_theme(cx: &Cx, viewer: Option<&User>) -> Option<&'static str> { | ||
| 41 | 48 | } |
| 42 | 49 | } |
| 43 | 50 | |
| 44 | -/// The preference the top-bar button switches to from `current`: auto, then light, then dark, | |
| 45 | -/// then back to auto. | |
| 51 | +/// The preference the top-bar button switches to from `current`: light, then dark, then auto, | |
| 52 | +/// then back to light. Anything that is not `"light"` or `"dark"` (including `"auto"`) moves on | |
| 53 | +/// to light, the first step of the cycle. | |
| 46 | 54 | pub fn next_preference(current: &str) -> &'static str { |
| 47 | 55 | match current { |
| 48 | - "auto" => "light", | |
| 49 | 56 | "light" => "dark", |
| 50 | - _ => "auto", | |
| 57 | + "dark" => "auto", | |
| 58 | + _ => "light", | |
| 51 | 59 | } |
| 52 | 60 | } |
| 53 | 61 | |
| @@ -68,18 +76,43 @@ pub fn set_theme_cookie(cx: &Cx, theme: &str) { | ||
| 68 | 76 | |
| 69 | 77 | #[cfg(test)] |
| 70 | 78 | mod tests { |
| 71 | - use super::next_preference; | |
| 79 | + use super::{next_preference, resolve_preference}; | |
| 72 | 80 | |
| 73 | 81 | #[test] |
| 74 | - fn the_toggle_cycles_auto_light_dark_auto() { | |
| 75 | - assert_eq!(next_preference("auto"), "light"); | |
| 82 | + fn the_toggle_cycles_light_dark_auto_light() { | |
| 76 | 83 | assert_eq!(next_preference("light"), "dark"); |
| 77 | 84 | assert_eq!(next_preference("dark"), "auto"); |
| 85 | + assert_eq!(next_preference("auto"), "light"); | |
| 86 | + } | |
| 87 | + | |
| 88 | + #[test] | |
| 89 | + fn unknown_preferences_move_on_to_light() { | |
| 90 | + assert_eq!(next_preference(""), "light"); | |
| 91 | + assert_eq!(next_preference("neon"), "light"); | |
| 92 | + } | |
| 93 | + | |
| 94 | + #[test] | |
| 95 | + fn no_choice_means_light() { | |
| 96 | + assert_eq!(resolve_preference(""), "light"); | |
| 97 | + assert_eq!(resolve_preference("neon"), "light"); | |
| 98 | + assert_eq!(resolve_preference("light"), "light"); | |
| 99 | + } | |
| 100 | + | |
| 101 | + #[test] | |
| 102 | + fn explicit_choices_are_kept() { | |
| 103 | + assert_eq!(resolve_preference("dark"), "dark"); | |
| 104 | + assert_eq!(resolve_preference("auto"), "auto"); | |
| 78 | 105 | } |
| 79 | 106 | |
| 80 | 107 | #[test] |
| 81 | - fn unknown_preferences_start_the_cycle_at_auto() { | |
| 82 | - assert_eq!(next_preference(""), "auto"); | |
| 83 | - assert_eq!(next_preference("neon"), "auto"); | |
| 108 | + fn the_default_cycle_starts_on_light_and_reaches_every_choice() { | |
| 109 | + let start = resolve_preference(""); | |
| 110 | + assert_eq!(start, "light"); | |
| 111 | + assert_eq!(next_preference(start), "dark"); | |
| 112 | + assert_eq!(next_preference(next_preference(start)), "auto"); | |
| 113 | + assert_eq!( | |
| 114 | + next_preference(next_preference(next_preference(start))), | |
| 115 | + "light" | |
| 116 | + ); | |
| 84 | 117 | } |
| 85 | 118 | } |
crates/forge-web/src/views/review_thread.rs| @@ -14,7 +14,7 @@ use crate::i18n::Lang; | ||
| 14 | 14 | use crate::i18n::pulls_review::{self as g, K}; |
| 15 | 15 | use crate::pages::issue_parts::When; |
| 16 | 16 | use crate::render::markdown::{LinkContext, render_markdown}; |
| 17 | -use crate::views::{avatar_url, display_name, now_unix}; | |
| 17 | +use crate::views::{avatar_url, display_name, now_unix, user_href, user_link}; | |
| 18 | 18 | |
| 19 | 19 | /// What a thread needs from its page: where the review POSTs go, the CSRF token, who is viewing |
| 20 | 20 | /// and what they may do. Built once per page and cloned into each thread. |
| @@ -45,6 +45,7 @@ pub struct ThreadCtx { | ||
| 45 | 45 | #[derive(Clone)] |
| 46 | 46 | struct CommentRow { |
| 47 | 47 | author: String, |
| 48 | + login: String, | |
| 48 | 49 | avatar: String, |
| 49 | 50 | when: When, |
| 50 | 51 | html: String, |
| @@ -76,6 +77,7 @@ fn comment_row(comment: ReviewComment, ctx: &ThreadCtx, now: i64, lang: Lang) -> | ||
| 76 | 77 | CommentRow { |
| 77 | 78 | avatar: avatar_url(&comment.author_name, None), |
| 78 | 79 | author: display_name(lang, &comment.author_name), |
| 80 | + login: comment.author_name.clone(), | |
| 79 | 81 | when: When::new(lang, &comment.created_at, &ctx.tz, now), |
| 80 | 82 | html: render_markdown(&comment.body, ctx.link.as_ref()), |
| 81 | 83 | history_href: edited |
| @@ -313,6 +315,7 @@ async fn thread_comment( | ||
| 313 | 315 | ) -> Result<impl View> { |
| 314 | 316 | let CommentRow { |
| 315 | 317 | author, |
| 318 | + login, | |
| 316 | 319 | avatar, |
| 317 | 320 | when, |
| 318 | 321 | html, |
| @@ -320,14 +323,15 @@ async fn thread_comment( | ||
| 320 | 323 | edit_href, |
| 321 | 324 | delete_href, |
| 322 | 325 | } = comment; |
| 326 | + let profile = user_href(&login); | |
| 323 | 327 | let edited = g::t(lang, K::Edited); |
| 324 | 328 | let edit = g::t(lang, K::EditLink); |
| 325 | 329 | let delete = g::t(lang, K::DeleteButton); |
| 326 | 330 | Ok(view! { |
| 327 | 331 | <article class="review-comment"> |
| 328 | 332 | <header class="review-comment-head"> |
| 329 | - <img class="avatar avatar-sm" src=(avatar.as_str()) alt="" width="24" height="24"> | |
| 330 | - <strong>(author.as_str())</strong> | |
| 333 | + <a href=(profile.as_str())><img class="avatar avatar-sm" src=(avatar.as_str()) alt="" width="24" height="24"></a> | |
| 334 | + <strong>user_link(shown: author.as_str(), login: login.as_str())</strong> | |
| 331 | 335 | <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time> |
| 332 | 336 | if let Some(href) = history_href { |
| 333 | 337 | <a class="issue-edited" href=(href.as_str())>(edited)</a> |
crates/forge-web/tests/community_i18n_e2e.rs| @@ -51,7 +51,7 @@ async fn snippets_page_follows_the_browser_language() { | ||
| 51 | 51 | .expect("GET /snippets (tr)"); |
| 52 | 52 | assert_eq!(turkish.status().as_u16(), 200); |
| 53 | 53 | let body = turkish.text().await.expect("read body"); |
| 54 | - assert!(body.contains("<html lang=\"tr\">"), "html lang is tr"); | |
| 54 | + assert!(body.contains("<html lang=\"tr\""), "html lang is tr"); | |
| 55 | 55 | assert!(body.contains("<h1>Parçacıklar</h1>"), "Turkish heading"); |
| 56 | 56 | assert!( |
| 57 | 57 | body.contains("Gösterilecek parçacık yok."), |
crates/forge-web/tests/i18n_e2e.rs| @@ -179,7 +179,7 @@ async fn anonymous_pages_follow_the_browser_language() { | ||
| 179 | 179 | .await; |
| 180 | 180 | assert_eq!(turkish.status, 200); |
| 181 | 181 | assert!( |
| 182 | - turkish.body.contains("<html lang=\"tr\">"), | |
| 182 | + turkish.body.contains("<html lang=\"tr\""), | |
| 183 | 183 | "html lang is tr" |
| 184 | 184 | ); |
| 185 | 185 | assert!(turkish.body.contains("Giriş yap"), "Turkish button label"); |
| @@ -194,16 +194,16 @@ async fn anonymous_pages_follow_the_browser_language() { | ||
| 194 | 194 | |
| 195 | 195 | let english = env.http.get("/login", None, Some("en-US,en;q=0.9")).await; |
| 196 | 196 | assert!( |
| 197 | - english.body.contains("<html lang=\"en\">"), | |
| 197 | + english.body.contains("<html lang=\"en\""), | |
| 198 | 198 | "html lang is en" |
| 199 | 199 | ); |
| 200 | 200 | assert!(english.body.contains("Username"), "English label"); |
| 201 | 201 | |
| 202 | 202 | // No header, and a language this build does not have: English. |
| 203 | 203 | let plain = env.http.get("/login", None, None).await; |
| 204 | - assert!(plain.body.contains("<html lang=\"en\">")); | |
| 204 | + assert!(plain.body.contains("<html lang=\"en\"")); | |
| 205 | 205 | let german = env.http.get("/login", None, Some("de-DE")).await; |
| 206 | - assert!(german.body.contains("<html lang=\"en\">")); | |
| 206 | + assert!(german.body.contains("<html lang=\"en\"")); | |
| 207 | 207 | } |
| 208 | 208 | |
| 209 | 209 | #[tokio::test] |
| @@ -228,7 +228,7 @@ async fn stored_language_beats_the_browser_for_a_signed_in_user() { | ||
| 228 | 228 | .get("/settings/security", Some(&cookie), Some("en-US,en;q=0.9")) |
| 229 | 229 | .await; |
| 230 | 230 | assert_eq!(page.status, 200, "security settings page"); |
| 231 | - assert!(page.body.contains("<html lang=\"tr\">"), "html lang is tr"); | |
| 231 | + assert!(page.body.contains("<html lang=\"tr\""), "html lang is tr"); | |
| 232 | 232 | assert!(page.body.contains("Güvenlik"), "Turkish heading"); |
| 233 | 233 | assert!(page.body.contains("Parolayı değiştir"), "Turkish button"); |
| 234 | 234 | } |
crates/forge-web/tests/ui_prefs_e2e.rs| @@ -215,9 +215,13 @@ async fn appearance_setting_persists_and_renders_the_html_attribute() { | ||
| 215 | 215 | assert_eq!(page.status, 200); |
| 216 | 216 | assert_eq!( |
| 217 | 217 | selected_option(&page.body, "theme").as_deref(), |
| 218 | - Some("auto") | |
| 218 | + Some("light") | |
| 219 | + ); | |
| 220 | + assert_eq!( | |
| 221 | + html_theme(&page.body).as_deref(), | |
| 222 | + Some("light"), | |
| 223 | + "the default is light, which renders data-theme=light" | |
| 219 | 224 | ); |
| 220 | - assert_eq!(html_theme(&page.body), None, "auto renders no data-theme"); | |
| 221 | 225 | let csrf = input_value(&page.body, "csrf"); |
| 222 | 226 | |
| 223 | 227 | let saved = env |
| @@ -277,7 +281,7 @@ async fn appearance_save_without_a_valid_csrf_token_is_403() { | ||
| 277 | 281 | let page = env.http.get("/settings/appearance", Some(&session)).await; |
| 278 | 282 | assert_eq!( |
| 279 | 283 | selected_option(&page.body, "theme").as_deref(), |
| 280 | - Some("auto"), | |
| 284 | + Some("light"), | |
| 281 | 285 | "nothing saved" |
| 282 | 286 | ); |
| 283 | 287 | } |
| @@ -309,7 +313,7 @@ async fn unoffered_theme_or_language_is_rejected_and_nothing_is_saved() { | ||
| 309 | 313 | let page = env.http.get("/settings/appearance", Some(&session)).await; |
| 310 | 314 | assert_eq!( |
| 311 | 315 | selected_option(&page.body, "theme").as_deref(), |
| 312 | - Some("auto") | |
| 316 | + Some("light") | |
| 313 | 317 | ); |
| 314 | 318 | assert_eq!( |
| 315 | 319 | selected_option(&page.body, "language").as_deref(), |
| @@ -321,7 +325,11 @@ async fn unoffered_theme_or_language_is_rejected_and_nothing_is_saved() { | ||
| 321 | 325 | async fn anonymous_footer_cookie_sets_the_theme() { |
| 322 | 326 | let env = start().await; |
| 323 | 327 | let page = env.http.get("/login", None).await; |
| 324 | - assert_eq!(html_theme(&page.body), None, "no cookie, no data-theme"); | |
| 328 | + assert_eq!( | |
| 329 | + html_theme(&page.body).as_deref(), | |
| 330 | + Some("light"), | |
| 331 | + "no cookie defaults to light" | |
| 332 | + ); | |
| 325 | 333 | assert!( |
| 326 | 334 | page.body.contains("action=\"/-/theme\""), |
| 327 | 335 | "anonymous footer offers the theme form" |
| @@ -361,7 +369,11 @@ async fn signed_in_preference_wins_over_the_anonymous_cookie() { | ||
| 361 | 369 | ); |
| 362 | 370 | let cookie = format!("{session}; forge_theme=dark"); |
| 363 | 371 | let page = env.http.get("/settings/appearance", Some(&cookie)).await; |
| 364 | - assert_eq!(html_theme(&page.body), None, "auto user ignores the cookie"); | |
| 372 | + assert_eq!( | |
| 373 | + html_theme(&page.body).as_deref(), | |
| 374 | + Some("light"), | |
| 375 | + "a light user ignores the cookie" | |
| 376 | + ); | |
| 365 | 377 | } |
| 366 | 378 | |
| 367 | 379 | #[tokio::test] |
| @@ -463,7 +475,7 @@ async fn signed_in_theme_toggle_without_a_valid_csrf_is_refused() { | ||
| 463 | 475 | let page = env.http.get("/settings/appearance", Some(&session)).await; |
| 464 | 476 | assert_eq!( |
| 465 | 477 | selected_option(&page.body, "theme").as_deref(), |
| 466 | - Some("auto"), | |
| 478 | + Some("light"), | |
| 467 | 479 | "nothing saved" |
| 468 | 480 | ); |
| 469 | 481 | } |
| @@ -485,28 +497,14 @@ async fn anonymous_theme_toggle_sets_the_cookie_and_returns_to_back() { | ||
| 485 | 497 | } |
| 486 | 498 | |
| 487 | 499 | #[tokio::test] |
| 488 | -async fn theme_button_cycles_auto_light_dark_for_signed_in_users() { | |
| 500 | +async fn theme_button_cycles_light_dark_auto_light_for_signed_in_users() { | |
| 489 | 501 | let env = start().await; |
| 490 | 502 | let session = login(&env.http, "alice").await; |
| 491 | 503 | let back = "/settings/appearance"; |
| 492 | 504 | |
| 505 | + // A new user starts on light, the default, so the button offers dark next. | |
| 493 | 506 | let page = env.http.get(back, Some(&session)).await; |
| 494 | 507 | let csrf = input_value(&page.body, "csrf"); |
| 495 | - assert_eq!( | |
| 496 | - theme_button(&page.body), | |
| 497 | - ("light".into(), "Theme: Match the system. Switch to Light".into()) | |
| 498 | - ); | |
| 499 | - | |
| 500 | - let step = env | |
| 501 | - .http | |
| 502 | - .post_form( | |
| 503 | - "/-/theme", | |
| 504 | - &[("csrf", &csrf), ("theme", "light"), ("back", back)], | |
| 505 | - Some(&session), | |
| 506 | - ) | |
| 507 | - .await; | |
| 508 | - assert_eq!(step.status, 303, "auto to light: {}", step.body); | |
| 509 | - let page = env.http.get(back, Some(&session)).await; | |
| 510 | 508 | assert_eq!(html_theme(&page.body).as_deref(), Some("light")); |
| 511 | 509 | assert_eq!( |
| 512 | 510 | theme_button(&page.body), |
| @@ -550,17 +548,33 @@ async fn theme_button_cycles_auto_light_dark_for_signed_in_users() { | ||
| 550 | 548 | assert_eq!(html_theme(&page.body), None, "auto renders no data-theme"); |
| 551 | 549 | assert_eq!( |
| 552 | 550 | theme_button(&page.body), |
| 553 | - ("light".into(), "Theme: Match the system. Switch to Light".into()) | |
| 551 | + ( | |
| 552 | + "light".into(), | |
| 553 | + "Theme: Match the system. Switch to Light".into() | |
| 554 | + ) | |
| 554 | 555 | ); |
| 556 | + | |
| 557 | + let step = env | |
| 558 | + .http | |
| 559 | + .post_form( | |
| 560 | + "/-/theme", | |
| 561 | + &[("csrf", &csrf), ("theme", "light"), ("back", back)], | |
| 562 | + Some(&session), | |
| 563 | + ) | |
| 564 | + .await; | |
| 565 | + assert_eq!(step.status, 303, "auto to light: {}", step.body); | |
| 566 | + let page = env.http.get(back, Some(&session)).await; | |
| 567 | + assert_eq!(html_theme(&page.body).as_deref(), Some("light")); | |
| 555 | 568 | } |
| 556 | 569 | |
| 557 | 570 | #[tokio::test] |
| 558 | 571 | async fn theme_button_cycles_for_anonymous_visitors_through_the_cookie() { |
| 559 | 572 | let env = start().await; |
| 573 | + // No cookie is the default, light, so the button offers dark next. | |
| 560 | 574 | let page = env.http.get("/login", None).await; |
| 561 | 575 | assert_eq!( |
| 562 | 576 | theme_button(&page.body), |
| 563 | - ("light".into(), "Theme: Match the system. Switch to Light".into()) | |
| 577 | + ("dark".into(), "Theme: Light. Switch to Dark".into()) | |
| 564 | 578 | ); |
| 565 | 579 | |
| 566 | 580 | let page = env.http.get("/login", Some("forge_theme=light")).await; |
| @@ -573,6 +587,9 @@ async fn theme_button_cycles_for_anonymous_visitors_through_the_cookie() { | ||
| 573 | 587 | let page = env.http.get("/login", Some("forge_theme=dark")).await; |
| 574 | 588 | assert_eq!( |
| 575 | 589 | theme_button(&page.body), |
| 576 | - ("auto".into(), "Theme: Dark. Switch to match the system".into()) | |
| 590 | + ( | |
| 591 | + "auto".into(), | |
| 592 | + "Theme: Dark. Switch to match the system".into() | |
| 593 | + ) | |
| 577 | 594 | ); |
| 578 | 595 | } |
style/_repo.scss| @@ -85,34 +85,14 @@ | ||
| 85 | 85 | |
| 86 | 86 | .fork-form, |
| 87 | 87 | .star-form, |
| 88 | - .watch-form { | |
| 88 | + .watch-control { | |
| 89 | 89 | display: inline-flex; |
| 90 | 90 | align-items: center; |
| 91 | 91 | gap: 6px; |
| 92 | 92 | margin: 0; |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | - .watch-label { | |
| 96 | - display: inline-flex; | |
| 97 | - } | |
| 98 | - | |
| 99 | - .watch-form select { | |
| 100 | - height: 28px; | |
| 101 | - padding: 0 26px 0 8px; | |
| 102 | - font-size: 13px; | |
| 103 | - color: var(--text); | |
| 104 | - background: var(--surface); | |
| 105 | - border: 1px solid var(--border-strong); | |
| 106 | - border-radius: var(--radius); | |
| 107 | - } | |
| 108 | - | |
| 109 | - .watch-form .button { | |
| 110 | - height: 28px; | |
| 111 | - padding: 0 10px; | |
| 112 | - font-size: 13px; | |
| 113 | - } | |
| 114 | - | |
| 115 | - .watch-form .muted, | |
| 95 | + .watch-control .muted, | |
| 116 | 96 | .star-control .muted, |
| 117 | 97 | > .muted { |
| 118 | 98 | font-size: 13px; |
| @@ -262,7 +242,8 @@ | ||
| 262 | 242 | } |
| 263 | 243 | } |
| 264 | 244 | |
| 265 | -// Two columns at 1100px and up: the directory and README, then the repo-meta aside. | |
| 245 | +// Two columns at 900px and up: the directory and README take the remaining width, then a | |
| 246 | +// narrow repo-meta aside (about 230px). Below 900px the aside stacks under the main column. | |
| 266 | 247 | .repo-grid { |
| 267 | 248 | display: grid; |
| 268 | 249 | grid-template-columns: minmax(0, 1fr); |
| @@ -303,9 +284,9 @@ | ||
| 303 | 284 | } |
| 304 | 285 | } |
| 305 | 286 | |
| 306 | -@media (min-width: 1100px) { | |
| 287 | +@media (min-width: 900px) { | |
| 307 | 288 | .repo-grid { |
| 308 | - grid-template-columns: minmax(0, 1fr) 260px; | |
| 289 | + grid-template-columns: minmax(0, 1fr) 230px; | |
| 309 | 290 | gap: 40px; |
| 310 | 291 | align-items: start; |
| 311 | 292 | } |
| @@ -554,7 +535,7 @@ | ||
| 554 | 535 | display: flex; |
| 555 | 536 | flex-direction: column; |
| 556 | 537 | gap: 16px; |
| 557 | - max-width: 880px; | |
| 538 | + max-width: 960px; | |
| 558 | 539 | width: 100%; |
| 559 | 540 | min-width: 0; |
| 560 | 541 |
style/_shell.scss| @@ -344,7 +344,7 @@ | ||
| 344 | 344 | box-sizing: border-box; |
| 345 | 345 | width: 100%; |
| 346 | 346 | min-width: 0; |
| 347 | - max-width: 1100px; | |
| 347 | + max-width: 1280px; | |
| 348 | 348 | margin: 0 auto; |
| 349 | 349 | padding: 24px 16px 48px; |
| 350 | 350 | } |
style/main.scss| @@ -283,7 +283,7 @@ pre { | ||
| 283 | 283 | } |
| 284 | 284 | |
| 285 | 285 | .container { |
| 286 | - max-width: 960px; | |
| 286 | + max-width: 1280px; | |
| 287 | 287 | margin: 0 auto; |
| 288 | 288 | padding: 0 16px; |
| 289 | 289 | } |
| @@ -2110,7 +2110,7 @@ code { | ||
| 2110 | 2110 | } |
| 2111 | 2111 | |
| 2112 | 2112 | .issue-new { |
| 2113 | - max-width: 48rem; | |
| 2113 | + max-width: 60rem; | |
| 2114 | 2114 | |
| 2115 | 2115 | textarea { |
| 2116 | 2116 | min-height: 14rem; |
| @@ -3166,21 +3166,129 @@ span.reaction { | ||
| 3166 | 3166 | } |
| 3167 | 3167 | } |
| 3168 | 3168 | |
| 3169 | -.watch-form { | |
| 3169 | +// Watch control: a dropdown whose summary shows the current level. Each level is a small POST form | |
| 3170 | +// inside the menu; the current one carries a check mark. | |
| 3171 | +.watch-control { | |
| 3170 | 3172 | display: inline-flex; |
| 3171 | 3173 | flex-wrap: wrap; |
| 3172 | 3174 | align-items: center; |
| 3173 | 3175 | gap: 0.5rem; |
| 3174 | 3176 | margin: 0; |
| 3177 | +} | |
| 3175 | 3178 | |
| 3176 | - select { | |
| 3177 | - padding: 0.3rem 0.5rem; | |
| 3178 | - border: 1px solid var(--border); | |
| 3179 | - border-radius: var(--radius); | |
| 3180 | - background: var(--surface); | |
| 3179 | +.watch-menu { | |
| 3180 | + summary { | |
| 3181 | + position: relative; | |
| 3182 | + display: inline-flex; | |
| 3183 | + align-items: center; | |
| 3184 | + gap: 6px; | |
| 3185 | + height: 28px; | |
| 3186 | + padding: 0 26px 0 8px; | |
| 3187 | + font-size: 13px; | |
| 3188 | + font-weight: 500; | |
| 3181 | 3189 | color: var(--text); |
| 3182 | - font: inherit; | |
| 3190 | + background: var(--surface); | |
| 3191 | + border: 1px solid var(--border-strong); | |
| 3192 | + white-space: nowrap; | |
| 3193 | + | |
| 3194 | + // The caret: a small rotated border, so no glyph is needed. | |
| 3195 | + &::after { | |
| 3196 | + content: ""; | |
| 3197 | + position: absolute; | |
| 3198 | + right: 11px; | |
| 3199 | + top: 50%; | |
| 3200 | + width: 5px; | |
| 3201 | + height: 5px; | |
| 3202 | + border-right: 1.5px solid currentColor; | |
| 3203 | + border-bottom: 1.5px solid currentColor; | |
| 3204 | + transform: translateY(-70%) rotate(45deg); | |
| 3205 | + } | |
| 3206 | + } | |
| 3207 | + | |
| 3208 | + &[open] summary { | |
| 3209 | + border-color: var(--accent); | |
| 3210 | + } | |
| 3211 | +} | |
| 3212 | + | |
| 3213 | +.watch-eye { | |
| 3214 | + flex: none; | |
| 3215 | + width: 15px; | |
| 3216 | + height: 15px; | |
| 3217 | + fill: none; | |
| 3218 | + stroke: currentColor; | |
| 3219 | + stroke-width: 2; | |
| 3220 | + stroke-linecap: round; | |
| 3221 | + stroke-linejoin: round; | |
| 3222 | +} | |
| 3223 | + | |
| 3224 | +.watch-list { | |
| 3225 | + left: 0; | |
| 3226 | + right: auto; | |
| 3227 | + min-width: 280px; | |
| 3228 | + max-width: calc(100vw - 24px); | |
| 3229 | +} | |
| 3230 | + | |
| 3231 | +.watch-option-form { | |
| 3232 | + margin: 0; | |
| 3233 | +} | |
| 3234 | + | |
| 3235 | +.watch-list button.watch-option { | |
| 3236 | + display: grid; | |
| 3237 | + grid-template-columns: 18px 1fr; | |
| 3238 | + column-gap: 4px; | |
| 3239 | + width: 100%; | |
| 3240 | + margin: 0; | |
| 3241 | + padding: 8px 14px; | |
| 3242 | + text-align: left; | |
| 3243 | + font: inherit; | |
| 3244 | + color: var(--text); | |
| 3245 | + background: none; | |
| 3246 | + border: 0; | |
| 3247 | + border-radius: 0; | |
| 3248 | + cursor: pointer; | |
| 3249 | + | |
| 3250 | + &:hover { | |
| 3251 | + background: var(--surface-2); | |
| 3252 | + } | |
| 3253 | + | |
| 3254 | + &:focus-visible { | |
| 3255 | + outline: 2px solid var(--focus); | |
| 3256 | + outline-offset: -2px; | |
| 3183 | 3257 | } |
| 3258 | + | |
| 3259 | + // The check mark, drawn with borders. Only the current level shows it. | |
| 3260 | + &::before { | |
| 3261 | + content: ""; | |
| 3262 | + grid-column: 1; | |
| 3263 | + grid-row: 1 / span 2; | |
| 3264 | + justify-self: center; | |
| 3265 | + align-self: start; | |
| 3266 | + width: 5px; | |
| 3267 | + height: 9px; | |
| 3268 | + margin-top: 3px; | |
| 3269 | + border: solid transparent; | |
| 3270 | + border-width: 0 2px 2px 0; | |
| 3271 | + transform: rotate(45deg); | |
| 3272 | + } | |
| 3273 | + | |
| 3274 | + &.is-current::before { | |
| 3275 | + border-color: var(--accent); | |
| 3276 | + } | |
| 3277 | +} | |
| 3278 | + | |
| 3279 | +.watch-option-title { | |
| 3280 | + grid-column: 2; | |
| 3281 | + grid-row: 1; | |
| 3282 | + font-weight: 500; | |
| 3283 | +} | |
| 3284 | + | |
| 3285 | +.watch-option-desc { | |
| 3286 | + grid-column: 2; | |
| 3287 | + grid-row: 2; | |
| 3288 | + font-size: 12px; | |
| 3289 | + line-height: 1.4; | |
| 3290 | + color: var(--muted); | |
| 3291 | + white-space: normal; | |
| 3184 | 3292 | } |
| 3185 | 3293 | |
| 3186 | 3294 | @media (max-width: 40rem) { |
| @@ -3246,7 +3354,7 @@ span.reaction { | ||
| 3246 | 3354 | } |
| 3247 | 3355 | |
| 3248 | 3356 | .release-form { |
| 3249 | - max-width: 720px; | |
| 3357 | + max-width: 960px; | |
| 3250 | 3358 | |
| 3251 | 3359 | textarea { |
| 3252 | 3360 | width: 100%; |
| @@ -3891,3 +3999,34 @@ span.reaction { | ||
| 3891 | 3999 | @import "repo"; |
| 3892 | 4000 | @import "pull"; |
| 3893 | 4001 | @import "dashboard"; |
| 4002 | + | |
| 4003 | +// Code inside cards, notices and settings panels must stay inside the card. Long tokens and | |
| 4004 | +// commands wrap instead of running past the right edge. The rendered README card (.readme) and | |
| 4005 | +// file views, diffs and highlighted blobs are left alone so their code keeps horizontal scrolling. | |
| 4006 | +.card:not(.readme), | |
| 4007 | +.notice { | |
| 4008 | + code { | |
| 4009 | + overflow-wrap: anywhere; | |
| 4010 | + word-break: normal; | |
| 4011 | + } | |
| 4012 | + | |
| 4013 | + pre { | |
| 4014 | + max-width: 100%; | |
| 4015 | + margin: 0 0 12px; | |
| 4016 | + padding: 12px; | |
| 4017 | + overflow-x: auto; | |
| 4018 | + white-space: pre-wrap; | |
| 4019 | + overflow-wrap: anywhere; | |
| 4020 | + background: var(--code-bg); | |
| 4021 | + border: 1px solid var(--border); | |
| 4022 | + border-radius: var(--radius); | |
| 4023 | + color: var(--text); | |
| 4024 | + | |
| 4025 | + code { | |
| 4026 | + padding: 0; | |
| 4027 | + background: none; | |
| 4028 | + white-space: inherit; | |
| 4029 | + overflow-wrap: anywhere; | |
| 4030 | + } | |
| 4031 | + } | |
| 4032 | +} |