record #74 merge

39854c3gl0w

all commits

Message

record #74 merge

Diff

.forge/merges/2026-10-27T16-25-41.736141610Z.md+9 −0
diff --git a/.forge/merges/2026-10-27T16-25-41.736141610Z.md b/.forge/merges/2026-10-27T16-25-41.736141610Z.mdnew file mode 100644index 0000000..be2cff0--- /dev/null+++ b/.forge/merges/2026-10-27T16-25-41.736141610Z.md@@ -0,0 +1,9 @@1+---2+pr: a5d3c785-f008-4cca-9bac-89d2135f5be73+applied: true4+verdict: pass5+resolved: []6+created: 2026-10-27T16:25:41.736141610Z7+---8+clean apply9+patch: 11aa1f5f33e297e4e660942a117b7a13c3bca4f6
.forge/prs/a5d3c785-f008-4cca-9bac-89d2135f5be7/patch.diff+480 −0
diff --git a/.forge/prs/a5d3c785-f008-4cca-9bac-89d2135f5be7/patch.diff b/.forge/prs/a5d3c785-f008-4cca-9bac-89d2135f5be7/patch.diffnew file mode 100644index 0000000..c2fcdf4--- /dev/null+++ b/.forge/prs/a5d3c785-f008-4cca-9bac-89d2135f5be7/patch.diff@@ -0,0 +1,480 @@1+From 92901ce02836be1a28ffa9541a3ba937e2a87826 Mon Sep 17 00:00:00 20012+From: hu13-test <[email protected]>3+Date: Tue, 27 Oct 2026 16:24:47 +00004+Subject: [PATCH] diffs: per-file cards, dual line numbers, visual diffstat5+ (#74)6+MIME-Version: 1.07+Content-Type: text/plain; charset=UTF-88+Content-Transfer-Encoding: 8bit9+10+diff_html now parses  chunks into one <details> card per11+file: path, +n/−m counts, proportional green/red bar, and every hunk12+line in a two-column (old/new) gutter. /index/mode/Binary13+lines stay visible as dim metadata; --- /+++ use --diff-file (now14+purple, distinct from body text); add/del rows carry the existing15+background colors. Format-patch mbox preambles (From/Date/Subject/16+stat) are dropped because they duplicate the PR title. Non-unified17+text still falls back to the old single-block coloring.18+19+Commit pages drop 'Author:/Date:' for a chip + monogram + relative20+time (parse_ts_secs now accepts git's 'YYYY-MM-DD HH:MM:SS ±HHMM'),21+and the commits table shows relative dates with exact values in22+title.23+---24+ src/build.rs            |  35 +++++++-25+ src/commits.rs          |  56 ++++++++++--26+ src/layout.rs           | 187 ++++++++++++++++++++++++++++++++--------27+ static/style.css        |  24 +++++-28+ tests/theme_contrast.rs |   2 +29+ 5 files changed, 257 insertions(+), 47 deletions(-)30+31+diff --git a/src/build.rs b/src/build.rs32+index 4c00c85..a813a50 10064433+--- a/src/build.rs34++++ b/src/build.rs35+@@ -522,7 +522,12 @@ pub fn now_unix() -> i64 {36+ #[must_use]37+ pub fn parse_ts_secs(s: &str) -> Option<i64> {38+     let s = s.trim();39+-    let (date, rest) = s.split_once('T')?;40++    // Accept both `2026-10-27T14:08:48Z` (RFC3339) and git's41++    // `2026-10-27 14:08:48 +0000` / `+00:00` forms.42++    let (date, rest) = match s.split_once('T') {43++        Some(v) => v,44++        None => s.split_once(' ')?,45++    };46+     let mut d = date.split('-');47+     let y: i64 = d.next()?.parse().ok()?;48+     let mo: i64 = d.next()?.parse().ok()?;49+@@ -532,7 +537,10 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {50+     }51+     let (hms, offset) = if let Some(t) = rest.strip_suffix('Z') {52+         (t, 0i64)53+-    } else if rest.len() >= 6 && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-') {54++    } else if rest.len() >= 655++        && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-')56++        && rest.as_bytes()[rest.len() - 3] == b':'57++    {58+         let sign = if rest.as_bytes()[rest.len() - 6] == b'-' {59+             -160+         } else {61+@@ -541,6 +549,15 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {62+         let oh: i64 = rest[rest.len() - 5..rest.len() - 3].parse().ok()?;63+         let om: i64 = rest[rest.len() - 2..].parse().ok()?;64+         (&rest[..rest.len() - 6], sign * (oh * 3600 + om * 60))65++    } else if rest.len() >= 5 && matches!(rest.as_bytes()[rest.len() - 5], b'+' | b'-') {66++        let sign = if rest.as_bytes()[rest.len() - 5] == b'-' {67++            -168++        } else {69++            170++        };71++        let oh: i64 = rest[rest.len() - 4..rest.len() - 2].parse().ok()?;72++        let om: i64 = rest[rest.len() - 2..].parse().ok()?;73++        (&rest[..rest.len() - 5], sign * (oh * 3600 + om * 60))74+     } else {75+         (rest, 0)76+     };77+@@ -553,6 +570,7 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {78+         .split('.')79+         .next()80+         .unwrap_or("0")81++        .trim()82+         .parse()83+         .ok()?;84+     if !(0..=23).contains(&h) || !(0..=59).contains(&mi) || !(0..=60).contains(&sec) {85+@@ -2189,6 +2207,19 @@ mod tests {86+         assert!(got.ends_with("…"));87+     }88+ 89++    #[test]90++    fn parse_git_and_rfc3339_dates() {91++        assert_eq!(92++            parse_ts_secs("2026-09-10T07:43:55Z"),93++            parse_ts_secs("2026-09-10 07:43:55 +0000")94++        );95++        let utc = parse_ts_secs("2026-09-10T07:43:55Z").unwrap();96++        let plus1 = parse_ts_secs("2026-09-10 08:43:55 +0100").unwrap();97++        assert_eq!(utc, plus1, "offset is applied");98++        assert!(parse_ts_secs("not a date").is_none());99++        assert_eq!(ts_title("2026-09-10T07:43:55Z"), "2026-09-10 07:43 UTC");100++    }101++102+     #[test]103+     fn relative_age_fixed_clock() {104+         // f0rge #33 rubric: fixed-clock fixture, build-time relative ages.105+diff --git a/src/commits.rs b/src/commits.rs106+index 67872bd..9cf0b82 100644107+--- a/src/commits.rs108++++ b/src/commits.rs109+@@ -8,9 +8,42 @@110+ 111+ use std::path::Path;112+ 113++use crate::build::{now_unix, parse_ts_secs, relative_age, ts_title};114+ use crate::gitx;115+ use crate::layout::{diff_html, esc, page_site_with_provenance};116+ 117++/// Deterministic 1-letter author monogram (same rule as forge pages).118++fn monogram(handle: &str) -> String {119++    let h = handle.trim();120++    let initial: String = h121++        .chars()122++        .next()123++        .map(|c| c.to_uppercase().to_string())124++        .unwrap_or_default();125++    let mut hash: u32 = 0;126++    for b in h.bytes() {127++        hash = hash.wrapping_mul(31).wrapping_add(u32::from(b));128++    }129++    format!(130++        "<span class=\"mono m{}\" aria-hidden=\"true\">{}</span>",131++        hash % 6,132++        esc(&initial)133++    )134++}135++136++/// Relative commit time with the exact value in `title` (f0rge #72/#74).137++fn time_ago(date: &str, now: i64) -> String {138++    match parse_ts_secs(date) {139++        Some(ts) => format!(140++            "<time datetime=\"{}\" title=\"{}\">{}</time>",141++            esc(date),142++            esc(&ts_title(date)),143++            esc(&relative_age(now, ts))144++        ),145++        None => format!("<time>{}</time>", esc(date)),146++    }147++}148++149+ /// Short display form of a full sha (first 7 hex chars, git-style).150+ #[must_use]151+ pub fn short_sha(sha: &str) -> &str {152+@@ -40,19 +73,21 @@ pub fn render_loaded(153+     if log.is_empty() {154+         body.push_str("<p class=\"muted\">No commits yet.</p>\n");155+     } else {156++        let now = now_unix();157+         body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>subject</th><th>sha</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");158+         for c in log {159+             body.push_str(&format!(160+-                "<tr><td class=\"title trunc trunc-subject\" data-label=\"subject\" title=\"{}\">{}</td><td class=\"sha trunc trunc-sha\" data-label=\"sha\" title=\"{}\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"trunc trunc-author\" data-label=\"author\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"date\" title=\"{}\">{}</td></tr>\n",161++                "<tr><td class=\"title trunc trunc-subject\" data-label=\"subject\" title=\"{}\">{}</td><td class=\"sha trunc trunc-sha\" data-label=\"sha\" title=\"{}\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"trunc trunc-author\" data-label=\"author\" title=\"{}\">{}{}</td><td class=\"trunc\" data-label=\"date\" title=\"{}\">{}</td></tr>\n",162+                 esc(&c.subject),163+                 esc(&c.subject),164+                 esc(&c.sha),165+                 esc(&c.sha),166+                 esc(short_sha(&c.sha)),167+                 esc(&c.author),168++                monogram(&c.author),169+                 esc(&c.author),170+                 esc(&c.date),171+-                esc(&c.date),172++                time_ago(&c.date, now),173+             ));174+         }175+         body.push_str("</tbody>\n</table></div>\n");176+@@ -75,14 +110,17 @@ pub fn render_loaded(177+     for c in log {178+         let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;179+         let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;180++        let now = now_unix();181+         let detail = format!(182+-            "<h1>commit {}</h1>\n<p class=\"repo-meta\"><code>{}</code></p>\n<p>Author: {}<br>Date: {}</p>\n<p><a href=\"/commits.html\">all commits</a></p>\n<h2>Message</h2>\n<div class=\"pre-scroll\"><pre>{}</pre></div>\n<h2>Diff</h2>\n<div class=\"pre-scroll\"><pre class=\"diff\">{}</pre></div>\n",183+-            esc(short_sha(&c.sha)),184+-            esc(&c.sha),185+-            esc(&c.author),186+-            esc(&c.date),187+-            esc(&message),188+-            diff_html(&diff),189++            "<h1>{subject}</h1>\n<div class=\"meta\"><span class=\"chip\"><a href=\"/commit/{full}.html\"><code>{short}</code></a></span>{mono}<span class=\"who\">{author}</span><span class=\"meta-when\">{when}</span></div>\n<p><a href=\"/commits.html\">all commits</a></p>\n<h2>Message</h2>\n<div class=\"pre-scroll\"><pre>{message}</pre></div>\n<h2>Diff</h2>\n{diff}\n",190++            subject = esc(&c.subject),191++            full = esc(&c.sha),192++            short = esc(short_sha(&c.sha)),193++            mono = monogram(&c.author),194++            author = esc(&c.author),195++            when = time_ago(&c.date, now),196++            message = esc(&message),197++            diff = diff_html(&diff),198+         );199+         let html = page_site_with_provenance(200+             &format!("commit {} — {site_name}", short_sha(&c.sha)),201+diff --git a/src/layout.rs b/src/layout.rs202+index d985a04..20480b7 100644203+--- a/src/layout.rs204++++ b/src/layout.rs205+@@ -24,26 +24,149 @@ pub fn esc(input: &str) -> String {206+     out207+ }208+ 209+-/// Render unified diff / patch text as escaped HTML with line-level color210+-/// spans (m6 diff-colors, f0rge issue #22; Tokyo Night vars in211+-/// `static/style.css`, dark + light).212++/// Render unified diff text as one card per file (f0rge #74).213+ ///214+-/// Line kinds (line-level only, no word-diff): `@@` hunk headers go in215+-/// `<span class="diff-hunk">`, `+++`/`---` file lines in216+-/// `<span class="diff-file">` (bold fg), `+` additions in217+-/// `<span class="diff-add">`, `-` removals in `<span class="diff-del">`.218+-/// Everything else (context, `diff --git` / `index` headers, commit219+-/// metadata, `Binary files ... differ` notes, `[hu13: ...]` truncation /220+-/// binary markers) is escaped as-is with no span so truncation and binary221+-/// behavior stay intact. Every line is escaped with [`esc`] before wrapping,222+-/// so hostile `<script>` inside an added line renders as text. Output joins223+-/// lines with `\n` (preserving a trailing newline) for use inside an224+-/// already-escaped `<pre class="diff">`.225++/// Each card carries the path, `+n −m` counts and a proportional bar;226++/// hunk lines show old/new line numbers in a two-column gutter. `diff227++/// --git`, `index`, mode and `Binary files` lines stay visible as dim228++/// metadata; the format-patch mbox preamble (From/Date/Subject/stat) is229++/// dropped because it duplicates the PR title. Every byte is escaped230++/// before wrapping, so a hostile `<script>` in an added line is text.231+ #[must_use]232+ pub fn diff_html(raw: &str) -> String {233+     if raw.is_empty() {234+         return String::new();235+     }236++    if !raw.lines().any(|l| l.starts_with("diff --git ")) {237++        return plain_diff_html(raw);238++    }239++    let all: Vec<&str> = raw.lines().collect();240++    let mut starts: Vec<usize> = all241++        .iter()242++        .enumerate()243++        .filter(|(_, l)| l.starts_with("diff --git "))244++        .map(|(i, _)| i)245++        .collect();246++    if starts.is_empty() {247++        return plain_diff_html(raw);248++    }249++    starts.push(all.len());250++    let mut out = String::with_capacity(raw.len() + raw.len() / 3);251++    for w in starts.windows(2) {252++        let chunk = &all[w[0]..w[1]];253++        let path = diff_chunk_path(chunk);254++        let mut added = 0usize;255++        let mut removed = 0usize;256++        for line in chunk {257++            if line.starts_with('+') && !line.starts_with("+++") {258++                added += 1;259++            } else if line.starts_with('-') && !line.starts_with("---") {260++                removed += 1;261++            }262++        }263++        let total = added + removed;264++        let pct = (added * 100).checked_div(total).unwrap_or(0);265++        out.push_str("<details open class=\"difffile\"><summary><code class=\"df-path\">");266++        out.push_str(&esc(&path));267++        out.push_str("</code><span class=\"df-count\"><span class=\"df-add\">+");268++        out.push_str(&added.to_string());269++        out.push_str("</span> <span class=\"df-del\">−");270++        out.push_str(&removed.to_string());271++        out.push_str("</span></span>");272++        if total > 0 {273++            out.push_str("<span class=\"df-bar\" aria-hidden=\"true\"><span class=\"df-bar-add\" style=\"width:");274++            out.push_str(&pct.to_string());275++            out.push_str("%\"></span></span>");276++        }277++        out.push_str("</summary>\n<div class=\"pre-scroll\"><pre class=\"diff\">");278++279++        let mut old_ln = 0i64;280++        let mut new_ln = 0i64;281++        let mut first = true;282++        for line in chunk {283++            if !first {284++                out.push('\n');285++            }286++            first = false;287++            if line.starts_with("@@") {288++                let mut parts = line.split_whitespace();289++                let _ = parts.next();290++                if let Some(o) = parts.next().and_then(|v| v.strip_prefix('-')) {291++                    old_ln = o292++                        .split(',')293++                        .next()294++                        .and_then(|v| v.parse().ok())295++                        .unwrap_or(0);296++                }297++                if let Some(n) = parts.next().and_then(|v| v.strip_prefix('+')) {298++                    new_ln = n299++                        .split(',')300++                        .next()301++                        .and_then(|v| v.parse().ok())302++                        .unwrap_or(0);303++                }304++                out.push_str(&format!("<span class=\"diff-hunk\">{}</span>", esc(line)));305++            } else if line.starts_with('+') && !line.starts_with("+++") {306++                out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line));307++                new_ln += 1;308++            } else if line.starts_with('-') && !line.starts_with("---") {309++                out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line));310++                old_ln += 1;311++            } else if line.starts_with(' ') {312++                out.push_str(&diff_numbered(313++                    &old_ln.to_string(),314++                    &new_ln.to_string(),315++                    "dctx",316++                    line,317++                ));318++                old_ln += 1;319++                new_ln += 1;320++            } else if line.starts_with("+++") || line.starts_with("---") {321++                out.push_str(&format!("<span class=\"diff-file\">{}</span>", esc(line)));322++            } else {323++                out.push_str(&format!("<span class=\"df-meta\">{}</span>", esc(line)));324++            }325++        }326++        out.push_str("</pre></div></details>\n");327++    }328++    out329++}330++331++fn diff_numbered(old: &str, new: &str, class: &str, line: &str) -> String {332++    format!(333++        "<span class=\"df-line {class}-row\"><span class=\"dln\">{old}</span><span class=\"dln\">{new}</span><span class=\"{class}\">{}</span></span>",334++        esc(line)335++    )336++}337++338++/// Path for one chunk: `+++ b/<p>` first, then `--- a/<p>`, then the339++/// `diff --git` header. `/dev/null` falls through to the other side.340++fn diff_chunk_path(lines: &[&str]) -> String {341++    for line in lines {342++        if let Some(rest) = line.strip_prefix("+++ ") {343++            if rest != "/dev/null" {344++                return rest.strip_prefix("b/").unwrap_or(rest).to_string();345++            }346++        }347++    }348++    for line in lines {349++        if let Some(rest) = line.strip_prefix("--- ") {350++            if rest != "/dev/null" {351++                return rest.strip_prefix("a/").unwrap_or(rest).to_string();352++            }353++        }354++    }355++    lines356++        .first()357++        .and_then(|l| l.strip_prefix("diff --git a/"))358++        .and_then(|rest| rest.split(" b/").next())359++        .unwrap_or("diff")360++        .to_string()361++}362++363++/// Fallback for diff text without `diff --git` headers: one escaped block364++/// with line-level coloring (markers stay span-free, old behavior).365++fn plain_diff_html(raw: &str) -> String {366+     let mut out = String::with_capacity(raw.len() + raw.len() / 4);367+     let mut first = true;368+     for line in raw.lines() {369+@@ -51,26 +174,7 @@ pub fn diff_html(raw: &str) -> String {370+             out.push('\n');371+         }372+         first = false;373+-        let e = esc(line);374+-        if line.starts_with("@@") {375+-            out.push_str("<span class=\"diff-hunk\">");376+-            out.push_str(&e);377+-            out.push_str("</span>");378+-        } else if line.starts_with("+++") || line.starts_with("---") {379+-            out.push_str("<span class=\"diff-file\">");380+-            out.push_str(&e);381+-            out.push_str("</span>");382+-        } else if line.starts_with('+') {383+-            out.push_str("<span class=\"diff-add\">");384+-            out.push_str(&e);385+-            out.push_str("</span>");386+-        } else if line.starts_with('-') {387+-            out.push_str("<span class=\"diff-del\">");388+-            out.push_str(&e);389+-            out.push_str("</span>");390+-        } else {391+-            out.push_str(&e);392+-        }393++        out.push_str(&diff_line_span(line));394+     }395+     if raw.ends_with('\n') {396+         out.push('\n');397+@@ -78,6 +182,21 @@ pub fn diff_html(raw: &str) -> String {398+     out399+ }400+ 401++fn diff_line_span(line: &str) -> String {402++    let e = esc(line);403++    if line.starts_with("@@") {404++        format!("<span class=\"diff-hunk\">{e}</span>")405++    } else if line.starts_with("+++") || line.starts_with("---") {406++        format!("<span class=\"diff-file\">{e}</span>")407++    } else if line.starts_with('+') {408++        format!("<span class=\"diff-add\">{e}</span>")409++    } else if line.starts_with('-') {410++        format!("<span class=\"diff-del\">{e}</span>")411++    } else {412++        e413++    }414++}415++416+ /// Render a full HTML page. `title` is escaped; `nav` and `body` are417+ /// inlined as-is (caller must escape untrusted text with [`esc`]).418+ #[allow(dead_code)]419+diff --git a/static/style.css b/static/style.css420+index 53ab719..ff5d619 100644421+--- a/static/style.css422++++ b/static/style.css423+@@ -48,7 +48,7 @@424+   --diff-del:#f7768e;425+   --diff-del-bg:#38232e;426+   --diff-hunk:#7dcfff;427+-  --diff-file:#c0caf5;428++  --diff-file:#bb9af7;429+ }430+ @media (prefers-color-scheme:light){431+   :root{432+@@ -82,7 +82,7 @@433+     --diff-del:#8c4351;434+     --diff-del-bg:#d9c2c7;435+     --diff-hunk:#0f4b6e;436+-    --diff-file:#343b58;437++    --diff-file:#5a4a78;438+   }439+ }440+ html{background:var(--bg);-webkit-text-size-adjust:100%}441+@@ -347,3 +347,23 @@ details.clonebox:not([open]){padding-bottom:.25rem}442+ .comment-head{display:flex;align-items:center;color:var(--fg-dim);font-size:var(--fs-sm);margin:0 0 var(--s2)}443+ .comment-head .who{color:var(--fg);font-weight:600;margin-right:.35em}444+ time{font-variant-numeric:tabular-nums}445++446++/* Diff cards (f0rge #74): one card per file, dual line numbers. */447++.difffile{border:1px solid var(--border);background:var(--surface);margin:var(--s3) 0}448++.difffile>summary{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s2) var(--s3);padding:var(--s2) var(--s3);cursor:pointer;background:var(--bg-alt)}449++.difffile>summary::marker{color:var(--fg-dim)}450++.df-path{color:var(--fg);background:none;padding:0;overflow-wrap:anywhere}451++.df-count{font-size:var(--fs-sm);font-variant-numeric:tabular-nums;margin-left:auto}452++.df-add{color:var(--green)}453++.df-del{color:var(--red)}454++.df-bar{display:inline-block;width:6rem;height:.5rem;background:var(--diff-del-bg);border:1px solid var(--border)}455++.df-bar-add{display:block;height:100%;background:var(--green)}456++.difffile .pre-scroll{margin:0}457++pre.diff{margin:0;border:0;background:var(--surface)}458++.df-line{display:block}459++.dln{display:inline-block;width:2.8em;text-align:right;padding-right:.5em;color:var(--fg-dim);user-select:none;font-variant-numeric:tabular-nums}460++.dln+.dln{border-right:1px solid var(--border);margin-right:.5em}461++.diff-add-row{background:var(--diff-add-bg)}462++.diff-del-row{background:var(--diff-del-bg)}463++.diff-file{color:var(--diff-file);font-weight:600}464++.df-meta{color:var(--fg-dim)}465+diff --git a/tests/theme_contrast.rs b/tests/theme_contrast.rs466+index 9ce311f..0c0fea3 100644467+--- a/tests/theme_contrast.rs468++++ b/tests/theme_contrast.rs469+@@ -117,6 +117,8 @@ fn both_themes_pass_aa_for_text_colors() {470+         ("--tok-com", "--surface-2"),471+         ("--tok-fn", "--surface"),472+         ("--tok-type", "--surface"),473++        ("--diff-file", "--surface"),474++        ("--diff-hunk", "--surface"),475+     ];476+     check("dark", dark, &pairs);477+     check("light", light, &pairs);478+-- 479+2.56.0480+
.forge/prs/a5d3c785-f008-4cca-9bac-89d2135f5be7/pr.md+13 −0
diff --git a/.forge/prs/a5d3c785-f008-4cca-9bac-89d2135f5be7/pr.md b/.forge/prs/a5d3c785-f008-4cca-9bac-89d2135f5be7/pr.mdnew file mode 100644index 0000000..f146621--- /dev/null+++ b/.forge/prs/a5d3c785-f008-4cca-9bac-89d2135f5be7/pr.md@@ -0,0 +1,13 @@1+---2+id: a5d3c785-f008-4cca-9bac-89d2135f5be73+title: 'Diffs: per-file cards, dual line numbers, visual diffstat (#74)'4+state: merged5+base: master6+head: issue-74-diff-cards7+draft: false8+issues:9+- 7fa9434e-8f1f-4e63-8236-c039237b073a10+ci: pass11+created: 2026-10-27T16:24:47.217018112Z12+updated: 2026-10-27T16:25:41.736141610Z13+---