From f7a03160d63b71eab0aca6e7a65bc36b135e60cc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 21:22:04 -0400 Subject: [PATCH] About: contributor and model credits compiled in (grab bag + table, self-submitted names) (#411) * About: contributors compiled in from contributors.json (grab bag + table, sortable, name toggle) Help > About VectorCraft gains Contributors and Models tabs next to the existing About content, following the craftrules standard (standards/contributors.md): contributors/contributors.json is generated by craftrules/scripts/contributors.py and baked into the binary by crates/ui-egui/build.rs as static tables (credits.rs). Each contributor line: @username, opt-in display/real name, PRs, commits, lines +/-/delta, binary assets +/-, first/last commit. Sortable by every column; alphabetical ignores case and the leading @. Labels are translated (ja, zh-hant, pt-br catalog rows). Co-Authored-By: Claude Opus 5.5 * Contributors docs: craftrules is two levels up (../../craftrules) Co-Authored-By: Claude Opus 5.5 * Contributors: self-submitted names in contributors/people.toml; AGENTS.md credit-yourself section build.rs reads contributors/people.toml next to the generated contributors.json and shows a name only for entries the generator verified as written by that person (craftrules standards/contributors.md). Co-Authored-By: Claude Opus 5.5 * Contributors: regenerate (verified people.toml entries) Co-Authored-By: Claude Opus 5.5 * i18n: credits labels for catalogs added on main since this branch Co-Authored-By: Claude Opus 5.5 * Contributors: regenerate after rebase onto main Co-Authored-By: Claude Opus 5.5 * Contributors: regenerate (verified people.toml entries) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 18 ++ Cargo.lock | 1 + contributors/contributors.json | 348 ++++++++++++++++++++++++ contributors/people.toml | 23 ++ crates/ui-egui/Cargo.toml | 4 + crates/ui-egui/build.rs | 105 ++++++++ crates/ui-egui/src/credits.rs | 404 ++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs/about.rs | 74 +++-- crates/ui-egui/src/i18n/cs.tsv | 31 +++ crates/ui-egui/src/i18n/es.tsv | 31 +++ crates/ui-egui/src/i18n/ja.tsv | 31 +++ crates/ui-egui/src/i18n/pt-br.tsv | 31 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 31 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 31 +++ crates/ui-egui/src/lib.rs | 1 + docs/contributors.md | 56 ++++ 16 files changed, 1197 insertions(+), 23 deletions(-) create mode 100644 contributors/contributors.json create mode 100644 contributors/people.toml create mode 100644 crates/ui-egui/build.rs create mode 100644 crates/ui-egui/src/credits.rs create mode 100644 docs/contributors.md diff --git a/AGENTS.md b/AGENTS.md index 0633ac64..3e544b94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,3 +55,21 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank. - Grade by behaviour against `plan/illustrator/`, not by whether a menu item exists. Scores are self-assessed, so err low. - Keep the README's Status section in step with the ROADMAP headline. + +## Contributor credits (About window) + +- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json` + (commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for + themselves). See `docs/contributors.md`. +- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub + username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once + whether they want to be credited by more than their username: a real name, a display name, and/or + their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry + (copy the template at the top of the file, or run + `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + and include it in their PR, committed as them. If no, change nothing: they are credited as + `@username` anyway. +- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or + GitHub profiles). Never hand-edit `contributors.json`. +- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also + re-verifies who wrote each `people.toml` entry; `--check` only verifies). diff --git a/Cargo.lock b/Cargo.lock index 3898ed73..f624b262 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4690,6 +4690,7 @@ dependencies = [ "log", "serde", "serde_json", + "toml", "vectorcraft-cad", "vectorcraft-color", "vectorcraft-doc", diff --git a/contributors/contributors.json b/contributors/contributors.json new file mode 100644 index 00000000..84edd44e --- /dev/null +++ b/contributors/contributors.json @@ -0,0 +1,348 @@ +{ + "schema": 1, + "repo": "storytold/vectorcraft", + "branch": "main", + "head": "fe9f3afcb7d32032e8f332b0594692a03e275b84", + "head_date": "2026-10-08T00:36:16Z", + "commits": 607, + "unattributed_commits": 0, + "contributors": [ + { + "login": "echelon", + "people_entry": true, + "github_name": null, + "prs": 15, + "commits": 119, + "lines_added": 172276, + "lines_deleted": 40329, + "binary_added": 37, + "binary_deleted": 2, + "first_commit": "2026-09-30T09:12:46Z", + "last_commit": "2026-10-07T22:04:30Z" + }, + { + "login": "bflatastic", + "people_entry": false, + "github_name": null, + "prs": 176, + "commits": 394, + "lines_added": 212929, + "lines_deleted": 24160, + "binary_added": 2, + "binary_deleted": 0, + "first_commit": "2026-10-02T15:22:29Z", + "last_commit": "2026-10-08T00:36:16Z" + }, + { + "login": "storybored379", + "people_entry": false, + "github_name": null, + "prs": 7, + "commits": 7, + "lines_added": 456, + "lines_deleted": 49, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-04T06:45:57Z", + "last_commit": "2026-10-04T08:14:20Z" + }, + { + "login": "theironchef", + "people_entry": false, + "github_name": null, + "prs": 38, + "commits": 41, + "lines_added": 2803, + "lines_deleted": 232, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-05T14:40:20Z", + "last_commit": "2026-10-07T22:39:00Z" + }, + { + "login": "midasdf", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 2, + "lines_added": 579, + "lines_deleted": 43, + "binary_added": 1, + "binary_deleted": 0, + "first_commit": "2026-10-05T16:10:37Z", + "last_commit": "2026-10-07T20:53:30Z" + }, + { + "login": "japbcoelho", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 57, + "lines_deleted": 13, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-06T01:45:49Z", + "last_commit": "2026-10-06T01:45:49Z" + }, + { + "login": "p0lyg0n", + "people_entry": false, + "github_name": null, + "prs": 5, + "commits": 6, + "lines_added": 780, + "lines_deleted": 69, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-06T09:22:46Z", + "last_commit": "2026-10-07T04:33:45Z" + }, + { + "login": "imachus", + "people_entry": false, + "github_name": null, + "prs": 12, + "commits": 12, + "lines_added": 6302, + "lines_deleted": 212, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-06T10:06:18Z", + "last_commit": "2026-10-07T22:20:10Z" + }, + { + "login": "petrleocompel", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 784, + "lines_deleted": 36, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-06T12:23:11Z", + "last_commit": "2026-10-06T12:23:11Z" + }, + { + "login": "fahrim", + "people_entry": false, + "github_name": null, + "prs": 3, + "commits": 6, + "lines_added": 1964, + "lines_deleted": 76, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-06T12:48:28Z", + "last_commit": "2026-10-07T23:36:17Z" + }, + { + "login": "EthanSuquet", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 4, + "lines_added": 413, + "lines_deleted": 71, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-06T19:30:49Z", + "last_commit": "2026-10-07T14:24:14Z" + }, + { + "login": "realwitt", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 607, + "lines_deleted": 3, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T00:12:52Z", + "last_commit": "2026-10-07T00:12:52Z" + }, + { + "login": "WildfootW", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 6809, + "lines_deleted": 2627, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T00:37:02Z", + "last_commit": "2026-10-07T00:37:02Z" + }, + { + "login": "coygeek", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 4, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T02:29:03Z", + "last_commit": "2026-10-07T02:29:03Z" + }, + { + "login": "Luskish", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 261, + "lines_deleted": 32, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T02:50:35Z", + "last_commit": "2026-10-07T02:50:35Z" + }, + { + "login": "daniel-efrat", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 547, + "lines_deleted": 43, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T08:13:19Z", + "last_commit": "2026-10-07T08:13:19Z" + }, + { + "login": "Pengjingyu1992", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 3132, + "lines_deleted": 4, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T09:56:23Z", + "last_commit": "2026-10-07T09:56:23Z" + }, + { + "login": "MillhioreBT", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 564, + "lines_deleted": 41, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T12:58:33Z", + "last_commit": "2026-10-07T12:58:33Z" + }, + { + "login": "fabian-co", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 270, + "lines_deleted": 48, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T18:05:26Z", + "last_commit": "2026-10-07T19:32:11Z" + }, + { + "login": "Haven-King", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 467, + "lines_deleted": 76, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T18:10:39Z", + "last_commit": "2026-10-07T18:40:49Z" + }, + { + "login": "njmueller", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 619, + "lines_deleted": 11, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T21:14:42Z", + "last_commit": "2026-10-07T21:14:42Z" + }, + { + "login": "RicSchonfelder", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 2592, + "lines_deleted": 9, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T21:44:42Z", + "last_commit": "2026-10-07T21:44:42Z" + }, + { + "login": "DWAA1660", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 76, + "lines_deleted": 21, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T23:09:37Z", + "last_commit": "2026-10-07T23:09:37Z" + } + ], + "models": [ + { + "id": "anthropic/claude-opus/5.5", + "company": "Anthropic", + "model": "Claude Opus", + "version": "5.5", + "commits": 567, + "lines_added": 382239, + "lines_deleted": 67174 + }, + { + "id": "anthropic/claude-sonnet/5.5", + "company": "Anthropic", + "model": "Claude Sonnet", + "version": "5.5", + "commits": 6, + "lines_added": 660, + "lines_deleted": 34 + }, + { + "id": "anthropic/claude-opus/4.8", + "company": "Anthropic", + "model": "Claude Opus", + "version": "4.8", + "commits": 3, + "lines_added": 1672, + "lines_deleted": 42 + }, + { + "id": "anthropic/claude-fable/5.1", + "company": "Anthropic", + "model": "Claude Fable", + "version": "5.1", + "commits": 1, + "lines_added": 6809, + "lines_deleted": 2627 + } + ] +} diff --git a/contributors/people.toml b/contributors/people.toml new file mode 100644 index 00000000..89c6db20 --- /dev/null +++ b/contributors/people.toml @@ -0,0 +1,23 @@ +# Contributor names for the About window (craftrules standards/contributors.md). +# +# Everyone who contributes is credited by GitHub username automatically. Anything more is up to +# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a +# maintainer may add a person only when that person asked, recording where in consent_source. +# +# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ +# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] +# +# Or copy this block, replacing your-github-username (every field is optional): +# +# [people.your-github-username] +# real_name = "Your Name" # shown when the viewer picks "Real name" +# display_name = "Nickname" # shown when the viewer picks "Display name" +# sync_github_name = true # or: use the name on your public GitHub profile as display name +# +# To remove your names, delete your entry (or set fields empty) in a PR. The next +# contributors.py run, and the next build, drop them. + +[people.echelon] +real_name = "Brandon Thomas" +display_name = "Brandon Thomas" +sync_github_name = false diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index df08cfee..fd672d8f 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -33,3 +33,7 @@ log = { workspace = true } [dev-dependencies] vectorcraft-testkit = { workspace = true } wat = { workspace = true } + +[build-dependencies] +serde_json = { workspace = true } +toml = "1" diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs new file mode 100644 index 00000000..f40007fb --- /dev/null +++ b/crates/ui-egui/build.rs @@ -0,0 +1,105 @@ +//! Bake the contributor credits into the binary as static Rust tables (craftrules +//! standards/contributors.md). Nothing is read at run time. +//! +//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules +//! scripts/contributors.py, which also marks whose people.toml entry it verified. +//! - contributors/people.toml: the names people chose for themselves. A name is used only when the +//! JSON says the entry was verified (`people_entry`). +//! +//! A missing or malformed file gives empty tables (or no names) and a warning. + +use std::fmt::Write as _; + +use serde_json::Value; + +fn main() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors"); + let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| { + serde_json::from_str::(t).map_err(|e| e.to_string()) + }) + .unwrap_or(Value::Null); + let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::().map_err(|e| e.to_string())) + .unwrap_or_default(); + let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs"); + if let Err(e) = std::fs::write(&out, generate(&json, &people)) { + println!("cargo::warning=writing {}: {e}", out.display()); + } +} + +fn read(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result) -> Option { + println!("cargo::rerun-if-changed={}", path.display()); + let r = std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|t| parse(&t)); + if let Err(e) = &r { + println!("cargo::warning={}: {e}; {fallback}", path.display()); + } + r.ok() +} + +/// A non-empty string field of someone's people.toml entry, as a Rust `Option<&str>` literal. +fn name(v: Option<&str>) -> String { + match v.map(str::trim).filter(|s| !s.is_empty()) { + Some(x) => format!("Some({x:?})"), + None => "None".into(), + } +} + +fn s(v: &Value, k: &str) -> String { + format!("{:?}", v.get(k).and_then(Value::as_str).unwrap_or("")) +} + +fn n(v: &Value, k: &str) -> u64 { + v.get(k).and_then(Value::as_u64).unwrap_or(0) +} + +fn rows(json: &Value, k: &str) -> Vec { + json.get(k).and_then(Value::as_array).cloned().unwrap_or_default() +} + +fn generate(json: &Value, people: &toml::Table) -> String { + let mut o = String::new(); + let _ = writeln!(o, "pub static TOTAL_COMMITS: u64 = {};", n(json, "commits")); + let _ = writeln!(o, "pub static CONTRIBUTORS: &[Contributor] = &["); + for c in rows(json, "contributors") { + let login = c.get("login").and_then(Value::as_str).unwrap_or(""); + if login.is_empty() { + continue; + } + // Names only from a verified, self-chosen people.toml entry. + let entry = people.get("people").and_then(|p| p.get(login)).filter(|_| c.get("people_entry").and_then(Value::as_bool) == Some(true)); + let field = |k: &str| entry.and_then(|e| e.get(k)).and_then(toml::Value::as_str); + let synced = entry.and_then(|e| e.get("sync_github_name")).and_then(toml::Value::as_bool) == Some(true); + let display = field("display_name") + .filter(|s| !s.trim().is_empty()) + .or_else(|| if synced { c.get("github_name").and_then(Value::as_str) } else { None }); + let _ = writeln!( + o, + " Contributor {{ login: {login:?}, display_name: {}, real_name: {}, prs: {}, commits: {}, lines_added: {}, lines_deleted: {}, binary_added: {}, binary_deleted: {}, first_commit: {}, last_commit: {} }},", + name(display), + name(field("real_name")), + n(&c, "prs"), + n(&c, "commits"), + n(&c, "lines_added"), + n(&c, "lines_deleted"), + n(&c, "binary_added"), + n(&c, "binary_deleted"), + s(&c, "first_commit"), + s(&c, "last_commit"), + ); + } + let _ = writeln!(o, "];"); + let _ = writeln!(o, "pub static MODELS: &[Model] = &["); + for m in rows(json, "models") { + let _ = writeln!( + o, + " Model {{ company: {}, model: {}, version: {}, commits: {}, lines_added: {}, lines_deleted: {} }},", + s(&m, "company"), + s(&m, "model"), + s(&m, "version"), + n(&m, "commits"), + n(&m, "lines_added"), + n(&m, "lines_deleted"), + ); + } + let _ = writeln!(o, "];"); + o +} diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs new file mode 100644 index 00000000..3219be7b --- /dev/null +++ b/crates/ui-egui/src/credits.rs @@ -0,0 +1,404 @@ +//! The About window's credits: every contributor (GitHub login always; display and real names only +//! with recorded consent) and the AI models that helped. The tables are baked into the binary by +//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is +//! read at run time. + +use std::cmp::Ordering; + +use egui::RichText; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Contributor { + pub login: &'static str, + pub display_name: Option<&'static str>, + pub real_name: Option<&'static str>, + pub prs: u64, + pub commits: u64, + pub lines_added: u64, + pub lines_deleted: u64, + pub binary_added: u64, + pub binary_deleted: u64, + /// ISO 8601 UTC; empty for someone credited only through a merged PR. + pub first_commit: &'static str, + pub last_commit: &'static str, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Model { + pub company: &'static str, + pub model: &'static str, + pub version: &'static str, + pub commits: u64, + pub lines_added: u64, + pub lines_deleted: u64, +} + +include!(concat!(env!("OUT_DIR"), "/credits.rs")); + +/// Which name to show. Display and real names fall back to the `@username` when not given. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum NameMode { + #[default] + Username, + DisplayName, + RealName, +} + +impl NameMode { + pub const ALL: [NameMode; 3] = [NameMode::Username, NameMode::DisplayName, NameMode::RealName]; + + pub fn label(self) -> &'static str { + match self { + NameMode::Username => tl!("Username"), + NameMode::DisplayName => tl!("Display name"), + NameMode::RealName => tl!("Real name"), + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SortKey { + Name, + Prs, + Commits, + LinesAdded, + LinesDeleted, + LinesDelta, + BinaryAdded, + BinaryDeleted, + #[default] + FirstCommit, + LastCommit, +} + +impl SortKey { + pub const ALL: [SortKey; 10] = [ + SortKey::Name, + SortKey::Prs, + SortKey::Commits, + SortKey::LinesAdded, + SortKey::LinesDeleted, + SortKey::LinesDelta, + SortKey::BinaryAdded, + SortKey::BinaryDeleted, + SortKey::FirstCommit, + SortKey::LastCommit, + ]; + + /// Menu label and table header, in the UI language (the short count headers stay as they are). + pub fn label(self) -> (&'static str, &'static str) { + match self { + SortKey::Name => (tl!("Name (A–Z)"), tl!("Name")), + SortKey::Prs => (tl!("Merged PRs"), "PRs"), + SortKey::Commits => (tl!("Commits"), tl!("Commits")), + SortKey::LinesAdded => (tl!("Lines added"), "+LOC"), + SortKey::LinesDeleted => (tl!("Lines deleted"), "−LOC"), + SortKey::LinesDelta => (tl!("Line delta"), "ΔLOC"), + SortKey::BinaryAdded => (tl!("Binary assets added"), "+Bin"), + SortKey::BinaryDeleted => (tl!("Binary assets removed"), "−Bin"), + SortKey::FirstCommit => (tl!("First commit"), tl!("First")), + SortKey::LastCommit => (tl!("Last commit"), tl!("Last")), + } + } + + /// Names and dates read naturally oldest/A first; counts read biggest first. + pub fn default_ascending(self) -> bool { + matches!(self, SortKey::Name | SortKey::FirstCommit | SortKey::LastCommit) + } +} + +impl Contributor { + pub fn name(&self, mode: NameMode) -> String { + let chosen = match mode { + NameMode::Username => None, + NameMode::DisplayName => self.display_name, + NameMode::RealName => self.real_name, + }; + chosen.map_or_else(|| format!("@{}", self.login), str::to_string) + } + + pub fn lines_delta(&self) -> i128 { + i128::from(self.lines_added) - i128::from(self.lines_deleted) + } + + /// One line with everything we know, for tooltips. + pub fn summary(&self) -> String { + format!( + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}", + self.login, + self.prs, + self.commits, + group(self.lines_added), + group(self.lines_deleted), + signed(self.lines_delta()), + self.binary_added, + self.binary_deleted, + day(self.first_commit), + day(self.last_commit), + ) + } +} + +/// Alphabetical key: case-insensitive, ignoring the `@` of usernames. +fn name_key(name: &str) -> String { + name.trim_start_matches('@').to_lowercase() +} + +fn compare(a: &Contributor, b: &Contributor, mode: NameMode, key: SortKey) -> Ordering { + match key { + SortKey::Name => name_key(&a.name(mode)).cmp(&name_key(&b.name(mode))), + SortKey::Prs => a.prs.cmp(&b.prs), + SortKey::Commits => a.commits.cmp(&b.commits), + SortKey::LinesAdded => a.lines_added.cmp(&b.lines_added), + SortKey::LinesDeleted => a.lines_deleted.cmp(&b.lines_deleted), + SortKey::LinesDelta => a.lines_delta().cmp(&b.lines_delta()), + SortKey::BinaryAdded => a.binary_added.cmp(&b.binary_added), + SortKey::BinaryDeleted => a.binary_deleted.cmp(&b.binary_deleted), + // A PR-only contributor (no commit date) sorts after everyone with a date. + SortKey::FirstCommit => dated(a.first_commit).cmp(&dated(b.first_commit)), + SortKey::LastCommit => dated(a.last_commit).cmp(&dated(b.last_commit)), + } +} + +fn dated(d: &str) -> (bool, &str) { + (d.is_empty(), d) +} + +/// Contributors in display order. Ties fall back to the name (A–Z) so the order is stable. +pub fn sorted(list: &[Contributor], mode: NameMode, key: SortKey, ascending: bool) -> Vec<&Contributor> { + let mut v: Vec<&Contributor> = list.iter().collect(); + v.sort_by(|a, b| { + let o = compare(a, b, mode, key); + let o = if ascending { o } else { o.reverse() }; + o.then_with(|| compare(a, b, mode, SortKey::Name)).then_with(|| a.login.cmp(b.login)) + }); + v +} + +fn group(n: u64) -> String { + let s = n.to_string(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, ch) in s.chars().enumerate() { + if i > 0 && (s.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(ch); + } + out +} + +fn signed(n: i128) -> String { + let g = group(u64::try_from(n.unsigned_abs()).unwrap_or(u64::MAX)); + if n < 0 { format!("−{g}") } else { format!("+{g}") } +} + +fn day(iso: &str) -> &str { + if iso.is_empty() { "—" } else { iso.get(..10).unwrap_or(iso) } +} + +/// The About window doesn't resize: long lists scroll inside it. +const SCROLL_HEIGHT: f32 = 340.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct View { + names: NameMode, + key: SortKey, + ascending: bool, + table: bool, +} + +impl Default for View { + fn default() -> Self { + View { names: NameMode::Username, key: SortKey::FirstCommit, ascending: true, table: false } + } +} + +/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. +pub fn contributors_ui(ui: &mut egui::Ui) { + let id = egui::Id::new("credits_view"); + let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); + ui.horizontal_wrapped(|ui| { + ui.label(tl!("Show")); + for m in NameMode::ALL { + if ui.selectable_label(v.names == m, m.label()).clicked() { + v.names = m; + } + } + ui.separator(); + ui.label(tl!("Sort")); + egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + for k in SortKey::ALL { + if ui.selectable_label(v.key == k, k.label().0).clicked() { + v.key = k; + v.ascending = k.default_ascending(); + } + } + }); + if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(tl!("Reverse the order")).clicked() { + v.ascending = !v.ascending; + } + ui.separator(); + if ui.selectable_label(!v.table, tl!("Grab bag")).clicked() { + v.table = false; + } + if ui.selectable_label(v.table, tl!("Table")).clicked() { + v.table = true; + } + }); + let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); + let (people, commits) = (group(list.len() as u64), group(TOTAL_COMMITS)); + let counts = crate::i18n::fmt(tl!("{people} contributors · {commits} commits"), &[("people", &people), ("commits", &commits)]); + ui.label(RichText::new(counts).small().weak()); + ui.separator(); + egui::ScrollArea::both().max_height(SCROLL_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { + if list.is_empty() { + ui.label(tl!("No contributor data was built into this copy.")); + } else if v.table { + table(ui, &list, &mut v); + } else { + ui.horizontal_wrapped(|ui| { + for (i, c) in list.iter().enumerate() { + if i > 0 { + ui.label(RichText::new("·").weak()); + } + ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); + } + }); + } + }); + ui.data_mut(|d| d.insert_temp(id, v)); +} + +fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { + egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { + for k in SortKey::ALL { + let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; + if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() { + if v.key == k { + v.ascending = !v.ascending; + } else { + v.key = k; + v.ascending = k.default_ascending(); + } + } + } + ui.end_row(); + for c in list { + ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); + ui.label(group(c.prs)); + ui.label(group(c.commits)); + ui.label(group(c.lines_added)); + ui.label(group(c.lines_deleted)); + ui.label(signed(c.lines_delta())); + ui.label(group(c.binary_added)); + ui.label(group(c.binary_deleted)); + ui.label(day(c.first_commit)); + ui.label(day(c.last_commit)); + ui.end_row(); + } + }); +} + +/// About ▸ Models: AI models credited in Co-Authored-By trailers. +pub fn models_ui(ui: &mut egui::Ui) { + if MODELS.is_empty() { + ui.label(tl!("No model credits were built into this copy.")); + return; + } + let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); + egui::ScrollArea::both().max_height(SCROLL_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { + egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { + for h in [tl!("Company"), tl!("Model"), tl!("Version"), tl!("Commits"), tl!("% of all commits"), tl!("Lines +/−")] { + ui.label(RichText::new(h).strong()); + } + ui.end_row(); + for m in MODELS { + ui.label(m.company); + ui.label(m.model); + ui.label(m.version); + ui.label(group(m.commits)); + ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64)); + ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted))); + ui.end_row(); + } + }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn c( + login: &'static str, + display: Option<&'static str>, + real: Option<&'static str>, + added: u64, + deleted: u64, + first: &'static str, + ) -> Contributor { + Contributor { + login, + display_name: display, + real_name: real, + prs: 0, + commits: 1, + lines_added: added, + lines_deleted: deleted, + binary_added: 0, + binary_deleted: 0, + first_commit: first, + last_commit: first, + } + } + + fn people() -> [Contributor; 4] { + [ + c("zed", None, None, 5, 50, "2026-01-02T00:00:00Z"), + c("Bob", Some("Ann"), None, 10, 0, "2026-01-01T00:00:00Z"), + c("alice", None, None, 7, 1, ""), + c("carol", None, Some("Dana"), 1, 0, "2026-01-03T00:00:00Z"), + ] + } + + fn logins(v: &[&Contributor]) -> Vec<&'static str> { + v.iter().map(|c| c.login).collect() + } + + #[test] + fn names_fall_back_to_the_username() { + let p = c("bob", Some("Bobby"), None, 0, 0, ""); + assert_eq!(p.name(NameMode::Username), "@bob"); + assert_eq!(p.name(NameMode::DisplayName), "Bobby"); + assert_eq!(p.name(NameMode::RealName), "@bob"); + } + + #[test] + fn alphabetical_ignores_at_and_case() { + let all = people(); + let v = sorted(&all, NameMode::Username, SortKey::Name, true); + assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]); + let v = sorted(&all, NameMode::DisplayName, SortKey::Name, true); + assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]); // "Ann" sorts with the @usernames + let v = sorted(&all, NameMode::RealName, SortKey::Name, false); + assert_eq!(logins(&v), ["zed", "carol", "Bob", "alice"]); // "Dana" between bob and zed + } + + #[test] + fn delta_and_dates() { + let all = people(); + let v = sorted(&all, NameMode::Username, SortKey::LinesDelta, false); + assert_eq!(logins(&v), ["Bob", "alice", "carol", "zed"]); + let v = sorted(&all, NameMode::Username, SortKey::FirstCommit, true); + assert_eq!(logins(&v), ["Bob", "zed", "carol", "alice"]); // undated last + } + + #[test] + fn formatting() { + assert_eq!(group(1234567), "1,234,567"); + assert_eq!(group(12), "12"); + assert_eq!(signed(-1000), "−1,000"); + assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07"); + assert_eq!(day(""), "—"); + } +} diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs index 3607da1c..523ab7ed 100644 --- a/crates/ui-egui/src/dialogs/about.rs +++ b/crates/ui-egui/src/dialogs/about.rs @@ -16,32 +16,60 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .frame(egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::same(18))) .show(ctx, |ui| { - ui.set_width(380.0); + // Tabs: About · Contributors · Models (the credits are compiled in, see `credits`). + let tab_id = egui::Id::new("about_tab"); + let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::hover()); - crate::brand::paint_mark(ui, r); - ui.vertical(|ui| { - ui.label(egui::RichText::new("VectorCraft").font(theme::semibold(22.0))); - ui.label(crate::i18n::fmt( - tl!("Version {version} — open-source vector illustration in pure Rust."), - &[("version", env!("CARGO_PKG_VERSION"))], - )); - }); + for (i, label) in [tl!("About"), tl!("Contributors"), tl!("Models")].into_iter().enumerate() { + let i = i as u8; + if ui.selectable_label(tab == i, label).clicked() { + tab = i; + } + } }); - ui.add_space(12.0); - crate::community::links(app, ui); - ui.add_space(12.0); - ui.label( - egui::RichText::new(tl!( - "Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft." - )) - .size(11.0), - ); - // The GPU the window renders with, for bug reports (Preferences › Performance picks it). - if let Some(adapter) = &app.graphics_adapter { - ui.add_space(6.0); - ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Graphics: {adapter}"), &[("adapter", adapter.as_str())])).size(11.0)); + ui.data_mut(|d| d.insert_temp(tab_id, tab)); + ui.separator(); + match tab { + 1 => { + ui.set_width(660.0); + crate::credits::contributors_ui(ui); + } + 2 => { + ui.set_width(660.0); + crate::credits::models_ui(ui); + } + _ => about_tab(app, ui), } }); app.ui.about = open; } + +/// The About tab: mark, version, community links, licences and the GPU in use. +fn about_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + ui.set_width(380.0); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::hover()); + crate::brand::paint_mark(ui, r); + ui.vertical(|ui| { + ui.label(egui::RichText::new("VectorCraft").font(theme::semibold(22.0))); + ui.label(crate::i18n::fmt( + tl!("Version {version} — open-source vector illustration in pure Rust."), + &[("version", env!("CARGO_PKG_VERSION"))], + )); + }); + }); + ui.add_space(12.0); + crate::community::links(app, ui); + ui.add_space(12.0); + ui.label( + egui::RichText::new(tl!( + "Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft." + )) + .size(11.0), + ); + // The GPU the window renders with, for bug reports (Preferences › Performance picks it). + if let Some(adapter) = &app.graphics_adapter { + ui.add_space(6.0); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Graphics: {adapter}"), &[("adapter", adapter.as_str())])).size(11.0)); + } +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e1148c8a..fde2f3f5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -572,3 +572,34 @@ Collapse Panels to Icons Sbalit panely na ikony Collapse to Icons Sbalit na ikony Expand Panels Rozbalit panely + +# About › Contributors / Models (credits.rs) + About O aplikaci + Contributors Přispěvatelé + Models Modely + Username Uživatelské jméno + Display name Zobrazované jméno + Real name Skutečné jméno + Sort Řadit + Reverse the order Obrátit pořadí + Grab bag Volný seznam + Table Tabulka + Name (A–Z) Jméno (A–Z) + Merged PRs Sloučené PR + Commits Commity + Lines added Přidané řádky + Lines deleted Odstraněné řádky + Line delta Rozdíl řádků + Binary assets added Přidané binární soubory + Binary assets removed Odstraněné binární soubory + First commit První commit + Last commit Poslední commit + First První + Last Poslední + {people} contributors · {commits} commits Přispěvatelé: {people} · Commity: {commits} + No contributor data was built into this copy. Tato kopie neobsahuje údaje o přispěvatelích. + No model credits were built into this copy. Tato kopie neobsahuje uvedení modelů. + Company Společnost + Version Verze + % of all commits % všech commitů + Lines +/− Řádky +/− diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6da82ee5..b591b7e9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2972,3 +2972,34 @@ # Leading model (Paragraph panel menu, East Asian options). Top-to-Top Leading Interlineado de superior a superior Bottom-to-Bottom Leading Interlineado de inferior a inferior + +# About › Contributors / Models (credits.rs) + About Acerca de + Contributors Colaboradores + Models Modelos + Username Nombre de usuario + Display name Nombre visible + Real name Nombre real + Sort Ordenar + Reverse the order Invertir el orden + Grab bag Lista libre + Table Tabla + Name (A–Z) Nombre (A–Z) + Merged PRs PR fusionadas + Commits Commits + Lines added Líneas agregadas + Lines deleted Líneas eliminadas + Line delta Diferencia de líneas + Binary assets added Recursos binarios agregados + Binary assets removed Recursos binarios eliminados + First commit Primer commit + Last commit Último commit + First Primero + Last Último + {people} contributors · {commits} commits {people} colaboradores · {commits} commits + No contributor data was built into this copy. Esta copia no incluye datos de colaboradores. + No model credits were built into this copy. Esta copia no incluye créditos de modelos. + Company Empresa + Version Versión + % of all commits % de todos los commits + Lines +/− Líneas +/− diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 81fffd89..836343c9 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2961,3 +2961,34 @@ Saved Selection 25 保存した選択範囲 25 Left-to-Right Paragraph Direction 左から右への段落方向 Right-to-Left Paragraph Direction 右から左への段落方向 + +# About › Contributors / Models (credits.rs) + About 情報 + Contributors コントリビューター + Models モデル + Username ユーザー名 + Display name 表示名 + Real name 本名 + Sort 並べ替え + Reverse the order 順序を逆にする + Grab bag まとめて表示 + Table 表 + Name (A–Z) 名前 (A–Z) + Merged PRs マージされた PR + Commits コミット + Lines added 追加した行数 + Lines deleted 削除した行数 + Line delta 行数の増減 + Binary assets added 追加したバイナリアセット + Binary assets removed 削除したバイナリアセット + First commit 最初のコミット + Last commit 最後のコミット + First 最初 + Last 最後 + {people} contributors · {commits} commits コントリビューター {people} 人 · コミット {commits} 件 + No contributor data was built into this copy. このビルドには貢献者のデータが含まれていません。 + No model credits were built into this copy. このビルドにはモデルのクレジットが含まれていません。 + Company 企業 + Version バージョン + % of all commits 全コミットに占める割合 + Lines +/− 行数 +/− diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c8c330ab..d87f42f1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2580,3 +2580,34 @@ @plural {n} star|{n} stars {n} estrela|{n} estrelas @plural {n} step|{n} steps {n} passo|{n} passos @plural {n} stroke|{n} strokes {n} traçado|{n} traçados + +# About › Contributors / Models (credits.rs) + About Sobre + Contributors Colaboradores + Models Modelos + Username Nome de usuário + Display name Nome de exibição + Real name Nome real + Sort Ordenar + Reverse the order Inverter a ordem + Grab bag Todos juntos + Table Tabela + Name (A–Z) Nome (A–Z) + Merged PRs PRs mesclados + Commits Commits + Lines added Linhas adicionadas + Lines deleted Linhas removidas + Line delta Saldo de linhas + Binary assets added Arquivos binários adicionados + Binary assets removed Arquivos binários removidos + First commit Primeiro commit + Last commit Último commit + First Primeiro + Last Último + {people} contributors · {commits} commits {people} colaboradores · {commits} commits + No contributor data was built into this copy. Esta cópia foi compilada sem dados de colaboradores. + No model credits were built into this copy. Esta cópia foi compilada sem créditos de modelos. + Company Empresa + Version Versão + % of all commits % de todos os commits + Lines +/− Linhas +/− diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2269c572..78813f8f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3219,3 +3219,34 @@ Saved Selection 23 已保存的选区 23 Saved Selection 24 已保存的选区 24 Saved Selection 25 已保存的选区 25 + +# About › Contributors / Models (credits.rs) + About 关于 + Contributors 贡献者 + Models 模型 + Username 用户名 + Display name 显示名称 + Real name 真实姓名 + Sort 排序 + Reverse the order 反转顺序 + Grab bag 名单 + Table 表格 + Name (A–Z) 名称 (A–Z) + Merged PRs 已合并的 PR + Commits 提交 + Lines added 新增行数 + Lines deleted 删除行数 + Line delta 行数变化 + Binary assets added 新增二进制资源 + Binary assets removed 删除二进制资源 + First commit 首次提交 + Last commit 最近提交 + First 首次 + Last 最近 + {people} contributors · {commits} commits {people} 位贡献者 · {commits} 次提交 + No contributor data was built into this copy. 此版本未包含贡献者数据。 + No model credits were built into this copy. 此版本未包含模型致谢。 + Company 公司 + Version 版本 + % of all commits 占全部提交的百分比 + Lines +/− 行数 +/− diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7eeaaa0b..758dd4f7 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2992,3 +2992,34 @@ — {n} characters from another font — {n} 個字符來自其他字體 [Normal Character Style] [正常字元樣式] [Normal Paragraph Style] [正常段落樣式] + +# About › Contributors / Models (credits.rs) + About 關於 + Contributors 貢獻者 + Models 模型 + Username 使用者名稱 + Display name 顯示名稱 + Real name 真實姓名 + Sort 排序 + Reverse the order 反轉順序 + Grab bag 全部列出 + Table 表格 + Name (A–Z) 名稱 (A–Z) + Merged PRs 已合併的 PR + Commits 提交 + Lines added 新增行數 + Lines deleted 刪除行數 + Line delta 行數增減 + Binary assets added 新增的二進位資產 + Binary assets removed 移除的二進位資產 + First commit 首次提交 + Last commit 最近一次提交 + First 首次 + Last 最近 + {people} contributors · {commits} commits {people} 位貢獻者 · {commits} 次提交 + No contributor data was built into this copy. 此版本未內建貢獻者資料。 + No model credits were built into this copy. 此版本未內建模型致謝資料。 + Company 公司 + Version 版本 + % of all commits 佔全部提交的比例 + Lines +/− 行數 +/− diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0ae472ed..a00e2dc7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ pub mod canvas; pub mod chrome; pub mod community; pub mod control; +pub mod credits; pub mod cursors; pub mod dialogs; pub mod dock; diff --git a/docs/contributors.md b/docs/contributors.md new file mode 100644 index 00000000..a3e731fe --- /dev/null +++ b/docs/contributors.md @@ -0,0 +1,56 @@ +# Contributors in the About window + +**About ▸ Contributors** credits everyone who contributed to VectorCraft, and **About ▸ Models** credits +the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard +[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); +this page is the local copy of the decision. + +## Decision + +- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from + disk or the network at run time; the web build has them too): + - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub + username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + - [`contributors/people.toml`](../contributors/people.toml): the names people chose for + themselves. **Each contributor adds only their own entry**, in their own PR. +- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced + GitHub profile name), real name (only if they told us), merged PRs, commits, lines added, lines + deleted, binary assets added, binary assets removed, first and last commit date. +- The list is shown as a **grab bag** (names flowing as text) or a **table**, in the same order. It + sorts by name, PRs, commits, lines added, lines deleted, line delta, binary assets added/removed, + first or last commit date. The name toggle cycles **Username → Display name → Real name**; a + missing name falls back to `@username`. Alphabetical sorting is case-insensitive and ignores the `@`. + +## In VectorCraft + +- The About window is `crates/ui-egui/src/dialogs/about.rs` (Help ▸ About VectorCraft). The labels are + translated like the rest of the UI (`tl!`, `crates/ui-egui/src/i18n`); names and numbers are data. + +## Credit yourself + +Add your entry to `contributors/people.toml` in a PR you commit yourself (every field optional): + +```toml +[people.your-github-username] +real_name = "Your Name" +display_name = "Nickname" +sync_github_name = true # use the name on your public GitHub profile as display name +``` + +or, with craftrules checked out next to `craft-apps/`: + +```sh +python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name +``` + +Names show after the next refresh: the script checks that each entry was committed by its own +user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. + +## Refreshing (maintainers) + +```sh +python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh +python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only +``` + +Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded.