1 From 3c95203548237b94d7a53bb6870f1b79299113d4 Mon Sep 17 00:00:00 20012 From: hu13-test <[email protected]>3 Date: Tue, 27 Oct 2026 16:32:09 +00004 Subject: [PATCH] responsive: nav wraps, file table drops commit column, clone5 summary segments (#75)6 7 - <=767px: header wraps into brand row + nav row; nav links wrap inside8 the row (overflow visible) instead of being clipped in a scroll strip,9 so all 8 destinations are reachable at 320/390px. The stray sep dot10 is hidden when the nav gets its own row.11 - File table hides 'Last commit' on narrow (Name + Age stay, both12 links) instead of wrapping subjects into four lines and clipping Age.13 - Clone summary is three .cb-seg spans (Clone & download / branch @ sha14 / CI badge) with white-space:nowrap + a CSS bullet between them, so15 the badge never splits from 'CI' or wraps mid-token.16 - Meta chips/pills get max-width:100% + overflow-wrap:anywhere.17 - Tests: new responsive assertion block; existing summary finders18 updated for the segmented markup; light_theme CSS helpers are now19 UTF-8-safe (they sliced bytes as chars).20 ---21 src/build.rs | 19 ++++++++++-------22 static/style.css | 20 +++++++++++++++++-23 tests/code_space.rs | 4 +++-24 tests/forge_readers.rs | 4 +++-25 tests/light_theme.rs | 26 +++++++++++------------26 tests/responsive.rs | 47 ++++++++++++++++++++++++++++++++++++++++++27 6 files changed, 96 insertions(+), 24 deletions(-)28 29 diff --git a/src/build.rs b/src/build.rs30 index a813a50..352fc43 10064431 --- a/src/build.rs32 +++ b/src/build.rs33 @@ -925,7 +925,7 @@ pub fn clone_block(34 let badge = crate::forge::verdict_badge(ci_verdict);35 let mut out = format!(36 "<details class=\"clonebox clone-block\" aria-label=\"Clone and download\">\n\37 - <summary>Clone & download \u{b7} {} @ {} \u{b7} CI {}</summary>\n<div class=\"clone-body\">\n",38 + <summary><span class=\"cb-seg\">Clone & download</span><span class=\"cb-seg\">{} @ {}</span><span class=\"cb-seg\">CI {}</span></summary>\n<div class=\"clone-body\">\n",39 esc(branch),40 esc(short_sha),41 badge42 @@ -1651,7 +1651,9 @@ mod tests {43 assert!(!index.contains("repo-meta"), "no dupe repo-meta line");44 assert!(!index.contains("<p>CI:"), "no dupe CI line");45 assert!(index.contains("badge"), "CI verdict badge in summary");46 - let summ = index.find("<summary>Clone").expect("summary");47 + let summ = index48 + .find("<summary><span class=\"cb-seg\">Clone")49 + .expect("summary");50 let send = index[summ..].find("</summary>").expect("summary end") + summ;51 let summary = &index[summ..send];52 assert!(summary.contains("main"), "branch in summary");53 @@ -2393,10 +2395,7 @@ mod tests {54 assert!(index.contains("<details"), "details present");55 assert!(index.contains("class=\"clonebox"), "clonebox class");56 assert!(index.contains("clone-block"), "clone-block class kept");57 - assert!(58 - index.contains("<summary>Clone & download"),59 - "summary label"60 - );61 + assert!(index.contains("Clone & download"), "summary label");62 assert!(index.contains("main"), "branch hint in summary");63 // Closed by default: no `open` attr on the clone details.64 let d0 = index65 @@ -2475,7 +2474,9 @@ mod tests {66 let out = mktemp(&format!("out-ci-{verdict}"));67 run_build(&repo, &out, &opts()).expect("build");68 let index = std::fs::read_to_string(out.join("index.html")).expect("index");69 - let summ = index.find("<summary>Clone").expect("summary");70 + let summ = index71 + .find("<summary><span class=\"cb-seg\">Clone")72 + .expect("summary");73 let send = index[summ..].find("</summary>").expect("summary end") + summ;74 let summary = &index[summ..send];75 assert!(76 @@ -2696,7 +2697,9 @@ mod tests {77 // Code tab: repo-head with h1, summary carries branch @ sha + CI.78 let head_pos = index.find("<div class=\"repo-head\">").expect("repo-head");79 assert!(index[head_pos..].contains("<h1>"), "code h1 in repo-head");80 - let s0 = index.find("<summary>Clone").expect("summary");81 + let s0 = index82 + .find("<summary><span class=\"cb-seg\">Clone")83 + .expect("summary");84 let s1 = index[s0..].find("</summary>").expect("summary end") + s0;85 let summary = &index[s0..s1];86 assert!(summary.contains("Clone & download"), "summary label");87 diff --git a/static/style.css b/static/style.css88 index ff5d619..95af580 10064489 --- a/static/style.css90 +++ b/static/style.css91 @@ -279,7 +279,9 @@ main>h1:first-child{margin-top:var(--gap-head);margin-bottom:var(--gap-content)}92 .lang-legend li{display:inline-flex;align-items:center;gap:.35rem}93 .lang-dot{display:inline-block;width:.75rem;height:.75rem;flex:none}94 details.clonebox{margin:1rem 0;padding:.25rem 1rem 1rem;background:var(--bg-alt);border:1px solid var(--border)}95 -details.clonebox>summary{cursor:pointer;min-height:40px;font-weight:700;color:var(--cyan);margin:0}96 +details.clonebox>summary{cursor:pointer;min-height:40px;font-weight:700;color:var(--cyan);margin:0;display:flex;flex-wrap:wrap;align-items:center;gap:.15rem .5rem}97 +.cb-seg{white-space:nowrap}98 +.cb-seg+.cb-seg::before{content:"·";margin-right:.5rem;color:var(--fg-dim)}99 details.clonebox>summary:focus-visible{outline:2px solid var(--blue);outline-offset:2px}100 /* Clone gap (m6/code-bits, f0rge #44): no blank line under summary when101 collapsed — collapsed padding stays tight; open gap lives on the102 @@ -367,3 +369,19 @@ pre.diff{margin:0;border:0;background:var(--surface)}103 .diff-del-row{background:var(--diff-del-bg)}104 .diff-file{color:var(--diff-file);font-weight:600}105 .df-meta{color:var(--fg-dim)}106 +107 +/* Responsive fixes (f0rge #75): every nav destination visible at 390px,108 + file table keeps Name + Age, meta chips never overflow. */109 +@media (max-width:767px){110 + .site-header .wrap.header-bar{flex-wrap:wrap}111 + .site-header .sep{display:none}112 + .site-header .site-nav.nav-scroll{flex:1 1 100%;overflow-x:visible;border-top:1px solid var(--border);margin-top:var(--s1);padding-top:var(--s1)}113 + .nav-scroll-row{flex-wrap:wrap;width:auto;min-width:0;white-space:normal;column-gap:.9rem;row-gap:.1rem}114 + /* Dense file table: drop "Last commit" below 768px instead of wrapping115 + commit subjects into four lines or clipping the age column. */116 + .file-table th:nth-child(2),.file-table td:nth-child(2){display:none}117 + .file-table td.title{white-space:nowrap}118 + .file-scroll{overflow-x:auto}119 + .meta .chip,.meta .pill{max-width:100%;overflow-wrap:anywhere}120 + .clonebox>summary{font-weight:700}121 +}122 diff --git a/tests/code_space.rs b/tests/code_space.rs123 index 02e1aab..d8e406c 100644124 --- a/tests/code_space.rs125 +++ b/tests/code_space.rs126 @@ -113,7 +113,9 @@ fn repo_head_spacing_is_css_margin_not_br_tags() {127 );128 assert!(!index.contains("repo-meta"), "no dupe repo-meta line");129 assert!(!index.contains("<p>CI:"), "no dupe CI line");130 - let s0 = index.find("<summary>Clone").expect("summary");131 + let s0 = index132 + .find("<summary><span class=\"cb-seg\">Clone")133 + .expect("summary");134 let s1 = index[s0..].find("</summary>").expect("summary end") + s0;135 let summary = &index[s0..s1];136 assert!(summary.contains("main"), "branch in summary");137 diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs138 index f0b7baa..276d52d 100644139 --- a/tests/forge_readers.rs140 +++ b/tests/forge_readers.rs141 @@ -326,7 +326,9 @@ fn forge_fixture_renders_counts_numbers_and_ci() {142 "no dupe repo-meta (f0rge #53)"143 );144 assert!(!index.contains("<p>CI:"), "no dupe CI line (f0rge #53)");145 - let summ = index.find("<summary>Clone").expect("summary");146 + let summ = index147 + .find("<summary><span class=\"cb-seg\">Clone")148 + .expect("summary");149 let send = index[summ..].find("</summary>").expect("summary end") + summ;150 let summary = &index[summ..send];151 assert!(summary.contains("CI"), "CI in summary");152 diff --git a/tests/light_theme.rs b/tests/light_theme.rs153 index d7cb27c..42f309b 100644154 --- a/tests/light_theme.rs155 +++ b/tests/light_theme.rs156 @@ -17,18 +17,18 @@ fn css() -> String {157 /// Strip `/* ... */` comments so palette hex in prose is not mistaken158 /// for hardcoded rule colors.159 fn without_comments(s: &str) -> String {160 + let chars: Vec<char> = s.chars().collect();161 let mut out = String::with_capacity(s.len());162 - let bytes = s.as_bytes();163 let mut i = 0;164 - while i < bytes.len() {165 - if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {166 + while i < chars.len() {167 + if i + 1 < chars.len() && chars[i] == '/' && chars[i + 1] == '*' {168 i += 2;169 - while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {170 + while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') {171 i += 1;172 }173 i += 2;174 } else {175 - out.push(bytes[i] as char);176 + out.push(chars[i]);177 i += 1;178 }179 }180 @@ -38,18 +38,18 @@ fn without_comments(s: &str) -> String {181 /// Remove every `:root { ... }` block (brace-matched) so var definitions182 /// may carry palette hex; what remains must reference vars, not hex.183 fn without_root_blocks(s: &str) -> String {184 + let chars: Vec<char> = s.chars().collect();185 let mut out = String::with_capacity(s.len());186 - let bytes = s.as_bytes();187 let mut i = 0;188 - while i < bytes.len() {189 - if s[i..].starts_with(":root") {190 - if let Some(open) = s[i..].find('{') {191 + while i < chars.len() {192 + if chars[i..].iter().take(5).collect::<String>() == ":root" {193 + if let Some(open) = chars[i..].iter().position(|c| *c == '{') {194 let mut depth = 0;195 let mut j = i + open;196 - while j < bytes.len() {197 - if bytes[j] == b'{' {198 + while j < chars.len() {199 + if chars[j] == '{' {200 depth += 1;201 - } else if bytes[j] == b'}' {202 + } else if chars[j] == '}' {203 depth -= 1;204 if depth == 0 {205 break;206 @@ -61,7 +61,7 @@ fn without_root_blocks(s: &str) -> String {207 continue;208 }209 }210 - out.push(bytes[i] as char);211 + out.push(chars[i]);212 i += 1;213 }214 out215 diff --git a/tests/responsive.rs b/tests/responsive.rs216 index 9c333b7..3a62e8c 100644217 --- a/tests/responsive.rs218 +++ b/tests/responsive.rs219 @@ -464,3 +464,50 @@ fn zero_bare_tables_in_sources() {220 }221 assert!(checked >= 5, "audit saw all list tables, got {checked}");222 }223 +224 +#[test]225 +fn mobile_nav_wraps_and_file_table_keeps_name_and_age() {226 + // f0rge #75: no destination may be clipped at 390px; the dense file227 + // table drops "Last commit" instead of wrapping/clipping.228 + let css = css();229 + let at = css.find("@media (max-width:767px)").expect("narrow block");230 + let narrow = &css[at..];231 + assert!(232 + narrow.contains(".nav-scroll-row{flex-wrap:wrap"),233 + "nav wraps into multiple rows on narrow: {narrow}"234 + );235 + assert!(236 + narrow.contains(".site-nav.nav-scroll{flex:1 1 100%;overflow-x:visible"),237 + "nav gets its own row and is not clipped"238 + );239 + assert!(240 + narrow.contains(".file-table th:nth-child(2),.file-table td:nth-child(2){display:none}"),241 + "file table drops Last commit on mobile"242 + );243 + assert!(244 + css.contains(".cb-seg{white-space:nowrap}"),245 + "clone summary segments never wrap mid-token"246 + );247 + let out = build_fixture();248 + let index = std::fs::read_to_string(out.join("index.html")).expect("index");249 + assert_eq!(250 + index.matches("class=\"cb-seg\"").count(),251 + 3,252 + "clone summary has three no-wrap segments"253 + );254 + assert!(255 + index.contains("class=\"cb-seg\">CI <span class=\"badge"),256 + "CI badge stays inside its segment"257 + );258 + // All nav destinations are present in the DOM (wrapping is CSS-only).259 + for tab in [260 + "/",261 + "/commits.html",262 + "/issues/index.html",263 + "/pulls/index.html",264 + "/ci/index.html",265 + "/agents.html",266 + ] {267 + assert!(index.contains(&format!("href=\"{tab}\"")), "nav has {tab}");268 + }269 +}270 -- 271 2.56.0272