forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0

💄 feat(ui): light theme by default, profile links, wider layout

Tahinli/forge

bfa240da84c134ff7b2e81c937ad48226e988ae4

Author Ahmet Kaan Gümüş ·

Parent: 9093535

Browse files at this commit

💄 feat(ui): light theme by default, profile links, wider layout

- light theme is the default for everyone, including existing users (migration 0036)
- add "Your profile" menu link; usernames link to profiles
- widen layout to 1280px
- code blocks in cards wrap instead of overflowing
- dropdowns close on outside click and Escape
- GitHub-style watch menu

24 changed files +362 -131

crates/forge-core/migrations/0036_default_light_theme.sqladded+5 -0
@@ -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.rsmodified+2 -2
@@ -300,14 +300,14 @@ impl Store {
300300 about: String::new(),
301301 timezone: "UTC".to_owned(),
302302 avatar_hash: None,
303- theme: "auto".into(),
303+ theme: "light".into(),
304304 language: "auto".into(),
305305 kind: AccountKind::User,
306306 email_notifications: true,
307307 };
308308 tx.execute(
309309 "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')",
311311 (
312312 user.id.clone(),
313313 user.username.clone(),
crates/forge-core/src/store/migrate.rsmodified+4 -0
@@ -51,6 +51,10 @@ pub const MIGRATIONS: &[(u32, &str)] = &[
5151 35,
5252 include_str!("../../migrations/0035_email_notifications.sql"),
5353 ),
54+ (
55+ 36,
56+ include_str!("../../migrations/0036_default_light_theme.sql"),
57+ ),
5458 ];
5559
5660 /// The highest migration version this binary knows about.
crates/forge-core/src/store/mod.rsmodified+6 -6
@@ -174,15 +174,15 @@ impl Store {
174174 about: String::new(),
175175 timezone: "UTC".to_string(),
176176 avatar_hash: None,
177- theme: "auto".into(),
177+ theme: "light".into(),
178178 language: "auto".into(),
179179 kind: AccountKind::User,
180180 email_notifications: true,
181181 };
182182 let conn = self.inner.conn.lock().await;
183183 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')",
186186 (
187187 user.id.clone(),
188188 user.username.clone(),
@@ -216,7 +216,7 @@ impl Store {
216216 about: String::new(),
217217 timezone: "UTC".to_string(),
218218 avatar_hash: None,
219- theme: "auto".into(),
219+ theme: "light".into(),
220220 language: "auto".into(),
221221 kind: AccountKind::User,
222222 email_notifications: true,
@@ -224,8 +224,8 @@ impl Store {
224224 let conn = self.inner.conn.lock().await;
225225 let inserted = conn
226226 .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)",
229229 (
230230 user.id.clone(),
231231 user.username.clone(),
crates/forge-core/src/store/ui_prefs.rsmodified+1 -1
@@ -97,7 +97,7 @@ mod tests {
9797 .unwrap();
9898 assert_eq!(
9999 store.user_by_id(&user.id).await.unwrap().unwrap().theme,
100- "auto"
100+ "light"
101101 );
102102
103103 store.set_ui_prefs(&user.id, "dark", "tr").await.unwrap();
crates/forge-core/src/store/user_delete.rsmodified+3 -3
@@ -162,14 +162,14 @@ impl Store {
162162 about: String::new(),
163163 timezone: "UTC".to_string(),
164164 avatar_hash: None,
165- theme: "auto".into(),
165+ theme: "light".into(),
166166 language: "auto".into(),
167167 kind: AccountKind::User,
168168 email_notifications: true,
169169 };
170170 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')",
173173 (
174174 user.id.clone(),
175175 user.username.clone(),
crates/forge-web/src/pages/discussion_view.rsmodified+13 -6
@@ -24,7 +24,7 @@ use crate::pages::repo_ctx::{RepoCtx, load_repo};
2424 use crate::pages::tree::link_context;
2525 use crate::render::markdown::{LinkContext, render_markdown};
2626 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};
2828
2929 path_param!(owner);
3030 path_param!(repo);
@@ -35,6 +35,8 @@ pub(crate) struct CommentView {
3535 lang: Lang,
3636 anchor: String,
3737 author: String,
38+ /// The author's username, the link target of the name and avatar.
39+ login: String,
3840 avatar: String,
3941 when: When,
4042 /// Rendered Markdown. Empty when the comment is deleted.
@@ -116,6 +118,7 @@ fn comment_view(
116118 lang: frame.lang,
117119 anchor: format!("comment-{}", c.id),
118120 author: display_name(frame.lang, &username),
121+ login: username.clone(),
119122 avatar: avatar_url(&username, hash.as_deref()),
120123 when: When::new(frame.lang, &c.created_at, &frame.tz, frame.now),
121124 html: if live {
@@ -151,6 +154,7 @@ async fn comment_article(item: CommentView) -> Result<impl View> {
151154 lang,
152155 anchor,
153156 author,
157+ login,
154158 avatar,
155159 when,
156160 html,
@@ -167,6 +171,7 @@ async fn comment_article(item: CommentView) -> Result<impl View> {
167171 csrf,
168172 reactions,
169173 } = item;
174+ let profile = user_href(&login);
170175 let class = if accepted {
171176 "issue-item discussion-accepted"
172177 } else {
@@ -178,8 +183,8 @@ async fn comment_article(item: CommentView) -> Result<impl View> {
178183 if deleted {
179184 <span class="muted">(g::t(lang, K::CommentDeleted))</span>
180185 } 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>
183188 <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time>
184189 }
185190 if accepted {
@@ -353,6 +358,8 @@ async fn discussion_page(
353358 .unwrap_or_else(|| ("ghost".to_owned(), None));
354359 let author = display_name(lang, &author_name);
355360 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();
356363 let opened = When::new(lang, &discussion.created_at, &tz, now);
357364 let body_source = if discussion.body.trim().is_empty() {
358365 g::t(lang, K::NoDescription)
@@ -404,7 +411,7 @@ async fn discussion_page(
404411 if locked {
405412 " " <span class="issue-badge">(g::t(lang, K::Locked))</span>
406413 }
407- " " <strong>(author.as_str())</strong>
414+ " " <strong>user_link(shown: author.as_str(), login: author_login.as_str())</strong>
408415 (g::t(lang, K::OpenedThis))
409416 <time datetime=(opened.iso.as_str()) title=(opened.absolute.as_str())>(opened.relative.as_str())</time>
410417 (g::t(lang, K::OpenedSeparator))
@@ -415,8 +422,8 @@ async fn discussion_page(
415422 <div class="issue-column">
416423 <article class="issue-item" id="discussion-body">
417424 <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>
420427 </header>
421428 <div class="issue-item-body markdown-body">(Unescaped::new_unchecked(body_html))</div>
422429 if let Some(bar) = body_bar {
crates/forge-web/src/pages/discussions.rsmodified+2 -2
@@ -26,7 +26,7 @@ use crate::pages::repo::repo_head;
2626 use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo};
2727 use crate::render::markdown::mentioned_usernames;
2828 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};
3030
3131 path_param!(owner);
3232 path_param!(repo);
@@ -327,7 +327,7 @@ async fn discussions_list_page(cx: &Cx) -> Result<impl View> {
327327 </div>
328328 <div class="issue-meta muted small">
329329 <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>
331331 <span>(by_middle)</span>
332332 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
333333 if !by_after.is_empty() {
crates/forge-web/src/pages/issue_parts.rsmodified+18 -4
@@ -28,6 +28,7 @@ use crate::views::review_thread::{ThreadCtx, review_thread_in};
2828 use crate::views::user_facing_message;
2929 use crate::views::{
3030 alert, avatar_url, display_name, iso_time, now_unix, relative_time, repo_href, short_oid,
31+ user_href, user_link,
3132 };
3233 use crate::webhooks::emit::{Sender, emit_comment, emit_item_action};
3334
@@ -220,6 +221,8 @@ pub(crate) fn is_hex_oid(s: &str) -> bool {
220221 pub(crate) struct CommentView {
221222 anchor: String,
222223 author: String,
224+ /// The author's username, the link target of the name and avatar.
225+ login: String,
223226 avatar: String,
224227 when: When,
225228 html: String,
@@ -269,6 +272,7 @@ pub(crate) struct EventLine {
269272 pub(crate) struct ReviewView {
270273 anchor: String,
271274 author: String,
275+ login: String,
272276 avatar: String,
273277 when: When,
274278 verb: &'static str,
@@ -289,6 +293,8 @@ pub(crate) enum Entry {
289293 pub(crate) struct Discussion {
290294 /// The item's author, by display name, and when it was opened.
291295 pub(crate) author: String,
296+ /// The author's username, for the profile link.
297+ pub(crate) author_login: String,
292298 pub(crate) opened: When,
293299 pub(crate) body: CommentView,
294300 pub(crate) entries: Vec<Entry>,
@@ -380,6 +386,7 @@ pub(crate) async fn comment_item(lang: Lang, item: CommentView) -> Result<impl V
380386 let CommentView {
381387 anchor,
382388 author,
389+ login,
383390 avatar,
384391 when,
385392 html,
@@ -389,11 +396,12 @@ pub(crate) async fn comment_item(lang: Lang, item: CommentView) -> Result<impl V
389396 csrf,
390397 reactions,
391398 } = item;
399+ let profile = user_href(&login);
392400 Ok(view! {
393401 <article class="issue-item" id=(anchor.as_str())>
394402 <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>
397405 <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time>
398406 if let Some(href) = history_href {
399407 <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
538546 let ReviewView {
539547 anchor,
540548 author,
549+ login,
541550 avatar,
542551 when,
543552 verb,
@@ -547,6 +556,7 @@ pub(crate) async fn review_item(lang: Lang, review: ReviewView) -> Result<impl V
547556 threads,
548557 ctx,
549558 } = review;
559+ let profile = user_href(&login);
550560 let verb_class = if dismissed {
551561 "review-verb review-verb-dismissed"
552562 } else {
@@ -555,8 +565,8 @@ pub(crate) async fn review_item(lang: Lang, review: ReviewView) -> Result<impl V
555565 Ok(view! {
556566 <article class="issue-item review-item" id=(anchor.as_str())>
557567 <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>
560570 <span class=(verb_class)>(verb)</span>
561571 <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time>
562572 </header>
@@ -679,6 +689,7 @@ pub(crate) async fn load_discussion(
679689 let body = CommentView {
680690 anchor: body_anchor,
681691 author: display_name(lang, &author),
692+ login: author.clone(),
682693 avatar: avatar_url(&author, author_hash.as_deref()),
683694 when: opened.clone(),
684695 html: render_markdown(body_source, Some(&link)),
@@ -712,6 +723,7 @@ pub(crate) async fn load_discussion(
712723 let item_view = CommentView {
713724 anchor: format!("comment-{}", comment.id),
714725 author: display_name(lang, &who_name),
726+ login: who_name.clone(),
715727 avatar: avatar_url(&who_name, hash.as_deref()),
716728 when: When::new(lang, &comment.created_at, &tz, now),
717729 html: render_markdown(&comment.body, Some(&link)),
@@ -751,6 +763,7 @@ pub(crate) async fn load_discussion(
751763
752764 Ok(Discussion {
753765 author: display_name(lang, &author),
766+ author_login: author,
754767 opened,
755768 body,
756769 entries,
@@ -891,6 +904,7 @@ async fn review_entries(
891904 let view = ReviewView {
892905 anchor: format!("review-{}", review.id),
893906 author: display_name(lang, &review.author_name),
907+ login: review.author_name.clone(),
894908 avatar: avatar_url(&review.author_name, None),
895909 when: When::new(lang, &stamp, tz, now),
896910 verb,
crates/forge-web/src/pages/issue_view.rsmodified+3 -2
@@ -29,7 +29,7 @@ use crate::pages::notifications::notify_box_for;
2929 use crate::pages::repo::repo_head;
3030 use crate::pages::repo_ctx::{RepoCtx, load_repo};
3131 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};
3333 use crate::webhooks::emit::{Sender, emit_comment, emit_item_action};
3434
3535 path_param!(owner);
@@ -59,6 +59,7 @@ async fn issue_page(
5959 let pin_action = format!("{issue_href}/pin");
6060 let notify = notify_box_for(state, &ctx, lang, &issue.id, &issue_href).await?;
6161 let author = discussion.author.clone();
62+ let author_login = discussion.author_login.clone();
6263 let opened = discussion.opened.clone();
6364
6465 let csrf = csrf_of(&ctx);
@@ -94,7 +95,7 @@ async fn issue_page(
9495 <h1 class="issue-heading">(issue.title.as_str()) " " <span class="issue-number">(number_label.as_str())</span></h1>
9596 <p class="issue-byline">
9697 <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>
9899 (g::t(lang, K::OpenedThis))
99100 <time datetime=(opened.iso.as_str()) title=(opened.absolute.as_str())>(opened.relative.as_str())</time>
100101 (g::opened_after(lang))
crates/forge-web/src/pages/issues_list.rsmodified+3 -3
@@ -26,7 +26,7 @@ use crate::state::AppState;
2626 use crate::templates::{self, Template};
2727 use crate::views::{
2828 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,
3030 };
3131 use crate::webhooks::emit::{Sender, emit_issue};
3232
@@ -489,7 +489,7 @@ async fn issues_list_page(cx: &Cx) -> Result<impl View> {
489489 <div class="issue-meta muted small">
490490 <span>(row.number_text.as_str())</span>
491491 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>
493493 <span>(g::t(lang, K::By))</span>
494494 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
495495 <span>(g::t(lang, K::Opened))</span>
@@ -497,7 +497,7 @@ async fn issues_list_page(cx: &Cx) -> Result<impl View> {
497497 <span>(g::t(lang, K::Opened))</span>
498498 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
499499 <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>
501501 }
502502 if let Some(milestone) = row.milestone.clone() {
503503 <a href=(milestone.href.as_str())>(milestone.title.as_str())</a>
crates/forge-web/src/pages/pull_view.rsmodified+13 -3
@@ -41,7 +41,7 @@ use crate::pages::repo_ctx::{RepoCtx, load_repo};
4141 use crate::state::AppState;
4242 use crate::views::commit_status::{StatusLang, StatusSummary, checks_summary, commit_status};
4343 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};
4545
4646 path_param!(owner);
4747 path_param!(repo);
@@ -108,6 +108,7 @@ pub(crate) struct Frame {
108108 pub(crate) page_title: String,
109109 pub(crate) href: String,
110110 author: String,
111+ author_known: bool,
111112 opened: When,
112113 pub(crate) commit_count: usize,
113114 pub(crate) file_count: usize,
@@ -135,6 +136,7 @@ pub(crate) async fn frame_in(
135136 .store
136137 .usernames_by_ids(std::slice::from_ref(&pr.issue.author_id))
137138 .await?;
139+ let author_known = users.contains_key(&pr.issue.author_id);
138140 let author = users
139141 .get(&pr.issue.author_id)
140142 .map(|(display, _)| display.clone())
@@ -165,6 +167,7 @@ pub(crate) async fn frame_in(
165167 page_title: format!("{} #{number} \u{b7} {owner}/{name}", pr.issue.title),
166168 href: repo_href(&owner, &name, "pulls", &number.to_string()),
167169 author,
170+ author_known,
168171 opened: When::new(lang, &pr.issue.created_at, ctx.viewer_tz(), now_unix()),
169172 commit_count,
170173 file_count,
@@ -183,6 +186,7 @@ pub(crate) async fn pull_header(frame: Frame) -> Result<impl View> {
183186 state_class,
184187 state_text,
185188 author,
189+ author_known,
186190 opened,
187191 commit_count,
188192 base_branch,
@@ -203,7 +207,13 @@ pub(crate) async fn pull_header(frame: Frame) -> Result<impl View> {
203207 <h1 class="issue-heading">(title.as_str()) " " <span class="issue-number">(number_label.as_str())</span></h1>
204208 <p class="issue-byline">
205209 <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>
207217 if turkish {
208218 (wants) <code>(head_label.as_str())</code>
209219 (from) <code>(base_branch.as_str())</code>
@@ -349,7 +359,7 @@ async fn merged_notice(
349359 Ok(view! {
350360 <section class="merge-box merge-merged">
351361 <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)
353363 <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time>
354364 if !commit.is_empty() {
355365 (as_sep)
crates/forge-web/src/pages/pulls_list.rsmodified+3 -3
@@ -21,7 +21,7 @@ use crate::pages::issues_list::{
2121 use crate::pages::repo::repo_head;
2222 use crate::pages::repo_ctx::load_repo;
2323 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};
2525
2626 path_param!(owner);
2727 path_param!(repo);
@@ -279,14 +279,14 @@ async fn pulls_list_page(cx: &Cx) -> Result<impl View> {
279279 <span>(row.number_text.as_str())</span>
280280 if lang == Lang::Tr {
281281 <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>
283283 <span>(g::t(lang, K::MetaAfterTime))</span>
284284 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
285285 } else {
286286 <span>(g::t(lang, K::MetaBeforeTime))</span>
287287 <time datetime=(row.iso.as_str()) title=(row.absolute.as_str())>(row.relative.as_str())</time>
288288 <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>
290290 }
291291 if let Some(milestone) = row.milestone.clone() {
292292 <a href=(milestone.href.as_str())>(milestone.title.as_str())</a>
crates/forge-web/src/pages/releases.rsmodified+3 -3
@@ -42,7 +42,7 @@ use crate::pages::repo_mirrors::stamp_in_tz;
4242 use crate::post_update::after_ref_update;
4343 use crate::render::markdown::render_markdown;
4444 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};
4646 use crate::webhooks::emit::{Sender, emit};
4747
4848 path_param!(owner);
@@ -506,7 +506,7 @@ async fn list_body(
506506 <code>(card.tag.as_str())</code>
507507 " \u{b7} " (card.when.as_str())
508508 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())
510510 }
511511 </p>
512512 if !card.body.is_empty() {
@@ -838,7 +838,7 @@ async fn release_tag_page(cx: &Cx) -> Result<impl View> {
838838 <code>(card.tag.as_str())</code>
839839 " \u{b7} " (card.when.as_str())
840840 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())
842842 }
843843 </p>
844844 if !body_html.is_empty() {
crates/forge-web/src/pages/repo.rsmodified+5 -3
@@ -30,7 +30,7 @@ use crate::pages::tree::{
3030 entry_rows, last_commits, load_readme, read_dir, readme_card, tree_table,
3131 };
3232 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};
3434
3535 path_param!(owner);
3636 path_param!(repo);
@@ -84,7 +84,9 @@ pub async fn repo_head(head: RepoHead) -> Result<impl View> {
8484 security,
8585 lang,
8686 } = 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();
8890 let badge = badge_class(visibility);
8991 let vis = i18n::visibility_label(lang, visibility);
9092 let active = active.to_owned();
@@ -136,7 +138,7 @@ pub async fn repo_head(head: RepoHead) -> Result<impl View> {
136138 <section class="page-head">
137139 <div>
138140 <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>
140142 <span class=(badge)>(vis)</span>
141143 </div>
142144 if let Some((href, label)) = fork_line {
crates/forge-web/src/pages/settings_appearance.rsmodified+15 -1
@@ -66,9 +66,14 @@ fn language_label(lang: Lang, code: &str) -> &'static str {
6666 }
6767 }
6868
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.
6971 fn theme_label(lang: Lang, code: &str) -> &'static str {
7072 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+ },
7277 "dark" => g::t(lang, K::Dark),
7378 _ => g::t(lang, K::MatchSystem),
7479 }
@@ -232,6 +237,15 @@ mod tests {
232237 fn labels_follow_the_page_language() {
233238 assert_eq!(theme_label(Lang::En, "dark"), "Dark");
234239 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+ );
235249 assert_eq!(language_label(Lang::Tr, "tr"), "T\u{fc}rk\u{e7}e");
236250 }
237251 }
crates/forge-web/src/theme.rsmodified+50 -17
@@ -1,6 +1,7 @@
11 //! 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.
45
56 use std::time::Duration;
67
@@ -15,8 +16,8 @@ pub const THEME_COOKIE: &str = "forge_theme";
1516 pub const THEME_COOKIE_TTL: Duration = Duration::from_secs(365 * 24 * 60 * 60);
1617
1718 /// 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.
2021 pub fn preference(cx: &Cx, viewer: Option<&User>) -> &'static str {
2122 let stored = match viewer {
2223 Some(user) => user.theme.clone(),
@@ -25,10 +26,16 @@ pub fn preference(cx: &Cx, viewer: Option<&User>) -> &'static str {
2526 .map(|cookie| cookie.value().to_owned())
2627 .unwrap_or_default(),
2728 };
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",
3037 "dark" => "dark",
31- _ => "auto",
38+ _ => "light",
3239 }
3340 }
3441
@@ -41,13 +48,14 @@ pub fn data_theme(cx: &Cx, viewer: Option<&User>) -> Option<&'static str> {
4148 }
4249 }
4350
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.
4654 pub fn next_preference(current: &str) -> &'static str {
4755 match current {
48- "auto" => "light",
4956 "light" => "dark",
50- _ => "auto",
57+ "dark" => "auto",
58+ _ => "light",
5159 }
5260 }
5361
@@ -68,18 +76,43 @@ pub fn set_theme_cookie(cx: &Cx, theme: &str) {
6876
6977 #[cfg(test)]
7078 mod tests {
71- use super::next_preference;
79+ use super::{next_preference, resolve_preference};
7280
7381 #[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() {
7683 assert_eq!(next_preference("light"), "dark");
7784 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");
78105 }
79106
80107 #[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+ );
84117 }
85118 }
crates/forge-web/src/views/review_thread.rsmodified+7 -3
@@ -14,7 +14,7 @@ use crate::i18n::Lang;
1414 use crate::i18n::pulls_review::{self as g, K};
1515 use crate::pages::issue_parts::When;
1616 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};
1818
1919 /// What a thread needs from its page: where the review POSTs go, the CSRF token, who is viewing
2020 /// and what they may do. Built once per page and cloned into each thread.
@@ -45,6 +45,7 @@ pub struct ThreadCtx {
4545 #[derive(Clone)]
4646 struct CommentRow {
4747 author: String,
48+ login: String,
4849 avatar: String,
4950 when: When,
5051 html: String,
@@ -76,6 +77,7 @@ fn comment_row(comment: ReviewComment, ctx: &ThreadCtx, now: i64, lang: Lang) ->
7677 CommentRow {
7778 avatar: avatar_url(&comment.author_name, None),
7879 author: display_name(lang, &comment.author_name),
80+ login: comment.author_name.clone(),
7981 when: When::new(lang, &comment.created_at, &ctx.tz, now),
8082 html: render_markdown(&comment.body, ctx.link.as_ref()),
8183 history_href: edited
@@ -313,6 +315,7 @@ async fn thread_comment(
313315 ) -> Result<impl View> {
314316 let CommentRow {
315317 author,
318+ login,
316319 avatar,
317320 when,
318321 html,
@@ -320,14 +323,15 @@ async fn thread_comment(
320323 edit_href,
321324 delete_href,
322325 } = comment;
326+ let profile = user_href(&login);
323327 let edited = g::t(lang, K::Edited);
324328 let edit = g::t(lang, K::EditLink);
325329 let delete = g::t(lang, K::DeleteButton);
326330 Ok(view! {
327331 <article class="review-comment">
328332 <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>
331335 <time datetime=(when.iso.as_str()) title=(when.absolute.as_str())>(when.relative.as_str())</time>
332336 if let Some(href) = history_href {
333337 <a class="issue-edited" href=(href.as_str())>(edited)</a>
crates/forge-web/tests/community_i18n_e2e.rsmodified+1 -1
@@ -51,7 +51,7 @@ async fn snippets_page_follows_the_browser_language() {
5151 .expect("GET /snippets (tr)");
5252 assert_eq!(turkish.status().as_u16(), 200);
5353 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");
5555 assert!(body.contains("<h1>Parçacıklar</h1>"), "Turkish heading");
5656 assert!(
5757 body.contains("Gösterilecek parçacık yok."),
crates/forge-web/tests/i18n_e2e.rsmodified+5 -5
@@ -179,7 +179,7 @@ async fn anonymous_pages_follow_the_browser_language() {
179179 .await;
180180 assert_eq!(turkish.status, 200);
181181 assert!(
182- turkish.body.contains("<html lang=\"tr\">"),
182+ turkish.body.contains("<html lang=\"tr\""),
183183 "html lang is tr"
184184 );
185185 assert!(turkish.body.contains("Giriş yap"), "Turkish button label");
@@ -194,16 +194,16 @@ async fn anonymous_pages_follow_the_browser_language() {
194194
195195 let english = env.http.get("/login", None, Some("en-US,en;q=0.9")).await;
196196 assert!(
197- english.body.contains("<html lang=\"en\">"),
197+ english.body.contains("<html lang=\"en\""),
198198 "html lang is en"
199199 );
200200 assert!(english.body.contains("Username"), "English label");
201201
202202 // No header, and a language this build does not have: English.
203203 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\""));
205205 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\""));
207207 }
208208
209209 #[tokio::test]
@@ -228,7 +228,7 @@ async fn stored_language_beats_the_browser_for_a_signed_in_user() {
228228 .get("/settings/security", Some(&cookie), Some("en-US,en;q=0.9"))
229229 .await;
230230 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");
232232 assert!(page.body.contains("Güvenlik"), "Turkish heading");
233233 assert!(page.body.contains("Parolayı değiştir"), "Turkish button");
234234 }
crates/forge-web/tests/ui_prefs_e2e.rsmodified+43 -26
@@ -215,9 +215,13 @@ async fn appearance_setting_persists_and_renders_the_html_attribute() {
215215 assert_eq!(page.status, 200);
216216 assert_eq!(
217217 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"
219224 );
220- assert_eq!(html_theme(&page.body), None, "auto renders no data-theme");
221225 let csrf = input_value(&page.body, "csrf");
222226
223227 let saved = env
@@ -277,7 +281,7 @@ async fn appearance_save_without_a_valid_csrf_token_is_403() {
277281 let page = env.http.get("/settings/appearance", Some(&session)).await;
278282 assert_eq!(
279283 selected_option(&page.body, "theme").as_deref(),
280- Some("auto"),
284+ Some("light"),
281285 "nothing saved"
282286 );
283287 }
@@ -309,7 +313,7 @@ async fn unoffered_theme_or_language_is_rejected_and_nothing_is_saved() {
309313 let page = env.http.get("/settings/appearance", Some(&session)).await;
310314 assert_eq!(
311315 selected_option(&page.body, "theme").as_deref(),
312- Some("auto")
316+ Some("light")
313317 );
314318 assert_eq!(
315319 selected_option(&page.body, "language").as_deref(),
@@ -321,7 +325,11 @@ async fn unoffered_theme_or_language_is_rejected_and_nothing_is_saved() {
321325 async fn anonymous_footer_cookie_sets_the_theme() {
322326 let env = start().await;
323327 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+ );
325333 assert!(
326334 page.body.contains("action=\"/-/theme\""),
327335 "anonymous footer offers the theme form"
@@ -361,7 +369,11 @@ async fn signed_in_preference_wins_over_the_anonymous_cookie() {
361369 );
362370 let cookie = format!("{session}; forge_theme=dark");
363371 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+ );
365377 }
366378
367379 #[tokio::test]
@@ -463,7 +475,7 @@ async fn signed_in_theme_toggle_without_a_valid_csrf_is_refused() {
463475 let page = env.http.get("/settings/appearance", Some(&session)).await;
464476 assert_eq!(
465477 selected_option(&page.body, "theme").as_deref(),
466- Some("auto"),
478+ Some("light"),
467479 "nothing saved"
468480 );
469481 }
@@ -485,28 +497,14 @@ async fn anonymous_theme_toggle_sets_the_cookie_and_returns_to_back() {
485497 }
486498
487499 #[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() {
489501 let env = start().await;
490502 let session = login(&env.http, "alice").await;
491503 let back = "/settings/appearance";
492504
505+ // A new user starts on light, the default, so the button offers dark next.
493506 let page = env.http.get(back, Some(&session)).await;
494507 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;
510508 assert_eq!(html_theme(&page.body).as_deref(), Some("light"));
511509 assert_eq!(
512510 theme_button(&page.body),
@@ -550,17 +548,33 @@ async fn theme_button_cycles_auto_light_dark_for_signed_in_users() {
550548 assert_eq!(html_theme(&page.body), None, "auto renders no data-theme");
551549 assert_eq!(
552550 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+ )
554555 );
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"));
555568 }
556569
557570 #[tokio::test]
558571 async fn theme_button_cycles_for_anonymous_visitors_through_the_cookie() {
559572 let env = start().await;
573+ // No cookie is the default, light, so the button offers dark next.
560574 let page = env.http.get("/login", None).await;
561575 assert_eq!(
562576 theme_button(&page.body),
563- ("light".into(), "Theme: Match the system. Switch to Light".into())
577+ ("dark".into(), "Theme: Light. Switch to Dark".into())
564578 );
565579
566580 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() {
573587 let page = env.http.get("/login", Some("forge_theme=dark")).await;
574588 assert_eq!(
575589 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+ )
577594 );
578595 }
style/_repo.scssmodified+7 -26
@@ -85,34 +85,14 @@
8585
8686 .fork-form,
8787 .star-form,
88- .watch-form {
88+ .watch-control {
8989 display: inline-flex;
9090 align-items: center;
9191 gap: 6px;
9292 margin: 0;
9393 }
9494
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,
11696 .star-control .muted,
11797 > .muted {
11898 font-size: 13px;
@@ -262,7 +242,8 @@
262242 }
263243 }
264244
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.
266247 .repo-grid {
267248 display: grid;
268249 grid-template-columns: minmax(0, 1fr);
@@ -303,9 +284,9 @@
303284 }
304285 }
305286
306-@media (min-width: 1100px) {
287+@media (min-width: 900px) {
307288 .repo-grid {
308- grid-template-columns: minmax(0, 1fr) 260px;
289+ grid-template-columns: minmax(0, 1fr) 230px;
309290 gap: 40px;
310291 align-items: start;
311292 }
@@ -554,7 +535,7 @@
554535 display: flex;
555536 flex-direction: column;
556537 gap: 16px;
557- max-width: 880px;
538+ max-width: 960px;
558539 width: 100%;
559540 min-width: 0;
560541
style/_shell.scssmodified+1 -1
@@ -344,7 +344,7 @@
344344 box-sizing: border-box;
345345 width: 100%;
346346 min-width: 0;
347- max-width: 1100px;
347+ max-width: 1280px;
348348 margin: 0 auto;
349349 padding: 24px 16px 48px;
350350 }
style/main.scssmodified+149 -10
@@ -283,7 +283,7 @@ pre {
283283 }
284284
285285 .container {
286- max-width: 960px;
286+ max-width: 1280px;
287287 margin: 0 auto;
288288 padding: 0 16px;
289289 }
@@ -2110,7 +2110,7 @@ code {
21102110 }
21112111
21122112 .issue-new {
2113- max-width: 48rem;
2113+ max-width: 60rem;
21142114
21152115 textarea {
21162116 min-height: 14rem;
@@ -3166,21 +3166,129 @@ span.reaction {
31663166 }
31673167 }
31683168
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 {
31703172 display: inline-flex;
31713173 flex-wrap: wrap;
31723174 align-items: center;
31733175 gap: 0.5rem;
31743176 margin: 0;
3177+}
31753178
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;
31813189 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;
31833257 }
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;
31843292 }
31853293
31863294 @media (max-width: 40rem) {
@@ -3246,7 +3354,7 @@ span.reaction {
32463354 }
32473355
32483356 .release-form {
3249- max-width: 720px;
3357+ max-width: 960px;
32503358
32513359 textarea {
32523360 width: 100%;
@@ -3891,3 +3999,34 @@ span.reaction {
38913999 @import "repo";
38924000 @import "pull";
38934001 @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+}