diff --git a/negpy/desktop/view/widgets/tutorial_steps.py b/negpy/desktop/view/widgets/tutorial_steps.py
index 0a494129..a49ba726 100644
--- a/negpy/desktop/view/widgets/tutorial_steps.py
+++ b/negpy/desktop/view/widgets/tutorial_steps.py
@@ -76,6 +76,15 @@ def _finish(w: "MainWindow") -> Optional[QWidget]:
def _cast_removal(w: "MainWindow") -> Optional[QWidget]:
return w.controls_panel.colour_sidebar.cast_removal_slider
+ def _split_grade(w: "MainWindow") -> Optional[QWidget]:
+ return w.controls_panel.tone_sidebar.shadow_grade_slider
+
+ def _zone_density(w: "MainWindow") -> Optional[QWidget]:
+ return w.controls_panel.tone_sidebar.shadow_density_slider
+
+ def _gear_manage(w: "MainWindow") -> Optional[QWidget]:
+ return w.right_panel.metadata_sidebar.manage_btn
+
return [
TutorialStep(
title="Welcome to NegPy",
@@ -132,6 +141,10 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]:
"click outside to draw a fresh rectangle. Auto detects the film edge, "
"Fine Rot straightens tilted scans, and Detect Aspect Ratio snaps "
"to the nearest standard ratio.
"
+ "The Guide dropdown swaps the overlay grid — Thirds, Phi Grid, Diagonals, "
+ "Golden Spiral and more (O cycles guides, Shift+O flips "
+ "orientation) — and four rotation handles just outside the crop box spin "
+ "the frame freehand (±45°), composing with Fine Rot for fine-tuning.
"
"Crop matters for more than framing: the conversion meters what's inside "
"the crop to find the black and white points. Unexposed rebate sits at "
"film-base density (a false brightest highlight); sprocket holes and scanner "
@@ -153,7 +166,10 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]:
"percentile black/white points.
"
"Rule of thumb: the analysis area should contain image and nothing else. Use "
"the buffer when you deliberately keep a border in frame; a tight crop is the "
- "cleaner fix."
+ "cleaner fix.
"
+ "For odd frames, the draw region tool next to it goes further: draw the "
+ "metering area freehand on the canvas, and the meter reads exactly that — "
+ "no centered inset."
),
target=_analysis_buffer,
section_attr="process_section",
@@ -173,7 +189,9 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]:
"per dye layer via their Global / R / G / B selector, like a scanner's "
"per-channel levels.
"
"The stretch is unclamped: tones outside the bounds survive and roll "
- "off later in the print curve's toe and shoulder."
+ "off later in the print curve's toe and shoulder.
"
+ "Linear RAW also lives here: it decodes the RAW with neutral "
+ "multipliers, bypassing the camera's as-shot white balance."
),
target=_process,
section_attr="process_section",
@@ -235,17 +253,47 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]:
"Width: how far each knee's roll-off reaches.
"
"Snap: the paper's variable midtone gamma — endpoints and anchor stay put.
"
"True Black maps the paper's D-max to display black; pull Toe negative "
- "with it on to clip deep shadows to exact black."
+ "with it on to clip deep shadows to exact black.
"
+ "Snap and True Black sit with the zone controls (next) under the "
+ "Paper Response header."
),
target=_toe,
section_attr="tone_section",
),
+ TutorialStep(
+ title="Split Grade — Zone Contrast",
+ body=(
+ "Split-grade printing: Shadows Grade and Highlights Grade trim "
+ "each zone's contrast in ISO-R points on top of the main Grade — harder "
+ "shadows without blowing the highlights, or softer highlights without "
+ "flattening the shadows, like a second enlarger exposure through a different "
+ "filter.
"
+ "Both trims spare the midtones and stay bounded by the paper's black and "
+ "white, and they scope per colour layer through the Global / R / G / B "
+ "selector like the main Grade."
+ ),
+ target=_split_grade,
+ section_attr="tone_section",
+ ),
+ TutorialStep(
+ title="Zone Density — Shadows & Highlights",
+ body=(
+ "Where Split Grade is zone contrast, these are zone brightness: "
+ "Shadows Density and Highlights Density darken or brighten each "
+ "zone while rolling into the paper's black and white limits instead of "
+ "clipping — burning in a sky without blocking it up.
"
+ "They live under the Paper Response header with Snap and True Black — "
+ "the deeper print-curve controls."
+ ),
+ target=_zone_density,
+ section_attr="tone_section",
+ ),
TutorialStep(
title="Per-Layer Trims — Crossover Correction",
body=(
"The Global / Red / Green / Blue selector scopes the curve controls to a "
- "single dye layer: in a channel mode, Grade, Toe, Shoulder, Width and Snap become "
- "that layer's trims.
"
+ "single dye layer: in a channel mode, Grade, the Split Grades, Toe, Shoulder, "
+ "Width and Snap become that layer's trims.
"
"Colour filtration can only shift a layer's curve; trims change its "
"shape — fixing crossover casts that differ between shadows, mids and "
"highlights, the correction a real colour darkroom never had. The H&D chart "
@@ -257,11 +305,16 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]:
TutorialStep(
title="Exposure — Color Balance",
body=(
- "Three CMY sliders operate in three regions — Global, Shadows, and Highlights — "
- "giving you precise split-toning control over colour balance.
"
- "Pick WB: click a neutral area in the preview to auto-calculate white balance shifts.
"
- "Linear RAW: bypasses the camera's as-shot white balance and starts from neutral "
- "multipliers. Leave it off for a sensible default starting point."
+ "White balance is real CC filtration — ±1.0 on a slider is ±20cc of dichroic "
+ "density. The Global / Shadows / Highlights buttons on top scope the "
+ "CMY sliders to a region for precise split-toning control.
"
+ "The Temperature slider re-dials the filter pack along the warm–cool "
+ "axis: Magenta and Yellow move together in the right ratio while your "
+ "green–magenta tint stays put. Travel is mired-linear (equal drag, equal "
+ "perceived shift), T/G nudge it, and the thermometer button "
+ "locks the temperature for the whole roll.
"
+ "Pick WB: click a neutral area in the preview and the filtration is "
+ "calculated for you."
),
target=_region_btn,
section_attr="colour_section",
@@ -275,8 +328,9 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]:
"Cast Removal measures each channel's deep-shadow reference and gives "
"it its own slope, pivoting on the midtone — greys read neutral from deep "
"shadows through highlights, not just at one point.
"
- "The auto toggle meters it per frame; the slider sets how much of the "
- "measured correction is applied."
+ "Its strength adapts per frame to how confidently the neutral greys read — "
+ "clean greys get the full correction, few-neutral scenes gentler — and the "
+ "slider (default 0.5) trims on top; 0 turns it off."
),
target=_cast_removal,
section_attr="colour_section",
@@ -338,7 +392,11 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]:
"silver density (B&W mode only): selenium converts the densest silver first — "
"deeper blacks and cool eggplant shadows; sepia bleach-redevelops the thinnest "
"silver first — warm highlights that hold the shadows (partial strength gives the "
- "classic split-sepia look)."
+ "classic split-sepia look).
"
+ "Gold is the archival gold bath: a cool blue-black shift in the "
+ "highlights and mids with a slight density boost, while dense shadows hold. "
+ "Run it over Sepia for the classic combination — toned highlights pushed from "
+ "yellow-brown toward orange-red."
),
target=_toning,
section_attr="toning_section",
@@ -348,8 +406,12 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]:
body=(
"Auto Dust detects and removes small particles based on a density threshold. "
"Lower the threshold to be more aggressive.
"
- "Heal Tool: click to enable, then click individual dust spots in the preview "
- "for manual removal. Use Undo Last or Clear All to manage spots."
+ "Heal Tool: click individual dust spots in the preview — each heal "
+ "clones a matching patch from elsewhere in the frame and blends the seam, so "
+ "grain stays intact.
"
+ "Scratch Tool: click a polyline along a hair or scratch, double-click "
+ "or Enter to commit. Undo Last / Clear All manage the "
+ "spots."
),
target=_retouch,
section_attr="retouch_section",
@@ -377,6 +439,20 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]:
target=_history,
pre_hook=lambda w: w.right_panel.show_tab_by_key("history"),
),
+ TutorialStep(
+ title="Metadata & Gear Library",
+ body=(
+ "The Metadata tab writes film and scan info — stock, format, "
+ "developer, push/pull, scanner — into the EXIF/XMP of exported files.
"
+ "Manage… opens the Gear Library: a searchable, user-extendable "
+ "library of cameras, lenses and film stocks; gear picked for a frame rides "
+ "into the exported XMP.
"
+ "Protect original metadata keeps the source file's EXIF/XMP untouched "
+ "instead of NegPy rewriting it."
+ ),
+ target=_gear_manage,
+ pre_hook=lambda w: w.right_panel.show_tab_by_key("metadata"),
+ ),
TutorialStep(
title="Export",
body=(
@@ -412,6 +488,9 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]:
body=(
"That's the core workflow. A few more things worth knowing:
"
"• Press ? or use the ⋯ menu for keyboard shortcuts.
"
+ "• Scanning with a tethered camera? The Camera Scanning section on the "
+ "Scan tab drives the body and Scanlight directly (macOS/Linux) — see "
+ "docs/CAMERA_SCANNING.md.
"
"• See docs/USER_GUIDE.md for the full reference.
"
"• Having GPU or rendering issues? Edit "
"Documents/NegPy/override.toml to switch backends "