app icon: lynx (owner's ArtCraft drawing), Dock/taskbar/launcher wiring, palette docs

- assets/app-icon/: lightcraft.svg (master), lightcraft-small.svg, 1024 PNG, macOS 512 PNG,
  .icns, .ico, hicolor theme (ai.storyteller.lightcraft), LICENSE.txt, README (palette,
  geometry, provenance, files)
- packaging/icons.sh regenerates everything (resvg, iconutil, new `cargo xtask ico`)
- apps/lightcraft: runtime window/Dock icon + Wayland app id; build.rs embeds the .ico and
  VERSIONINFO into lightcraft.exe (winresource, Windows targets only)
- packaging/linux/ai.storyteller.lightcraft.desktop
- ATTRIBUTION.md entry; icon at the top of README

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 09:37:06 -04:00
parent ece59f161c
commit 75e4e3ced0
27 changed files with 339 additions and 1 deletions

41
Cargo.lock generated
View File

@ -2085,6 +2085,7 @@ dependencies = [
"muda",
"rfd",
"serde_json",
"winresource",
]
[[package]]
@ -3768,6 +3769,15 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "shlex"
version = "2.0.1"
@ -4058,6 +4068,21 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "toml"
version = "1.1.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
@ -4088,6 +4113,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tracing"
version = "0.1.44"
@ -5003,6 +5034,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winresource"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
dependencies = [
"toml",
"version_check",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"

View File

@ -7,6 +7,10 @@
</a>
</p>
<p align="center">
<img alt="LightCraft app icon: an engraved lynx in paper and ink on LightCraft amber (#f2a516)" src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.lightcraft.png" width="128">
</p>
<h1 align="center">LightCraft</h1>
<h3 align="center">Your photos. Your pixels. Your machine.</h3>

View File

@ -19,6 +19,10 @@ egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for lightcraft.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }

28
apps/lightcraft/build.rs Normal file
View File

@ -0,0 +1,28 @@
//! Windows only: embed the app icon and version info (VERSIONINFO) into `lightcraft.exe`, so
//! Explorer, the taskbar, the Start menu and Alt-Tab show the lynx.
//!
//! On every other target this does nothing. A missing resource compiler is a warning, so a
//! cross-compile from macOS or Linux still links, unless `LIGHTCRAFT_REQUIRE_WINRES=1` turns it
//! into an error (for release builds).
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../assets/app-icon/lightcraft.ico");
println!("cargo:rerun-if-env-changed=LIGHTCRAFT_REQUIRE_WINRES");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
let mut res = winresource::WindowsResource::new();
res.set_icon("../../assets/app-icon/lightcraft.ico")
.set("ProductName", "LightCraft")
.set("FileDescription", "LightCraft photo library and raw developer")
.set("LegalCopyright", "Copyright (c) the LightCraft authors. MIT OR Apache-2.0.")
.set("OriginalFilename", "lightcraft.exe")
.set("InternalName", "lightcraft");
if let Err(e) = res.compile() {
if std::env::var_os("LIGHTCRAFT_REQUIRE_WINRES").is_some() {
panic!("embedding Windows resources failed: {e}");
}
println!("cargo:warning=lightcraft.exe built without icon/version resources: {e}");
}
}

View File

@ -21,6 +21,19 @@ mod native_menu;
use lightcraft_engine::Session;
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
/// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name.
const APP_ID: &str = "ai.storyteller.lightcraft";
/// The window, taskbar and Dock icon (assets/app-icon/, regenerated by packaging/icons.sh). macOS
/// gets the version with Apple's transparent margin so it sits right among the other Dock icons.
fn app_icon() -> egui::IconData {
#[cfg(target_os = "macos")]
let png: &[u8] = include_bytes!("../../../assets/app-icon/lightcraft-macos-512.png");
#[cfg(not(target_os = "macos"))]
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.lightcraft.png");
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
struct App(LightcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
impl eframe::App for App {
@ -251,7 +264,10 @@ fn main() -> eframe::Result {
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false),
.with_title_shown(false)
.with_icon(app_icon())
// Wayland matches the window to packaging/linux/ai.storyteller.lightcraft.desktop by this id
.with_app_id(APP_ID),
..Default::default()
};
eframe::run_native(

View File

@ -9,6 +9,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| Path | Asset | Creator | Source | Licence | Added | Modifications |
|---|---|---|---|---|---|---|
| `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none |
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |

View File

@ -0,0 +1,13 @@
LightCraft app icon (lightcraft.svg and every file derived from it in this directory:
lightcraft-small.svg, lightcraft-1024.png, lightcraft-macos-512.png, lightcraft.icns,
lightcraft.ico, hicolor/).
Original artwork made for this project by the LightCraft project owner in ArtCraft (no
third-party material). Licensed like the rest of LightCraft, under either of
* Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
* MIT license (https://opensource.org/licenses/MIT)
at your option.
Regenerate the derived files with packaging/icons.sh.

49
assets/app-icon/README.md Normal file
View File

@ -0,0 +1,49 @@
# LightCraft app icon
A **lynx**, frontal head-and-shoulders portrait with an alert gaze, in the style of an engraving: the
Crafting Apps "owl template" (full-bleed colour field, tight portrait, the figure in paper with ink line
work and a crisp ink contour, no roundel or border).
## Palette (exactly three colours)
| Colour | Hex | Used for |
|---|---|---|
| Ink | `#0b0b0c` | line work and the contour |
| Paper | `#efe9dc` | the figure (the lynx) |
| LightCraft amber | `#f2a516` | the full-bleed field (the app colour) |
## Geometry
`viewBox="0 0 512 512"`, a rounded-square tile with `rx=112`, clipped; the colour fills the whole tile.
macOS files (`.icns`, `lightcraft-macos-512.png`) add Apple's transparent margin (an 824/1024 body);
Windows and Linux files use the full-bleed tile so the lynx reads at 16-48 px.
## Provenance
The owner's original drawing, made in ArtCraft (2880 px, keyed to the three colours), vectorised with
craftrules `assets/logo-options/_tools/vectorize_tile.py`. The source drawing stays in craftrules at
`assets/app-icons/lightcraft/source.png`; `lightcraft.svg` here is the canonical artwork. Licence:
`LICENSE.txt` (MIT OR Apache-2.0, like the repo).
## Files
| File | What |
|---|---|
| `lightcraft.svg` | canonical vector, traced at 2048 px (about 770 KB) |
| `lightcraft-small.svg` | lighter vector, traced at 1024 px (about 360 KB) |
| `lightcraft-1024.png` | 1024 px render (store listings, docs) |
| `lightcraft-macos-512.png` | runtime window/Dock icon on macOS (embedded by `apps/lightcraft/src/main.rs`) |
| `lightcraft.icns` | macOS bundle icon (`CFBundleIconFile`) |
| `lightcraft.ico` | Windows icon, 16-256 px, embedded in `lightcraft.exe` by `apps/lightcraft/build.rs` |
| `hicolor/<n>x<n>/apps/ai.storyteller.lightcraft.png` | Linux icon theme, 16-512 px; the 256 px one is also the runtime window icon on Windows and Linux |
| `hicolor/scalable/apps/ai.storyteller.lightcraft.svg` | Linux scalable icon (the small SVG) |
The app id is `ai.storyteller.lightcraft`: the Wayland app id, the `.desktop` file
(`packaging/linux/ai.storyteller.lightcraft.desktop`, `Icon=ai.storyteller.lightcraft`) and the hicolor icon
name. To install on Linux, copy `hicolor/` into `/usr/share/icons/hicolor/` (or `~/.local/share/icons/hicolor/`)
and the `.desktop` file into `applications/`.
## Regenerate
Edit `lightcraft.svg` (or `lightcraft-small.svg`), then run `packaging/icons.sh` (needs `resvg`; `iconutil`
on macOS for the `.icns`; the `.ico` is packed by `cargo xtask ico`). Every output is committed.

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 750 KiB

59
packaging/icons.sh Executable file
View File

@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Regenerate every app icon file from assets/app-icon/lightcraft.svg.
#
# Needs: resvg (brew install resvg / cargo install resvg). On macOS, iconutil also writes the
# .icns. The outputs are committed, so building and packaging never need these tools.
#
# packaging/icons.sh
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIR="$ROOT/assets/app-icon"
SVG="$DIR/lightcraft.svg"
ID="ai.storyteller.lightcraft"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
command -v resvg >/dev/null || { echo "error: resvg not found (brew install resvg)" >&2; exit 1; }
# The SVG is the full-bleed 512 tile (rx=112). Windows and Linux use it as is, so the lynx reads at
# 16-48 px. macOS icons follow Apple's grid: an 824/1024 body with a transparent margin, made by
# widening the viewBox (512 / 0.805 = 636, so 62 units each side).
MAC="$TMP/macos.svg"
sed 's/viewBox="0 0 512 512"/viewBox="-62 -62 636 636"/' "$SVG" >"$MAC"
grep -q 'viewBox="-62 -62 636 636"' "$MAC" || { echo "error: unexpected viewBox in $SVG" >&2; exit 1; }
render() { resvg -w "$2" -h "$2" "$1" "$3" </dev/null; }
render "$SVG" 1024 "$DIR/lightcraft-1024.png"
# Runtime window/Dock icon on macOS (embedded by apps/lightcraft/src/main.rs).
render "$MAC" 512 "$DIR/lightcraft-macos-512.png"
# Linux hicolor theme (also the runtime window icon on Windows and Linux: 256x256).
for s in 16 24 32 48 64 128 256 512; do
mkdir -p "$DIR/hicolor/${s}x${s}/apps"
render "$SVG" "$s" "$DIR/hicolor/${s}x${s}/apps/$ID.png"
done
mkdir -p "$DIR/hicolor/scalable/apps"
cp "$DIR/lightcraft-small.svg" "$DIR/hicolor/scalable/apps/$ID.svg"
# Windows .ico.
ICO_PNGS=()
for s in 16 20 24 32 40 48 64 128 256; do
render "$SVG" "$s" "$TMP/ico-$s.png"
ICO_PNGS+=("$TMP/ico-$s.png")
done
(cd "$ROOT" && cargo run -q -p xtask -- ico "$DIR/lightcraft.ico" "${ICO_PNGS[@]}")
# macOS .icns.
if command -v iconutil >/dev/null; then
SET="$TMP/lightcraft.iconset"
mkdir -p "$SET"
for s in 16 32 128 256 512; do
render "$MAC" "$s" "$SET/icon_${s}x${s}.png"
render "$MAC" $((s * 2)) "$SET/icon_${s}x${s}@2x.png"
done
iconutil -c icns -o "$DIR/lightcraft.icns" "$SET"
else
echo "warning: iconutil not found (macOS only); lightcraft.icns not regenerated" >&2
fi
echo "icons written to $DIR"

View File

@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Name=LightCraft
GenericName=Photo Library and Raw Developer
Comment=Organise photos and develop raw files, non-destructively
Exec=lightcraft %F
Icon=ai.storyteller.lightcraft
Terminal=false
Categories=Graphics;2DGraphics;RasterGraphics;Photography;
Keywords=photo;raw;camera;library;develop;
StartupWMClass=ai.storyteller.lightcraft
MimeType=image/jpeg;image/png;image/tiff;image/webp;image/x-adobe-dng;image/x-sony-arw;image/x-canon-cr2;image/x-canon-cr3;image/x-nikon-nef;image/x-fuji-raf;image/x-olympus-orf;image/x-panasonic-rw2;image/x-pentax-pef;

104
xtask/src/ico.rs Normal file
View File

@ -0,0 +1,104 @@
//! `cargo xtask ico <out.ico> <in.png>…`: pack PNG images into a Windows `.ico`.
//!
//! Vista and later read PNG-compressed icon entries, so each PNG is stored as is. The size comes
//! from the PNG's IHDR chunk; 256 px is written as 0, per the ICO format.
use std::path::Path;
/// Width and height from a PNG's IHDR chunk.
pub fn png_size(png: &[u8]) -> Result<(u32, u32), String> {
const SIG: &[u8] = b"\x89PNG\r\n\x1a\n";
if png.len() < 24 || &png[..8] != SIG || &png[12..16] != b"IHDR" {
return Err("not a PNG file".into());
}
let w = u32::from_be_bytes([png[16], png[17], png[18], png[19]]);
let h = u32::from_be_bytes([png[20], png[21], png[22], png[23]]);
Ok((w, h))
}
/// An ICO file holding `pngs`, which must be square and at most 256 px.
pub fn pack(pngs: &[Vec<u8>]) -> Result<Vec<u8>, String> {
if pngs.is_empty() || pngs.len() > u16::MAX as usize {
return Err("need at least one PNG".into());
}
let mut out = Vec::new();
out.extend_from_slice(&0u16.to_le_bytes()); // reserved
out.extend_from_slice(&1u16.to_le_bytes()); // type: icon
out.extend_from_slice(&(pngs.len() as u16).to_le_bytes());
let mut offset = 6 + 16 * pngs.len();
for png in pngs {
let (w, h) = png_size(png)?;
if w != h || w == 0 || w > 256 {
return Err(format!("icon images must be square and at most 256 px, got {w}x{h}"));
}
let dim = if w == 256 { 0 } else { w as u8 };
out.extend_from_slice(&[dim, dim, 0, 0]); // width, height, palette size, reserved
out.extend_from_slice(&1u16.to_le_bytes()); // colour planes
out.extend_from_slice(&32u16.to_le_bytes()); // bits per pixel
out.extend_from_slice(&(png.len() as u32).to_le_bytes());
out.extend_from_slice(&(offset as u32).to_le_bytes());
offset += png.len();
}
for png in pngs {
out.extend_from_slice(png);
}
Ok(out)
}
pub fn run(args: &[&str]) -> Result<(), String> {
let [out, inputs @ ..] = args else {
return Err("usage: cargo xtask ico <out.ico> <in.png>…".into());
};
let pngs = inputs.iter().map(|p| std::fs::read(p).map_err(|e| format!("read {p}: {e}"))).collect::<Result<Vec<_>, _>>()?;
let ico = pack(&pngs)?;
std::fs::write(Path::new(out), &ico).map_err(|e| format!("write {out}: {e}"))?;
println!("{out}: {} images, {} bytes", pngs.len(), ico.len());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_png(size: u32) -> Vec<u8> {
let mut v = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
v.extend_from_slice(&size.to_be_bytes());
v.extend_from_slice(&size.to_be_bytes());
v.extend_from_slice(&[8, 6, 0, 0, 0, 1, 2, 3, 4]);
v
}
#[test]
fn packs_directory_and_payloads() {
let a = fake_png(16);
let b = fake_png(256);
let ico = pack(&[a.clone(), b.clone()]).unwrap();
assert_eq!(&ico[..6], &[0, 0, 1, 0, 2, 0]);
// Entry 0: 16 px at offset 6 + 32.
assert_eq!(&ico[6..8], &[16, 16]);
assert_eq!(u32::from_le_bytes(ico[14..18].try_into().unwrap()), a.len() as u32);
assert_eq!(u32::from_le_bytes(ico[18..22].try_into().unwrap()), 38);
// Entry 1: 256 px stored as 0, right after the first payload.
assert_eq!(&ico[22..24], &[0, 0]);
assert_eq!(u32::from_le_bytes(ico[34..38].try_into().unwrap()), 38 + a.len() as u32);
assert_eq!(&ico[38..38 + a.len()], &a[..]);
assert_eq!(&ico[38 + a.len()..], &b[..]);
}
#[test]
fn rejects_bad_input() {
assert!(pack(&[]).is_err());
assert!(pack(&[fake_png(512)]).is_err());
assert!(pack(&[b"GIF89a".to_vec()]).is_err());
assert_eq!(png_size(&fake_png(48)).unwrap(), (48, 48));
}
#[test]
fn committed_icon_is_valid() {
let path = crate::root().join("assets/app-icon/lightcraft.ico");
let Ok(ico) = std::fs::read(&path) else { return };
assert_eq!(&ico[..4], &[0, 0, 1, 0]);
let n = u16::from_le_bytes([ico[4], ico[5]]) as usize;
assert!(n >= 4, "expected several sizes in {}", path.display());
}
}

View File

@ -5,6 +5,7 @@
mod assets;
mod bench;
mod ico;
mod layers;
mod parity;
mod stats;
@ -21,6 +22,8 @@ commands:
bench [FILE] [--strict] [--threshold PCT]
run the render benchmark, append to target/bench/history.jsonl, compare CPU time with
the previous run (default input: corpus/raw/arw-sony-a7m3-compressed.arw)
ico <out.ico> <in.png>...
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
layers enforce the crate dependency layering (plan/architecture.md §3)
parity [--write]
check docs/parity.md (every cmd:/ctl: id and path it cites exists) and print the
@ -39,6 +42,7 @@ fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
let result = match args.first().map(String::as_str) {
Some("ico") => ico::run(&rest),
Some("layers") => cmd_layers(),
Some("assets") => assets::run(&root()),
Some("bench") => bench::run(&root(), &rest),