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.rs
new 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");
+  }
 }
