collective

Browse / github.com/storytold/artcraft / patch

artcraft-native: Shot List board for planning a scene, with Markdown export

artcraft-native: Shot List board for planning a scene, with Markdown export ## Demo ![demo](https://raw.githubusercontent.com/mvanhorn/pr-demo-assets/main/artcraft/native-shot-list/demo.gif) <video src="https://raw.githubusercontent.com/mvanhorn/pr-demo-assets/main/artcraft/native-shot-list/demo.mp4" controls></video> | Before | After | | --- | --- | | <img src="https://raw.githubusercontent.com/mvanhorn/pr-demo-assets/main/artcraft/native-shot-list/before.png" width="420" alt="before"> | <img src="https://raw.githubusercontent.com/mvanhorn/pr-demo-assets/main/artcraft/native-shot-list/after.png" width="420" alt="after"> | ## Summary Studio › Shot List is a local

Status
open
Score
0.00
Adopters
0 other operators
Author
mvanhorn (imported from pull request #2106, open)
Base
28a39a0de4a2fc2eccc237df2046c24245bd12ab
Size
+607 −11 in 5 files
Review
pending
Shared
2026-10-28
Id
dd298077eb88e7a43a6f1043

How it went

No other operator has reported using it yet.

Diff

raw · artcraft-native/README.md artcraft-native/src/app.rs artcraft-native/src/pages/mod.rs artcraft-native/src/pages/shot_list.rs artcraft-native/src/shell.rs

diff --git a/artcraft-native/README.md b/artcraft-native/README.md--- a/artcraft-native/README.md+++ b/artcraft-native/README.md@@ -7,8 +7,9 @@ The look follows the brutalist webapp (`artcraft-services/frontend/apps/artcraft means near-black surfaces with white/15 hairlines and 3 px corners on controls. Type is Archivo for display, Inter for body text and Geist Mono for labels. The brand blue marks the primary actions. -Create Image, Create Video and Create Audio are fully built. The other tools show a "coming soon"-page that links to the same tool in the web app.+Create Image, Create Video and Create Audio are fully built. Shot List is a local board for+planning scenes and does not need an account. The other tools show a "coming soon" page that+links to the same tool in the web app.  ## Running @@ -35,7 +36,7 @@ The workspace's default member is the Tauri app, so pass `-p artcraft_native`. | `backend::audio`   | One clip at a time: reference previews, feed tracks (pause, seek, volume), waveforms | | `models`           | Model catalog from OmniGen listings, plus names, families and limits           | | `prompt_box`       | The shared prompt box: editor, reference deck, keyframes, pickers, model selector |-| `pages`            | Create Image, Video and Audio, Home, Library, and the shared page shell        |+| `pages`            | Create Image, Video and Audio, Shot List, Home, Library, and the page shell    | | `feed`             | Generation feed: running, failed and finished jobs, library paging, grid and list | | `overlays`         | Lightbox, library picker, sign-in and settings dialogs                         | | `shell`            | Custom title bar, sidebar flush to the window edge, content panel header       |@@ -93,8 +94,9 @@ Feed and lightbox: ## Diagnostics  Set `ARTCRAFT_SCREENSHOT=<file.png>` to save a screenshot once the window has settled.-`ARTCRAFT_PAGE` (`home`, `create-image`, `create-video`, `create-audio` or `library`) picks the page, and-`ARTCRAFT_SCREENSHOT_EXIT=1` closes the app afterwards.+`ARTCRAFT_PAGE` (`home`, `create-image`, `create-video`, `create-audio`, `library` or `shot-list`)+picks the page, and `ARTCRAFT_SCREENSHOT_EXIT=1` closes the app afterwards. Shot List is stored in+`~/Artcraft/shot_list.json`.  ## Tests @@ -102,9 +104,10 @@ Set `ARTCRAFT_SCREENSHOT=<file.png>` to save a screenshot once the window has se cargo test -p artcraft_native ``` -There are unit tests for the catalog, feed store, wire decoding and backend helpers. Headless UI-tests (`egui_kittest`) drive the prompt box (generate gating, Enter to generate, the pickers, `@`-mentions, clear-all), the Characters dialog and the audio toolbar (toggles, Beat & Key, Tuning).+There are unit tests for the catalog, feed store, wire decoding, backend helpers and Shot List+(reorder, filter, Markdown export, JSON round-trip). Headless UI tests (`egui_kittest`) drive the+prompt box (generate gating, Enter to generate, the pickers, `@` mentions, clear-all), the+Characters dialog, the audio toolbar (toggles, Beat & Key, Tuning) and Shot List (new shot).  ## Fonts and brand assets diff --git a/artcraft-native/src/app.rs b/artcraft-native/src/app.rs--- a/artcraft-native/src/app.rs+++ b/artcraft-native/src/app.rs@@ -25,6 +25,7 @@ use crate::pages::create_audio::{AudioSettings, CreateAudioPage}; use crate::pages::create_image::{CreateImagePage, ImageSettings}; use crate::pages::create_video::{CreateVideoPage, VideoSettings}; use crate::pages::other;+use crate::pages::shot_list::ShotListPage; use crate::prompt_box::refs::{RefKind, RefMedia, References}; use crate::shell::{self, Account, FeedToggles, Page, ShellAction}; use crate::theme;@@ -84,6 +85,7 @@ pub struct ArtcraftApp {   image: CreateImagePage,   video: CreateVideoPage,   audio: CreateAudioPage,+  shot_list: ShotListPage,   library: FeedStore,   ratios: RatioCache,   /// Prompt texts by prompt token (list view, lightbox).@@ -125,7 +127,7 @@ impl ArtcraftApp {       backend.load_models(modality);     }     backend.refresh_session();-    Self { cache, player, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, autoplay: saved.autoplay, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), audio: CreateAudioPage::new(saved.audio), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, characters_modal: None, characters: Vec::new(), pending_characters: Vec::new(), settings_open: false, preview: None, requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend }+    Self { cache, player, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, autoplay: saved.autoplay, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), audio: CreateAudioPage::new(saved.audio), shot_list: ShotListPage::new(backend.data_root()), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, characters_modal: None, characters: Vec::new(), pending_characters: Vec::new(), settings_open: false, preview: None, requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend }   }    // --- Events from the backend -------------------------------------------------------------@@ -597,6 +599,7 @@ impl ArtcraftApp {             Page::CreateVideo => self.video.ui(ui, &mut env),             Page::CreateAudio => self.audio.ui(ui, &mut env),             Page::Library => other::library(ui, &mut env, &mut self.library),+            Page::ShotList => self.shot_list.ui(ui),             page => other::coming_soon(ui, page),           }         });diff --git a/artcraft-native/src/pages/mod.rs b/artcraft-native/src/pages/mod.rs--- a/artcraft-native/src/pages/mod.rs+++ b/artcraft-native/src/pages/mod.rs@@ -3,4 +3,5 @@ pub mod create_audio; pub mod create_image; pub mod create_video; pub mod other;+pub mod shot_list; pub mod toolbar;diff --git a/artcraft-native/src/pages/shot_list.rs b/artcraft-native/src/pages/shot_list.rsnew file mode 100644--- /dev/null+++ b/artcraft-native/src/pages/shot_list.rs@@ -0,0 +1,577 @@+//! Shot List: a local board of shots for a scene or short. Titles, duration, camera, status and+//! notes live in `~/Artcraft/shot_list.json`. Nothing is sent anywhere.++use std::fs;+use std::path::{Path, PathBuf};++use egui::{Align2, Color32, FontId, Id, Rect, Sense, Ui, pos2, vec2};+use log::warn;+use serde::{Deserialize, Serialize};++use crate::theme;+use crate::ui::icons::Icon;+use crate::ui::widgets;++const STORE_NAME: &str = "shot_list.json";+const MAX_TITLE: usize = 120;+const MAX_TEXT: usize = 4_000;++/// How far a shot has come.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]+#[serde(rename_all = "snake_case")]+pub enum ShotStatus {+  #[default]+  Idea,+  Ready,+  Shooting,+  Done,+}++impl ShotStatus {+  pub const ALL: [ShotStatus; 4] = [ShotStatus::Idea, ShotStatus::Ready, ShotStatus::Shooting, ShotStatus::Done];++  pub fn label(self) -> &'static str {+    match self {+      ShotStatus::Idea => "Idea",+      ShotStatus::Ready => "Ready",+      ShotStatus::Shooting => "Shooting",+      ShotStatus::Done => "Done",+    }+  }+}++/// One row on the board.+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]+pub struct Shot {+  pub id: String,+  pub title: String,+  pub description: String,+  pub duration_secs: u32,+  pub camera: String,+  pub status: ShotStatus,+}++impl Shot {+  pub fn new(title: impl Into<String>) -> Self {+    Self { id: uuid::Uuid::new_v4().to_string(), title: title.into(), description: String::new(), duration_secs: 0, camera: String::new(), status: ShotStatus::Idea }+  }+}++/// The document written to disk.+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]+pub struct ShotListDoc {+  #[serde(default)]+  pub shots: Vec<Shot>,+}++impl ShotListDoc {+  pub fn add(&mut self, title: impl Into<String>) -> String {+    let shot = Shot::new(title);+    let id = shot.id.clone();+    self.shots.push(shot);+    id+  }++  pub fn remove(&mut self, id: &str) -> bool {+    let before = self.shots.len();+    self.shots.retain(|s| s.id != id);+    self.shots.len() != before+  }++  pub fn get_mut(&mut self, id: &str) -> Option<&mut Shot> {+    self.shots.iter_mut().find(|s| s.id == id)+  }++  pub fn move_by(&mut self, id: &str, delta: i32) -> bool {+    let Some(i) = self.shots.iter().position(|s| s.id == id) else {+      return false;+    };+    let j = i as i32 + delta;+    if j < 0 || j as usize >= self.shots.len() {+      return false;+    }+    self.shots.swap(i, j as usize);+    true+  }++  pub fn filtered(&self, query: &str, status: Option<ShotStatus>) -> Vec<&Shot> {+    let q = query.trim().to_lowercase();+    self+      .shots+      .iter()+      .filter(|s| status.is_none_or(|st| s.status == st))+      .filter(|s| {+        if q.is_empty() {+          return true;+        }+        s.title.to_lowercase().contains(&q) || s.description.to_lowercase().contains(&q) || s.camera.to_lowercase().contains(&q)+      })+      .collect()+  }++  /// A padded Markdown table, newest order as stored.+  pub fn to_markdown(&self) -> String {+    let mut rows: Vec<[String; 6]> = Vec::with_capacity(self.shots.len() + 1);+    rows.push(["#".into(), "Title".into(), "Duration".into(), "Camera".into(), "Status".into(), "Description".into()]);+    for (i, shot) in self.shots.iter().enumerate() {+      rows.push([(i + 1).to_string(), shot.title.clone(), format_duration(shot.duration_secs), shot.camera.clone(), shot.status.label().to_owned(), one_line(&shot.description)]);+    }+    pad_table(&rows)+  }++  pub fn load(path: &Path) -> Result<Self, String> {+    let text = fs::read_to_string(path).map_err(|e| e.to_string())?;+    serde_json::from_str(&text).map_err(|e| e.to_string())+  }++  pub fn save(&self, path: &Path) -> Result<(), String> {+    if let Some(parent) = path.parent() {+      fs::create_dir_all(parent).map_err(|e| e.to_string())?;+    }+    let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;+    fs::write(path, text).map_err(|e| e.to_string())+  }+}++/// The Studio › Shot List page.+pub struct ShotListPage {+  path: PathBuf,+  doc: ShotListDoc,+  selected: Option<String>,+  query: String,+  filter: Option<ShotStatus>,+  dirty: bool,+  notice: Option<String>,+}++impl ShotListPage {+  pub fn new(data_root: &Path) -> Self {+    let path = data_root.join(STORE_NAME);+    let (doc, notice) = match ShotListDoc::load(&path) {+      Ok(doc) => (doc, None),+      Err(_) if !path.exists() => (ShotListDoc::default(), None),+      Err(err) => (ShotListDoc::default(), Some(format!("Couldn't read the shot list: {err}"))),+    };+    let selected = doc.shots.first().map(|s| s.id.clone());+    Self { path, doc, selected, query: String::new(), filter: None, dirty: false, notice }+  }++  pub fn ui(&mut self, ui: &mut Ui) {+    ui.add_space(8.0);+    ui.horizontal(|ui| {+      ui.add_space(20.0);+      ui.vertical(|ui| {+        ui.set_width((ui.available_width() - 16.0).max(240.0));+        self.toolbar(ui);+        if let Some(notice) = &self.notice {+          ui.add_space(8.0);+          ui.label(egui::RichText::new(notice).color(theme::BAD).size(13.0));+        }+        ui.add_space(12.0);+        let height = (ui.available_height() - 12.0).max(220.0);+        ui.horizontal(|ui| {+          ui.set_height(height);+          let list_w = (ui.available_width() * 0.38).clamp(220.0, 420.0);+          ui.vertical(|ui| {+            ui.set_width(list_w);+            ui.set_min_height(height);+            self.list_pane(ui);+          });+          ui.add_space(16.0);+          ui.vertical(|ui| {+            ui.set_width(ui.available_width());+            ui.set_min_height(height);+            self.editor_pane(ui);+          });+        });+      });+    });+    if self.dirty {+      self.persist();+    }+  }++  fn toolbar(&mut self, ui: &mut Ui) {+    ui.horizontal(|ui| {+      ui.spacing_mut().item_spacing.x = 8.0;+      if widgets::button(ui, Some(Icon::Plus), "New Shot", widgets::Kind::Primary, 0.0, 36.0).clicked() {+        self.add_shot();+      }+      ui.add_enabled_ui(!self.doc.shots.is_empty(), |ui| {+        if widgets::button(ui, Some(Icon::Download), "Export Markdown", widgets::Kind::Secondary, 0.0, 36.0).clicked() {+          self.export_markdown();+        }+      });+      ui.add_space(12.0);+      let mut query = std::mem::take(&mut self.query);+      ui.add(egui::TextEdit::singleline(&mut query).desired_width(220.0).hint_text("Search shots").id(Id::new("shot-list-search")));+      self.query = query;+      ui.add_space(8.0);+      self.filter_chips(ui);+    });+  }++  fn filter_chips(&mut self, ui: &mut Ui) {+    if chip(ui, "All", self.filter.is_none()).clicked() {+      self.filter = None;+    }+    for status in ShotStatus::ALL {+      if chip(ui, status.label(), self.filter == Some(status)).clicked() {+        self.filter = if self.filter == Some(status) { None } else { Some(status) };+      }+    }+  }++  fn list_pane(&mut self, ui: &mut Ui) {+    egui::Frame::new().fill(theme::WASH).stroke(theme::hairline()).inner_margin(egui::Margin::same(8)).show(ui, |ui| {+      ui.set_min_height(ui.available_height());+      ui.set_width(ui.available_width());+      let visible: Vec<String> = self.doc.filtered(&self.query, self.filter).into_iter().map(|s| s.id.clone()).collect();+      if visible.is_empty() {+        ui.centered_and_justified(|ui| {+          ui.label(egui::RichText::new(if self.doc.shots.is_empty() { "No shots yet. Add one to start a board." } else { "No shots match this search." }).color(theme::MUTED));+        });+        return;+      }+      egui::ScrollArea::vertical().id_salt("shot-list-rows").auto_shrink([false, false]).show(ui, |ui| {+        ui.set_width(ui.available_width());+        for id in visible {+          if let Some(shot) = self.doc.shots.iter().find(|s| s.id == id) {+            let selected = self.selected.as_deref() == Some(id.as_str());+            if shot_row(ui, shot, selected).clicked() {+              self.selected = Some(id);+            }+            ui.add_space(4.0);+          }+        }+      });+    });+  }++  fn editor_pane(&mut self, ui: &mut Ui) {+    egui::Frame::new().fill(theme::WASH).stroke(theme::hairline()).inner_margin(egui::Margin::same(18)).show(ui, |ui| {+      ui.set_min_height(ui.available_height());+      ui.set_width(ui.available_width());+      let Some(id) = self.selected.clone() else {+        ui.centered_and_justified(|ui| {+          ui.label(egui::RichText::new("Select a shot, or add a new one.").color(theme::MUTED));+        });+        return;+      };+      let Some(index) = self.doc.shots.iter().position(|s| s.id == id) else {+        ui.label(egui::RichText::new("That shot is gone.").color(theme::MUTED));+        return;+      };+      let total = self.doc.shots.len();+      ui.horizontal(|ui| {+        ui.label(widgets::heading("Shot", 28.0));+        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+          ui.spacing_mut().item_spacing.x = 6.0;+          if widgets::ghost(ui, Icon::Trash, None, "Delete shot", false).clicked() {+            self.delete_selected();+          }+          ui.add_enabled_ui(index + 1 < total, |ui| {+            if widgets::ghost(ui, Icon::ChevronDown, None, "Move down", false).clicked() {+              self.doc.move_by(&id, 1);+              self.dirty = true;+            }+          });+          ui.add_enabled_ui(index > 0, |ui| {+            if widgets::ghost(ui, Icon::ChevronUp, None, "Move up", false).clicked() {+              self.doc.move_by(&id, -1);+              self.dirty = true;+            }+          });+        });+      });+      if self.selected.as_deref() != Some(id.as_str()) {+        return;+      }+      let Some(shot) = self.doc.get_mut(&id) else {+        return;+      };+      ui.add_space(12.0);+      widgets::hud(ui, "Title", theme::FAINT);+      ui.add_space(4.0);+      let title = ui.add(egui::TextEdit::singleline(&mut shot.title).desired_width(ui.available_width()).hint_text("Wide shot of the hall").char_limit(MAX_TITLE));+      if title.changed() {+        self.dirty = true;+      }+      ui.add_space(12.0);+      ui.horizontal(|ui| {+        ui.vertical(|ui| {+          ui.set_width(140.0);+          widgets::hud(ui, "Duration (sec)", theme::FAINT);+          ui.add_space(4.0);+          if ui.add(egui::DragValue::new(&mut shot.duration_secs).range(0..=600).speed(1.0).suffix(" s")).changed() {+            self.dirty = true;+          }+        });+        ui.add_space(16.0);+        ui.vertical(|ui| {+          ui.set_width((ui.available_width() - 16.0).max(80.0));+          widgets::hud(ui, "Camera", theme::FAINT);+          ui.add_space(4.0);+          if ui.add(egui::TextEdit::singleline(&mut shot.camera).desired_width(ui.available_width()).hint_text("35mm, tracking left").char_limit(MAX_TITLE)).changed() {+            self.dirty = true;+          }+        });+      });+      ui.add_space(12.0);+      widgets::hud(ui, "Status", theme::FAINT);+      ui.add_space(6.0);+      ui.horizontal(|ui| {+        let current = shot.status;+        for status in ShotStatus::ALL {+          if chip(ui, status.label(), current == status).clicked() {+            shot.status = status;+            self.dirty = true;+          }+        }+      });+      ui.add_space(12.0);+      widgets::hud(ui, "Description", theme::FAINT);+      ui.add_space(4.0);+      let desc = ui.add(egui::TextEdit::multiline(&mut shot.description).desired_width(ui.available_width()).desired_rows(8).hint_text("What happens in this shot.").char_limit(MAX_TEXT));+      if desc.changed() {+        self.dirty = true;+      }+    });+  }++  fn add_shot(&mut self) {+    let n = self.doc.shots.len() + 1;+    let id = self.doc.add(format!("Shot {n}"));+    self.selected = Some(id);+    self.dirty = true;+  }++  fn delete_selected(&mut self) {+    let Some(id) = self.selected.clone() else {+      return;+    };+    let idx = self.doc.shots.iter().position(|s| s.id == id);+    self.doc.remove(&id);+    self.selected = idx.and_then(|i| if i < self.doc.shots.len() { Some(self.doc.shots[i].id.clone()) } else { self.doc.shots.last().map(|s| s.id.clone()) });+    self.dirty = true;+  }++  fn export_markdown(&self) {+    let name = "shot-list.md";+    if let Some(path) = rfd::FileDialog::new().set_file_name(name).save_file() {+      if let Err(err) = fs::write(&path, self.doc.to_markdown()) {+        warn!("Couldn't export shot list: {err}");+      }+    }+  }++  fn persist(&mut self) {+    match self.doc.save(&self.path) {+      Ok(()) => {+        self.dirty = false;+        self.notice = None;+      },+      Err(err) => self.notice = Some(format!("Couldn't save the shot list: {err}")),+    }+  }+}++fn shot_row(ui: &mut Ui, shot: &Shot, selected: bool) -> egui::Response {+  let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 56.0), Sense::click());+  let hovered = resp.hovered();+  let fill = if selected {+    theme::WASH_SELECTED+  } else if hovered {+    theme::WASH_HOVER+  } else {+    Color32::TRANSPARENT+  };+  ui.painter().rect_filled(rect, theme::RADIUS, fill);+  if selected {+    ui.painter().rect_filled(Rect::from_min_size(pos2(rect.left(), rect.top() + 10.0), vec2(2.0, 36.0)), 0.0, Color32::WHITE);+  }+  let title = if shot.title.trim().is_empty() { "Untitled shot" } else { shot.title.trim() };+  ui.painter().text(pos2(rect.left() + 14.0, rect.center().y - 8.0), Align2::LEFT_CENTER, title, FontId::new(14.0, theme::medium()), theme::INK);+  let sub = format!("{} · {}", shot.status.label(), format_duration(shot.duration_secs));+  ui.painter().text(pos2(rect.left() + 14.0, rect.center().y + 10.0), Align2::LEFT_CENTER, sub, FontId::new(12.0, egui::FontFamily::Proportional), theme::MUTED);+  resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title));+  resp.on_hover_cursor(egui::CursorIcon::PointingHand)+}++fn chip(ui: &mut Ui, label: &str, on: bool) -> egui::Response {+  let galley = ui.painter().layout_no_wrap(label.to_owned(), FontId::new(12.5, theme::medium()), theme::INK);+  let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 18.0, 28.0), Sense::click());+  let fill = if on {+    theme::WASH_SELECTED+  } else if resp.hovered() {+    theme::WASH_HOVER+  } else {+    theme::WASH+  };+  ui.painter().rect(rect, theme::RADIUS, fill, theme::hairline(), egui::StrokeKind::Inside);+  ui.painter().galley(rect.center() - galley.size() / 2.0, galley, theme::INK);+  resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));+  resp.on_hover_cursor(egui::CursorIcon::PointingHand)+}++pub fn format_duration(secs: u32) -> String {+  format!("{}:{:02}", secs / 60, secs % 60)+}++fn one_line(text: &str) -> String {+  text.split_whitespace().collect::<Vec<_>>().join(" ")+}++fn pad_table(rows: &[[String; 6]]) -> String {+  let mut widths = [0usize; 6];+  for row in rows {+    for (i, cell) in row.iter().enumerate() {+      widths[i] = widths[i].max(cell.len());+    }+  }+  let mut out = String::from("# Shot List\n\n");+  for (r, row) in rows.iter().enumerate() {+    out.push('|');+    for (i, cell) in row.iter().enumerate() {+      out.push(' ');+      out.push_str(cell);+      out.push_str(&" ".repeat(widths[i] - cell.len()));+      out.push_str(" |");+    }+    out.push('\n');+    if r == 0 {+      out.push('|');+      for width in widths {+        out.push(' ');+        out.push_str(&"-".repeat(width));+        out.push_str(" |");+      }+      out.push('\n');+    }+  }+  out+}++#[cfg(test)]+mod tests {+  use super::*;+  use egui_kittest::Harness;+  use egui_kittest::kittest::Queryable;++  #[test]+  fn add_remove_and_reorder() {+    let mut doc = ShotListDoc::default();+    let a = doc.add("Hallway");+    let b = doc.add("Close-up");+    let c = doc.add("Exterior");+    assert_eq!(doc.shots.len(), 3);+    assert!(doc.move_by(&c, -1));+    let titles: Vec<_> = doc.shots.iter().map(|s| s.title.as_str()).collect();+    assert_eq!(titles, ["Hallway", "Exterior", "Close-up"]);+    assert!(!doc.move_by(&a, -1), "already first");+    assert!(doc.remove(&b));+    assert_eq!(doc.shots.len(), 2);+    assert!(!doc.remove("missing"));+  }++  #[test]+  fn filter_matches_title_notes_and_status() {+    let mut doc = ShotListDoc::default();+    doc.add("Hallway tracking");+    doc.shots[0].camera = "35mm".into();+    doc.shots[0].status = ShotStatus::Ready;+    doc.add("Close-up eyes");+    doc.shots[1].description = "wet pavement reflection".into();+    assert_eq!(doc.filtered("35mm", None).len(), 1);+    assert_eq!(doc.filtered("pavement", None).len(), 1);+    assert_eq!(doc.filtered("", Some(ShotStatus::Ready)).len(), 1);+    assert_eq!(doc.filtered("hallway", Some(ShotStatus::Idea)).len(), 0);+  }++  #[test]+  fn markdown_pads_columns() {+    let mut doc = ShotListDoc::default();+    doc.add("Hall");+    doc.shots[0].duration_secs = 8;+    doc.shots[0].camera = "wide".into();+    doc.shots[0].description = "she enters\nthe hall".into();+    let md = doc.to_markdown();+    assert!(md.starts_with("# Shot List\n\n"));+    assert!(md.contains("| Title"));+    assert!(md.contains("Hall"));+    assert!(md.contains("0:08"));+    assert!(md.contains("she enters the hall"));+    for line in md.lines().filter(|l| l.starts_with('|')) {+      assert_eq!(line.matches('|').count(), 7, "{line}");+    }+  }++  #[test]+  fn round_trips_through_json() {+    let dir = std::env::temp_dir().join(format!("artcraft-shot-list-{}", uuid::Uuid::new_v4()));+    fs::create_dir_all(&dir).unwrap();+    let path = dir.join("shot_list.json");+    let mut doc = ShotListDoc::default();+    doc.add("A");+    doc.shots[0].status = ShotStatus::Shooting;+    doc.save(&path).unwrap();+    let loaded = ShotListDoc::load(&path).unwrap();+    assert_eq!(loaded.shots[0].title, "A");+    assert_eq!(loaded.shots[0].status, ShotStatus::Shooting);+  }++  #[test]+  fn seeded_page_lists_titles() {+    let dir = std::env::temp_dir().join(format!("artcraft-shot-ui-seed-{}", uuid::Uuid::new_v4()));+    fs::create_dir_all(&dir).unwrap();+    let mut doc = ShotListDoc::default();+    doc.add("Hallway tracking");+    doc.add("Close-up, eyes");+    doc.save(&dir.join(STORE_NAME)).unwrap();+    let harness = build(ShotListPage::new(&dir));+    assert!(harness.query_by_label("Hallway tracking").is_some());+    assert!(harness.query_by_label("Close-up, eyes").is_some());+    assert!(harness.query_by_label("Title").is_some());+  }++  #[test]+  fn empty_page_offers_new_shot() {+    let dir = std::env::temp_dir().join(format!("artcraft-shot-ui-{}", uuid::Uuid::new_v4()));+    fs::create_dir_all(&dir).unwrap();+    let mut harness = build(ShotListPage::new(&dir));+    harness.get_by_label("New Shot").click();+    harness.run();+    assert_eq!(harness.state().page.doc.shots.len(), 1);+    assert_eq!(harness.state().page.doc.shots[0].title, "Shot 1");+    assert!(harness.query_by_label("Shot 1").is_some());+  }++  #[test]+  fn status_labels_and_clock() {+    assert_eq!(ShotStatus::ALL.map(ShotStatus::label), ["Idea", "Ready", "Shooting", "Done"]);+    assert_eq!(format_duration(125), "2:05");+    assert_eq!(format_duration(8), "0:08");+  }++  fn build(page: ShotListPage) -> Harness<'static, State> {+    let mut harness = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(+      |ui, s: &mut State| {+        if !s.fonts_ready {+          crate::theme::apply(ui.ctx());+          s.fonts_ready = true;+          return;+        }+        s.page.ui(ui);+      },+      State { page, fonts_ready: false },+    );+    harness.run();+    harness.run();+    harness+  }++  struct State {+    page: ShotListPage,+    fonts_ready: bool,+  }+}diff --git a/artcraft-native/src/shell.rs b/artcraft-native/src/shell.rs--- a/artcraft-native/src/shell.rs+++ b/artcraft-native/src/shell.rs@@ -35,6 +35,7 @@ pub enum Page {   VideoEditor,   RemoveBackground,   FrameExtractor,+  ShotList,   Library, } @@ -52,6 +53,7 @@ impl Page {       Page::VideoEditor => "Edit Video",       Page::RemoveBackground => "BG Remove",       Page::FrameExtractor => "Frame Extract",+      Page::ShotList => "Shot List",       Page::Library => "Library",     }   }@@ -67,6 +69,7 @@ impl Page {       Page::EditImage => Icon::Pencil,       Page::VideoEditor | Page::FrameExtractor => Icon::Film,       Page::RemoveBackground => Icon::Wand,+      Page::ShotList => Icon::List,       Page::Library => Icon::Library,     }   }@@ -89,13 +92,14 @@ impl Page {       "create-video" | "video" => Page::CreateVideo,       "create-audio" | "audio" => Page::CreateAudio,       "library" => Page::Library,+      "shot-list" => Page::ShotList,       _ => return None,     })   }    /// Pages the native app has; the rest open a "coming soon" page.   pub fn is_ready(self) -> bool {-    matches!(self, Page::Home | Page::CreateImage | Page::CreateVideo | Page::CreateAudio | Page::Library)+    matches!(self, Page::Home | Page::CreateImage | Page::CreateVideo | Page::CreateAudio | Page::Library | Page::ShotList)   }    /// The same tool in the web app.@@ -111,12 +115,13 @@ impl Page {       Page::FrameExtractor => "/frame-extractor",       Page::CreateVideo => "/create-video",       Page::Library => "/library",+      Page::ShotList => "/shot-list",       _ => "/create-image",     }   } } -const SECTIONS: [(&str, &[Page]); 3] = [("Create", &[Page::CreateImage, Page::CreateVideo, Page::CreateAudio, Page::Object3d, Page::World3d]), ("Studio", &[Page::EditImage, Page::Stage3d, Page::VideoEditor, Page::RemoveBackground, Page::FrameExtractor]), ("Assets", &[Page::Library])];+const SECTIONS: [(&str, &[Page]); 3] = [("Create", &[Page::CreateImage, Page::CreateVideo, Page::CreateAudio, Page::Object3d, Page::World3d]), ("Studio", &[Page::ShotList, Page::EditImage, Page::Stage3d, Page::VideoEditor, Page::RemoveBackground, Page::FrameExtractor]), ("Assets", &[Page::Library])];  /// Who's signed in, for the sidebar and top bar. pub struct Account<'a> {@@ -418,4 +423,11 @@ mod tests {     assert_eq!(group_thousands(1_234_567), "1,234,567");     assert_eq!(group_thousands(15), "15");   }++  #[test]+  fn shot_list_slug_is_ready() {+    assert_eq!(Page::from_slug("shot-list"), Some(Page::ShotList));+    assert!(Page::ShotList.is_ready());+    assert_eq!(Page::ShotList.label(), "Shot List");+  } }