diff --git a/CHANGELOG.md b/CHANGELOG.md
index b719810..01239fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,6 +45,54 @@ OME-XML description is parsed (which images are marked is unchanged; a malformed
debug line without a traceback), and while the capture is attached the package's DEBUG records
go to the "Technical details" panel only, with WARNING and above still reaching the root handlers.
+### Notebook redesign 5: seven steps, what the files hold first
+
+Owner's test of the first redesign (10 November 2026), items (o), (p) and (q). The shipped
+notebook has 7 steps instead of 12, in the owner's order: **Images → Axes → Channels → Masks →
+Design → Check and save → Run**. For the same answers the published `morphobricks.toml`,
+`experiment_design.csv`, `plane_histograms.csv`, `calibration.csv`, the mask files and the
+manifest (but for its time and paths) are byte-identical to before; the Spec behaviour is
+unchanged.
+
+- **Axes holds the image type** (what the channel dimension holds: separate stains or markers,
+ or colour), in the pattern of the pixel size (`notebook_image_type_line.ImageTypeLine`): one
+ line for all folders, pre-filled from what the image headers say and **recorded only by the
+ click Use for all folders**; **Set per folder…** holds each folder's row as before; when the
+ headers differ nothing is pre-filled, the line says how many folders say what, and the
+ per-folder part opens. Folders answered differently are said at once with the refusal that
+ follows (`R_HETEROGENEOUS_LAYOUT_NOT_ENABLED`); colour pictures beside separate-channel
+ images are still refused at the axes. Replacing answers already given asks first. The step is
+ done once the roles are confirmed and every folder's image type is answered.
+- **Channels** is *Stains* and *Tag channels* on one page: name the stains, then tag.
+- **Masks** holds the masks the check paired (with **Check the masks**, moved from step 1),
+ what each mask type is, and **Describe the masks**. The mask step reads the masks a run
+ recorded, so it stays greyed out, with its reason, until a run exists; after such a run the
+ run step says to come back for it. `A_MASK_UNCHECKED` is unchanged.
+- **Design** is *Conditions*, *Replicates* and *Compared against* in one grid
+ (`notebook_design_step`): conditions down, biological repeats across, each cell the folder
+ of that repeat, and what each condition is compared against in the last column. The names
+ are the folder names, folders of the same name are one repeat, and "no comparison" is shown
+ for every condition: all proposals. **Looks right: confirm the design** adopts every ID as
+ shown (all or none, each refusal listed, as *Yes, use these IDs*) and records "no
+ comparison" where nothing else was chosen. A name that cannot be proposed blocks the click
+ with its reason and opens **Edit names or pairing…**, which holds the earlier rows.
+ *Check and save* waits for the click and says so. The grid is updated in place.
+- **Check and save** lists at most eight missing answers at once; the others are one click
+ away (`notebook_check_view.TODO_SHOWN`). Every one is still listed.
+- Smaller: the tiles of counts are one line of text in the stepper; mask pictures are
+ smaller; once the axes are confirmed the tick box and the "roles are valid" line make way
+ for the confirmation. Messages name the new steps (*Channels*, *Design*, *Axes*).
+- The notebook's Markdown cell and the user guide §4 describe the seven steps (the stale
+ card-by-card text of §4 is removed). `RELEASE_REF` and the code cells are unchanged.
+- **For bricks that embed the pages:** `welcome`, `folder_step`, `axis_step`, `channels_step`,
+ `masks_step`, `check_save_step`, `run_step`, `results_step` and the public methods are
+ unchanged (`tests/test_embedding_api.py` is untouched). Texts that changed on the stacked
+ pages too: the masks' "Before you run" items end in "(Masks)" instead of "(step 1)" /
+ "(step 1, Masks)"; the mask step's hint reads "Available after a run that recorded
+ masks: …"; its help paragraph starts with what it is needed for; a mask question without
+ labels says "declare them on Channels"; the missing-answer lines end in "(Channels)",
+ "(Design)" and "(Axes)".
+
### Notebook redesign 4: one column, less on screen; Compare groups in the run step
Owner test of v0.5.0 (10 November 2026), items (a) and (b), and the owner's test of the first
diff --git a/docs/user_guide.md b/docs/user_guide.md
index 5cebc62..b2ee688 100644
--- a/docs/user_guide.md
+++ b/docs/user_guide.md
@@ -265,384 +265,69 @@ notebook's step 3 (§4), also tells how they relate.
## 4. The notebook, step by step
-### The app in short (v0.5.1)
-
-Cell 3 opens the app: a strip of twelve steps, one on screen at a time, with **Back** and
-**Next** under it (**Next** is never blocked; a chip opens its step). The page is one column
-and scrolls as a whole: nothing scrolls inside its own box and nothing sits side by side.
-Explanations are folded under **About this step**, and details are in collapsed panels.
-Every wait shows a bar with what is done of the total, the file being read, the time elapsed
-and, once it can be measured, the time left. Every greyed-out button has a line beside it
-saying what it waits for.
-
-1. **Images.** Give the folders (images; masks and output are optional). **Scan the folders**
- lists the conditions and folders with their image counts without reading any image header,
- and shows one image chosen at random, with its path, for looking only: it is not used for
- anything. Every channel of it is shown side by side (`c=0`, `c=1`, …, each with its own
- contrast; at most six, and then it says *6 of N channels shown*).
- If this is your experiment, click **Looks right: read all image headers**; the
- headers are then read and checked. Changing a folder asks for a new scan. The pictures of
- each folder, the check's report and **Exclude images** are collapsed below.
-2. **Axes.** One row per image dimension with its role, and under them an **example of one
- image as these roles read it**: one tile per channel (`c=0`, `c=1`, …), a few time points
- or slices, or the single plane, each with its height and width. It is drawn by itself and
- redrawn whenever you change a role, so a wrong role looks wrong (a strip, stripes). Another
- image or other planes are under **Another image, or other planes**. Nothing is confirmed
- for you: click **Confirm these axes** (offered when the headers name every axis and
- nothing is open), or **Yes, the example looks right** and then **Confirm axis roles**.
- - **Pixel size** is one line here: for example *Pixel size: 0.108 × 0.108 µm (from the
- headers, same in all 270 images)*. When the headers give none, type it once and click
- **Use for all images** (or choose *pixels: deliberately uncalibrated*); leaving it empty
- records the images as uncalibrated, nothing is guessed. When the headers do not agree,
- the line says how many images have which size and in which folders, and **Set per
- imaging unit…** opens: there you state a size for one folder (imaging unit) or image.
- **Use for all images** is your statement for every image: it replaces sizes stated per
- folder, and a size that differs from an image's header is still listed as a warning.
-3. **Conditions**, 4. **Replicates**, 5. **Compared against**, 6. **Image type**, 7. **Stains**,
- 8. **Tag channels**, 9. **Masks**: one kind of answer per step, saved as you give it.
-10. **Check and save.** Your experiment in sentences, one question per replicate, then **Yes,
- save the experiment description**. The line beside the button says what is still missing.
-11. **Run.** **Compute histograms**; what is still to do is listed only while something is.
- The settings and *What will happen* are collapsed. After the run: its result, and collapsed
- panels for the **intensity checks and plots**, **Compare groups** and the report.
- **Compare groups** replaces the former optional statistics page: choose how to group
- (condition, biological repeat, technical repeat or image) and click **Calculate
- statistics** for the pooled statistics and the distribution plot of the table this run
- wrote. It writes nothing.
-12. **Describe the masks** (optional), after a run that recorded masks.
+Run the notebook's three cells from top to bottom (install, mount Google Drive, run
+preprocessing). Cell 3 opens the app: a strip of seven steps, one on screen at a time, with
+**Back** and **Next** under it. **Next** is never blocked, and a chip of the strip opens its
+step; a green chip with a check mark is done.
+
+The page is one column and scrolls as a whole. Each step shows what you must answer;
+explanations are under **About this step** and details are in collapsed panels. Every wait
+shows a bar with what is done of the total, the file being read and the time. Every greyed-out
+button has a line beside it saying what it waits for. Nothing is decided for you: what the app
+proposes is recorded only when you click.
+
+1. **Images.** Give the folders (images; masks and output are optional) and click **Scan the
+ folders**. The scan lists the conditions and folders without reading any image header and
+ shows one image chosen at random, for looking only: every channel side by side (`c=0`,
+ `c=1`, …, each with its own contrast; at most six, and then it says *6 of N channels
+ shown*). If this is your experiment, click **Looks right: read all image headers**. The
+ pictures of each folder, the check's report and **Exclude images** are collapsed below.
+2. **Axes.** What the files hold, in three parts.
+ - **Axis roles:** one row per image dimension with its role, and under them an example of
+ one image as these roles read it, redrawn whenever you change a role (a wrong role looks
+ wrong: a strip, stripes). Click **Confirm these axes** (offered when the headers name
+ every axis), or **Yes, the example looks right** and then **Confirm axis roles**.
+ - **Pixel size:** one line, for example *Pixel size: 0.108 × 0.108 µm (from the headers,
+ same in all 270 images)*. When the headers give none, type it once and click **Use for
+ all images**, or leave the images uncalibrated; nothing is guessed. When the headers
+ disagree, the line says how many images have which size, and **Set per imaging unit…**
+ opens.
+ - **Image type:** what the channel dimension holds, *separate stains or markers* or
+ *colour (RGB)*. It is proposed from the image headers and recorded when you click **Use
+ for all folders**. **Set per folder…** holds one row per folder; it opens when the
+ headers differ. An experiment that mixes colour and separate-channel folders is refused
+ in this version.
+3. **Channels.** Name what you stained and confirm the list; then tag each folder's channels
+ with those names, looking at their pictures.
+4. **Masks.** The masks the check paired with the images (and, for masks whose files do not
+ name their axes, the axes to state), then for each mask type: *separate objects* or
+ *regions*. **Describe the masks** reads every mask and reports how the types relate. It
+ reads the masks a run recorded, so it is greyed out before the run and says so; come back
+ to it after **Run**. The histogram table does not need it, but bricks that use masks accept
+ only masks it has read. Without masks, the step says so.
+5. **Design.** One grid: conditions down, biological repeats across, each cell the folder of
+ that repeat, and what each condition is compared against in the last column. The names are
+ the folder names, folders with the same name are the same repeat, and nothing is compared.
+ If that is right, click **Looks right: confirm the design**; otherwise change a comparison
+ in the grid, or a name or the pairing under **Edit names or pairing…**, first.
+6. **Check and save.** Your experiment in sentences and one question per replicate (*Were
+ these prepared and imaged the same way?*), then **Yes, save the experiment description**.
+ The line beside the button says what is still missing.
+7. **Run.** **Compute histograms**; what is still to do is listed only while something is.
+ After the run: its result, and collapsed panels for the **intensity checks and plots**,
+ **Compare groups** (pooled statistics and a distribution plot per condition, repeat or
+ image, for the table this run wrote; it writes nothing) and the report.
Images with different numbers of channels (for example three in most folders and four in
-one) are refused in this version (`different_channel_counts`): the axes step names the error in
-one sentence, with the files and what to do one click away, and **Confirm axis roles** says
-that this is what it waits for.
-
-The rest of this section describes each card in detail. It is written for the same cards
-shown as stacked pages (as other bricks embed them); where the app differs, the list above
-is what you see. In particular: **Show preview** and **Yes, the preview looks right** are the
-example and **Yes, the example looks right**; **Check folder** is **Looks right: read all
-image headers**; and step 4 below is the **Compare groups** panel.
-
-### The cards in detail
-
-The notebook's code cells are hidden: run them from top to bottom (click a cell and press
-**Shift + Enter**). Each step is a page of boxes. A blue box at the top says what the step
-is for, every control has a grey help line, and one coloured message says what happened
-and what to do next. Green means done, amber means "please check", and red means
-something must be fixed first. Where you need to confirm something, a yellow box asks the
-question and you answer with a large green **Yes, …** button. The box turns green, and an
-**Undo** button takes the answer back. Later steps stay locked until the earlier ones are
-confirmed. If you change the experiment folder, everything after it is reset.
-
-After a successful action, a separate **What next?** box gives the next action in larger
-text. In step 1 it first asks you to check the labels, then points to step 2 once they are
-confirmed. Step 3 offers the optional statistics step, and step 4 suggests reviewing the
-results or comparing another grouping.
-
-0. **Start the notebook.** The setup cell checks the environment and installs missing
- requirements if needed. A green box then confirms it is ready and shows the package
- version.
-1. **Check the experiment folder.**
- - Paste the folder path, or click **Browse…** to choose it in a dialog. Below the output
- folder, a line shows where the results will go. An output folder that is a file, or
- inside one (for example `somefile/out`), cannot be created: the line says so in red,
- nothing is written there, and step 3 waits until you choose another folder.
- - If you have masks (§2), give their folder under **Masks folder (optional)** the same way
- (on Colab, paste its path: **Browse…** explains how). Leave it empty for no masks.
- **Check folder** pairs them with the images exactly as `check --masks` does.
- - Click **Check folder**. Only folder names and image headers are read. Like the command
- line's `check`, it writes its report, **discovery_report.txt** and
- **discovery_report.json** (§6), to the output folder; **Show the report** under the
- status box shows it. (When another brick embeds this step without the output folder,
- the check writes nothing.)
- - If another run holds the output folder's lock, **The output folder is locked** names it
- (computer, process ID, start time). Wait for it to finish. If it was stopped (on Colab,
- the runtime disconnected) and no other session uses the folder, press **Remove the lock
- of a stopped run**: the same rule as `--remove-stale-lock`, so the lock of a process
- still running on this computer is kept. On Google Drive the lock cannot guard a folder
- across computers or sessions: use one session per output folder (§1).
- - Tiles summarise the result: images to analyse, image folders, conditions, pixel type,
- skipped files, and errors and warnings.
- - The **Labels from the folder names** table shows every image folder with its condition,
- biological repeat and technical repeat (*single* where a level is missing). If they
- are right, press **Yes, the labels are correct** in the yellow box under the table.
- In the shipped notebook (*Images found*), each folder also gets a picture of its first
- image, so a wrong folder shows at once. It is drawn from the file header only, before the
- axes are confirmed, and is for looking only: only when the header names Y and X (otherwise
- "preview after Axes (step 2)"), every other axis max-projected and named in the caption,
- colour when the header says RGB. At most 8 planes and 4096 x 4096 pixels are read for one
- picture; a larger image says so instead. The first 12 folders are drawn by the check;
- **Draw the pictures of the other N folders** draws the rest. A large image also shows a
- square of 128 pixels at full resolution (where the picture varies most, with its x and y
- range), here and on *Replicates* and *Check and save*; **Enlarge** shows both larger.
- - Once the labels are confirmed, **Experiment design** asks about the biological
- repeats. Later bricks normalise each biological repeat over all conditions together,
- so they need to know which folders are the same repeat:
- - **Pair the biological repeats across conditions.** Folders with the same name in
- different conditions are one *repeat group*. If a repeat has another folder name in
- some condition (acquired a day later, for example), choose its group in the
- **Repeat group** dropdown. A group can hold only one folder of each condition.
- - **Confirm that they are comparable.** For each repeat group, a yellow box asks
- whether its images were prepared and acquired under the same circumstances. Press
- **Yes, they are comparable** if they were. Without biological-repeat folders, one
- question covers the whole experiment. Step 3 runs only once every group is
- confirmed; if images are not comparable, process them as separate experiments.
- - **Controls (optional).** Tick the control conditions. This is recorded for later
- bricks; nothing uses it yet.
- - The answers are saved in the draft (`morphobricks.draft.json`) after every answer and
- published with the description on *Check and save*; `experiment_design.csv` is
- derived from the published description: a repeat group is confirmed only when its
- folders pass the run's checks, and a refused answer is shown with the reason and
- changes nothing. Nothing
- is confirmed for you. Next time, if the folders are the same, they are loaded and the
- questions are folded away; **Change the experiment design** shows them again. A new
- biological-repeat folder asks again only for the group it joins.
- - An **experiment_design.csv** that an earlier version left in the output folder is not
- read and does not prefill these questions: describe the experiment here, in the
- notebook. The file is only an output, derived from the published description.
- - The design belongs to its output folder. If you change the output folder, the design
- is read from the new folder (its own answers and controls, a blue box says so), or,
- when it holds none, the questions are asked again: answers given for the previous
- output folder are never carried over, and nothing is written in the new folder until
- you answer. Changing back reads the first folder's design again.
- - **Pixel sizes (calibration)** lists the size of one pixel along X, Y, Z and T for every
- image (images with the same sizes share a row), with its source: **header** (the image
- metadata), **stated** (in **calibration.csv**), **pixels** (stated as deliberately
- uncalibrated) or **uncalibrated** (nothing is guessed). To state a size, fill in **For**
- (`.` for the whole experiment, or a folder or image relative to the experiment folder),
- **Axes**, **Size** and **Unit** (or *pixels*) and press **State this size**; **Remove the
- statement** goes back to the metadata. This writes **calibration.csv** in the output
- folder with the `calibrate` command's functions, under the lock (§5), and the most
- specific statement applies. The messages `calibration_target_not_found` (an error),
- `calibration_differs_from_header` and `calibration_axis_absent` (warnings, §8) are
- shown on the card. Until the axis roles are confirmed in step 2, Z and T show the
- metadata's sizes; once they are, an image without a Z or T axis shows **no such axis**,
- as the run records no size for it. No histogram depends on the sizes. (When another
- brick embeds this step without the output folder, the card is left out.)
- - **Dataset consistency** shows how many images each condition (columns) and
- biological repeat (rows; the repeat groups once paired) has, with the number of technical-repeat folders. Excluded images
- are not counted, nor is a technical-repeat folder whose images are all excluded; the
- line under the table says how many images were excluded and names those folders. A
- technical-repeat folder named **single** is the level's value single, so it counts as
- no technical-repeat folder, like images directly in the biological-repeat folder. A cell is
- amber when it has fewer than half or more than twice the median number of images, or
- when a condition does not have that biological repeat. Expand **Show consistency
- warnings** to see the checks that compare the images with each other: pixel type,
- dimensions, width and height, numbers of channels, slices and time points, channel
- names and physical pixel sizes (see §8). Warnings never stop you; they say what to
- check. Images with different numbers of channels, pixel types or dimension orders are
- errors: fix them before going on.
- - **Masks** (with a masks folder, or OME-Zarr images with label images) lists every kind
- of mask found, where from (the masks folder or label images), its axes, how many masks
- it has and the images without one (named below the table), and the masks' messages
- (§8): the same checks as `check --masks` and, once the axis roles are confirmed in
- step 2, as `run`. A mask error blocks step 3; the step-1 status and tiles count the
- masks' errors and warnings too.
- - **Mask axes** appears only for kinds of mask that need their axes stated, as the
- command line's `--mask-axes TYPE=ROLES`: masks whose files do not name their axes (a
- plain stack, `mask_not_2d`), or whose statement does not fit. A 2D mask needs no
- statement and is never shared by several planes, so a 2D mask of a time-lapse is not
- asked about: it is refused (`mask_planes_mismatch`) until it is saved with the
- image's time points. The dropdown lists only the statements that fit every
- mask of that kind (one role per axis as stored, never X before Y), starts at **Not
- stated**, and nothing is applied until you press **Check the masks**. If no statement
- fits (masks of one kind stored differently), it says so: save them alike.
- - **Check the masks** pairs the masks again with the masks folder and the mask axes
- chosen, keeping your other answers (labels, design, axis roles). Step 3 asks you to
- press it after changing the masks folder or a mask axis; masks that were not checked
- are never used. A new masks folder starts its mask axes over.
- - The run records the masks folder and the mask axes in **run_manifest.json**; a later
- run in the same output folder reuses them (§7). With the box empty, the masks folder of
- the earlier run is used again, and the card says so; **Drop the recorded masks** (then
- **Check the masks**) runs without masks, as `--no-masks`, and drops the mask axes
- stated for that folder's masks. A masks folder other than the recorded one, or dropped,
- is named under **What will happen** in step 3 and in the run's report.
- - When another brick embeds this step without the output folder, neither the masks
- folder nor the card is shown, and the check pairs no masks folder.
- - **Messages** lists the other problems (folders, files that are not images, unreadable
- images), with the warning count visible in orange and the error count in red.
- Expand **Show warnings and errors** to see each problem, the affected files and what
- to do. The details start collapsed after each check. If some images cannot be read,
- **Exclude these images and check again** inside the dropdown leaves them out (see §7).
-2. **Review the axis roles.**
- - Each dimension of the images has its own box. It shows the dimension's length and the
- role suggested from the metadata, with a dropdown to change it.
- - A line under each dropdown says what the choice means, for example *3 depth slices,
- numbered z = 0 to 2*. A blue line says how many planes each image will be split into.
- - Click **Show preview** and move the sliders to look at different slices, channels and
- time points of any image. Until you confirm the roles, the preview is labelled
- **Preview of the roles above, not confirmed yet**: it is drawn with the roles as you
- edit them, nothing is written, and the run uses only the roles you confirm. If the roles you choose give the images different numbers of
- channels, a message says so and step 3 stays locked. Each picture should be a normal image of your sample;
- stripes or a smear mean X or Y is on the wrong dimension. Brightness is stretched for
- display only.
- - The roles are checked against the axes each image's metadata names, in one **Axis
- check** card. A role that disagrees with the header is marked in amber under its
- dropdown; the card gives the error (`axes_yx_swapped` or `axes_contradict_header`,
- §8) naming the images with their header axes, your roles and the likely cause. Such
- images are never read: correct the axes, or save named-axis copies with your roles and
- run preprocessing on the copies (the **Make named-axis copies (optional)** card under
- the axis check, §5 "Named-axis copies"). An axis the metadata does not name (a plain stack of
- pages) takes the role you confirm: the card warns (`axes_not_named`) and the run
- records the roles as confirmed by you.
- - If the preview looks right, press **Yes, the preview looks right** (offered only while a
- preview of the roles shown is drawn), then click
- **Confirm axis roles**. When the roles agree with every header, a green card says
- **Axes checked: they are correct for every image**. Changing a role afterwards asks
- you to check the preview again.
- The confirmed roles are locked until you click **Change axis roles**.
- - In the shipped notebook (one step at a time), when the suggested roles leave nothing
- to ask (no axis message, every image names its axes, no missing axis to state), the
- *Axes* step shows them with a preview and one **Confirm these axes** button; it records
- the same two answers. A reopened experiment whose folders did not change offers the
- roles of its earlier run. The role boxes stay under **Look closer, or change the roles**.
- - On a reopened experiment, new folders whose header is exactly that of a saved, tagged
- group form their own block on *Tag channels* with **Tag like the saved group (…)**. One
- click copies those tags, exactly as choosing them by hand; nothing is copied without it.
- - If some images lack a dimension the others have (often a single channel), a yellow box
- lists them; press **Yes, they have a single plane there** if that is the case.
- Once you confirm the axis roles, this resolves the missing-axis error for step 3.
- Any other errors still need to be fixed before running.
- - Once the axis roles are confirmed, **Image type** asks whether the images are
- **Fluorescence** (each channel is a separate stain or marker) or **RGB** (colour
- images, such as brightfield or histology stains). Later bricks normalise the two types
- differently: fluorescence channel by channel, RGB with the red, green and blue channels
- together (StarDist's method), so the colours are kept. The histograms are the same for
- both types.
- - RGB is suggested when the images are stored as RGB colour (three colour samples per
- pixel) and those samples are the channels; fluorescence otherwise. The suggestion is
- only preselected: press **Yes, this is the right image type** to confirm it, or choose
- the other type first. An amber line reminds you when you choose against the
- suggestion. Step 3 runs only once the type is confirmed.
- - RGB needs exactly three channels of a whole-number pixel type (8- or 16-bit colour).
- For other images the choice is greyed out at Fluorescence, with the reason.
- - Changing the axis roles asks for the image type again. The type is recorded in
- **run_manifest.json** (§6); changing it on a later run reads no image again.
-3. **Compute the histograms.**
- - **Before you run** lists anything still to do.
- - **The settings start from the earlier run** in the output folder, as the command
- line's `run` keeps them: the excluded images (filled in step 1 when you choose the
- folders), the bins (when recorded for the same pixel type), the histogram range and
- its quantiles, the intensity limits, the image type (preselected in step 2) and the
- answer about missing axes; the masks folder and mask axes are kept by leaving the
- masks folder empty. Leave a setting as it is to keep it. A setting you change is
- listed under **What will happen** and in the run's report. Choosing another output
- folder fills them again from that folder's run; a folder without a run sets back the
- values filled from the other folder that you did not edit.
- - **What will happen** names the output folder and files, the image type and the masks
- recorded (or that the recorded masks are dropped), and says
- whether an earlier run was found and what will be read. It lists the inputs that differ
- from those the earlier run recorded (for example an image excluded since, or another
- bin count), each with its effect (*every row is recomputed*, *no row is affected*,
- …), from the run's own rules, and the run's report names them again. Once the axis roles are confirmed it also names
- the images whose planes are too large for the memory limit and will be read in
- blocks, and, in a red box, any image that cannot be read within the limit at all
- (the run would stop; raise the limit or exclude the image).
- - The recommended settings suit most experiments. Untick **Use the recommended
- settings** to change them:
- - *Bins per plane:* one bin per possible value for 8- and 16-bit images (exact
- histograms); 65,536 bins for wider integers and 10,000 for floats.
- - *Histogram range:* **Minimum to maximum of each plane** (recommended) or
- **Robust**, which bins between two quantiles of each plane (0.01 % and 99.99 % by
- default) and counts the pixels outside separately. It matters only for histograms
- that are not exact (float images, and integer images with more values than bins).
- A few extreme pixels, such as hot pixels, can squeeze every other pixel into a few
- bins of the minimum-to-maximum range; the run then warns
- (`narrow_histogram_range`), names the planes, and says how many bins the robust
- range would give. Run again with the robust range to fix it. Changing the range
- reads every image again.
- - *Memory limit:* the most working memory one plane may need (default 1 GiB). A
- plane that needs more (roughly above 5,000 x 5,000 pixels) is read in blocks
- instead, and the run reports this as a warning: the histograms are exactly the
- same, only reading takes longer. Raise the limit only if the computer has enough
- memory.
- - **Mode:** **Restart** (recommended) reuses the rows of unchanged images and reads new
- or changed ones; **Full** reads every image again and recomputes every row and check.
- Both keep your confirmations (labels, design, axis roles, image type, stated pixel
- sizes) and write the table only when the whole run succeeds.
- - **Verify each image's content (SHA-256) before reusing its rows** (off by default): an
- image counts as unchanged when its size and modification time are, so a file rewritten
- with the same size and its time set back would keep its earlier rows. With the box
- ticked, the run records each image's SHA-256 and reuses rows only for the same content;
- every image is read once more. The choice is recorded and kept by later runs; turning
- it on or off reads every image again.
- - Click **Compute histograms**. A progress bar shows which image is being read. Tiles
- then summarise planes written, images read, kept, new, changed, removed and excluded,
- and the status names the mode, the files replaced and the inputs that changed. The
- table and manifest are written only when the whole run succeeds; the report
- (**discovery_report.txt**, **Show the report**) is written either way. A run that stops
- says *The run stopped: no table or manifest was written.*, and that its report was
- written; a run refused before it starts (the lock) says *Nothing was written.* and
- shows **The output folder is locked**. **Technical details (log)** holds the log for
- troubleshooting.
- - **Intensity checks** then compares the images, using only the table just written (no
- image is read again, and nothing else is written). Each image is compared with the
- other images of its condition and biological repeat, and each biological repeat with
- the other repeats of its condition, so differences between conditions (treatment
- effects) are never reported:
- - Tiles count the images checked and each kind of warning; a table lists every
- warning with the check, the channel (**C0**, **C1**, …), the image or biological
- repeat, and what was found (the values compared and the limit), followed by what to
- do about each kind. The four checks find images far from the others of their
- biological repeat, biological repeats shifted from the others of their condition,
- saturated images, and images without intensity spread; a fifth kind names each
- image channel without finite pixels, which the checks cannot see (see §8).
- - **Check settings** (collapsed) holds the limits: how many robust deviations count as
- far (default 3), the share of saturated pixels (default 1 %), and the fewest images a
- comparison needs (default 5). Changing them updates the warnings and plots at once;
- the run records the limits in **run_manifest.json**, so the next run records the
- new ones. **Restore the defaults** sets them back.
- - Plots, one channel at a time (choose it above the plots): box plots of the image
- medians and high percentiles for each condition and biological repeat, one point per
- image, with images that have a warning in that channel as red diamonds and shifted
- repeats as amber boxes; and one panel per condition with the intensity distribution
- of each biological repeat (comparable repeats lie close together; a shifted one is
- dashed). The plots are only shown here, never saved.
- - **Describe the masks (optional)**, after a run that recorded masks: the notebook's
- counterpart of the `masks` command (§5), with the same functions and the inputs the
- run recorded (experiment folder, masks folder, mask axes, excluded images). The button
- works for the run in step 1's output folder: after a run in this session, and also for
- an earlier run when you reopen the notebook (choose the same experiment or output
- folder in step 1). The line above it names the run it describes (output folder, when
- it was written, experiment and masks folders, mask types); it is greyed out, saying
- why, when that folder holds no run that recorded masks. Click it to read every mask, one image at a
- time (a progress bar names the image); the card then shows each mask type (kind,
- images, objects, median pieces per label), the relationships found (which type is
- inside or grouped by which, how many objects are inside, how many objects of the
- other type hold 0, 1, 2 or 3 or more, and how many are outside or split), the
- objects given no parent because they lie equally in two or more objects of the other
- type, masks of the same objects, and the hierarchy of direct parents. These are suggestions to
- check, not decisions. The description is written to **mask_relationships.json** in
- the output folder (its path is shown). The output folder's lock is held from before the
- first mask is read until the file is written: when another run holds it, **The output folder is locked** is shown with **Remove the lock of a
- stopped run**, no mask is read and nothing is written. Errors found since the run
- (the experiment or masks folder moved, a mask changed or removed) and masks that
- cannot be used (a label above 2,147,483,647) are shown as red cards, and nothing is
- written. The notebook uses the default thresholds
- (contract §15.1); the `masks` command can change them. A new run clears the card;
- an embedding brick that hides the output folder does not show it.
-4. **Calculate the statistics (optional).** The table from step 3 is all that later bricks
- need; this step only reads it to compare groups and writes no files.
- - **Histogram table** is filled in with the table from step 3. To look at another
- experiment, paste the path of its `plane_histograms.csv` or click **Browse…**.
- - Choose **Condition**, **Biological repeat**, **Technical repeat** or **Image**, and the
- percentiles to report, then click **Calculate statistics**.
- - A spinning wheel shows that work is in progress while the table is read, statistics
- are calculated and the plot is drawn. The button says **Calculating…** and stays
- disabled until the calculation finishes or an error is shown; you can then try again.
- - The table lists pooled statistics per group **and channel**: minimum, maximum, mean,
- standard deviation, median and percentiles. Each channel (**C0**, **C1**, …) has its
- own row, since different fluorescence markers (or the red, green and blue of an RGB
- image) are different measurements and are never mixed. **XYC planes** counts the
- planes (an image at one time point) and **channel histograms** the histograms pooled
- in that row.
- - The plot compares the groups' intensity distributions, one panel per channel: curves
- further right are brighter.
- - **Intensity checks** shows the same warnings, settings and plots as step 3 for the
- chosen table, so tables of other experiments can be checked too. It needs only the
- table, not the images.
-
-In JupyterLab, the optional `jl-hidecode` extension keeps the code cells collapsed and adds
-a run button to each; without it, click the "…" of a cell to see its code.
+one) are refused in this version (`different_channel_counts`): the axes step names the error
+in one sentence, with the files and what to do one click away.
+
+When you open the notebook again on an experiment that is already saved and whose folders
+did not change, it opens on the first step still to do (usually **Run**).
+
+Other MorphoBricks notebooks embed the first cards of this app as stacked pages
+(`folder_step`, `axis_step`, `run_step`): the cards and their messages are the same, laid out
+one page under the other.
## 5. Command line
diff --git a/notebooks/MorphoBricks_Preprocessing.ipynb b/notebooks/MorphoBricks_Preprocessing.ipynb
index 0e8d130..eedc65b 100644
--- a/notebooks/MorphoBricks_Preprocessing.ipynb
+++ b/notebooks/MorphoBricks_Preprocessing.ipynb
@@ -370,14 +370,17 @@
"\n",
"Opens the preprocessing app. Run cells 1 and 2 first: without them this cell stops and says which cell to run.\n",
"\n",
- "In the app:\n",
+ "The app has seven steps, one on screen at a time; move with **Next** or click a step in the strip at the top:\n",
"\n",
- "1. Give the **experiment folder** (masks and output folders are optional) and click **Scan the folders**. Look at the folders and the example image, then click **Looks right: read all image headers**.\n",
- "2. Go through the steps one at a time with **Next** (or click a step in the strip at the top): axes and pixel size, conditions, replicates, comparisons, image type, stains, channel tags and masks.\n",
- "3. On **Check and save**, read your experiment in words and save it; then **Run** computes the histograms.\n",
- "4. After the run, the intensity checks and **Compare groups** are collapsed panels of the run step.\n",
+ "1. **Images**: give the experiment folder (masks and output are optional), click **Scan the folders**, look at the example image, then **Looks right: read all image headers**.\n",
+ "2. **Axes**: what each image dimension is, the pixel size, and the image type (separate stains or colour).\n",
+ "3. **Channels**: name what you stained, then tag each folder's channels.\n",
+ "4. **Masks**: say what each mask type is (nothing to do without masks). After the run, come back here for **Describe the masks** if later bricks use the masks.\n",
+ "5. **Design**: conditions, biological repeats and comparisons in one grid; **Looks right: confirm the design**.\n",
+ "6. **Check and save**: read your experiment in words and save it.\n",
+ "7. **Run**: computes the histograms; the intensity checks and **Compare groups** are collapsed panels after the run.\n",
"\n",
- "Every wait shows its progress, and a greyed-out button says beside it what it waits for."
+ "What the app proposes is recorded only when you click. Every wait shows its progress, and a greyed-out button says beside it what it waits for."
]
},
{
diff --git a/pyproject.toml b/pyproject.toml
index 081960a..c6b5f14 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -143,6 +143,8 @@ baseline = "tools/code_rules_baseline.json"
"morphobricks_preprocessing.notebook_axes" = "ui"
"morphobricks_preprocessing.notebook_progress" = "ui"
"morphobricks_preprocessing.notebook_compact" = "ui"
+"morphobricks_preprocessing.notebook_design_step" = "ui"
+"morphobricks_preprocessing.notebook_image_type_line" = "ui"
"morphobricks_preprocessing.notebook_quick_scan" = "ui"
"morphobricks_preprocessing.notebook_axes_quick" = "ui"
"morphobricks_preprocessing.notebook_channel_thumbs" = "ui"
diff --git a/src/morphobricks_preprocessing/notebook_axes_quick.py b/src/morphobricks_preprocessing/notebook_axes_quick.py
index 76a3c46..fcffa64 100644
--- a/src/morphobricks_preprocessing/notebook_axes_quick.py
+++ b/src/morphobricks_preprocessing/notebook_axes_quick.py
@@ -111,8 +111,12 @@ def _refresh_quick_axes(self, drawn: bool = False) -> None:
for widget in (self.preview_checked.widget, self.preview_blocker, self.confirm_button,
self.confirm_blocker): # fmt: skip
ui.set_visible(widget, not offered)
- # The offer says the roles are the headers' and nothing is open: the line is not repeated.
- ui.set_visible(self.axis_status, not offered)
+ # The offer says the roles are the headers' and nothing is open: the line is not repeated;
+ # once confirmed, the confirmation says it, and Change axis roles takes it back.
+ confirmed = self.confirmed_mapping is not None
+ ui.set_visible(self.axis_status, not offered and not confirmed)
+ if confirmed:
+ ui.set_visible(self.preview_checked.widget, False)
if offer is None:
return
if offer.roles != self.mapping.roles:
diff --git a/src/morphobricks_preprocessing/notebook_channels_grid.py b/src/morphobricks_preprocessing/notebook_channels_grid.py
index 3b0bb42..afdb89e 100644
--- a/src/morphobricks_preprocessing/notebook_channels_grid.py
+++ b/src/morphobricks_preprocessing/notebook_channels_grid.py
@@ -408,11 +408,11 @@ def _block_text(self, units: Sequence[UnitLayout], colour: bool, apart: bool) ->
def _type_text(self, layout: UnitLayout) -> str:
"""Say the image-type answer of a colour block, as the image-type page gave it."""
if self.types.saved(layout.key) == "rgb":
- where = "" if self.place_types else " (Image type)"
+ where = "" if self.place_types else " (Axes)"
return f"Answered colour (RGB){where}: its channels are R, G and B, nothing to tag."
if self.place_types:
return "Colour is pre-ticked from the header: confirm it with the button below."
- return "Colour is pre-ticked from the header: confirm it on Image type."
+ return "Colour is pre-ticked from the header: confirm it on Axes (Image type)."
def _pending(self, layout: UnitLayout) -> bool:
"""Return True when the unit shows a proposal not saved yet.
diff --git a/src/morphobricks_preprocessing/notebook_check_save.py b/src/morphobricks_preprocessing/notebook_check_save.py
index 98b20e5..43d68da 100644
--- a/src/morphobricks_preprocessing/notebook_check_save.py
+++ b/src/morphobricks_preprocessing/notebook_check_save.py
@@ -502,7 +502,7 @@ def save_experiment(self) -> bool:
self.save_status.value = ui.status(
"error",
"Not saved: a short name was refused.",
- ["See Conditions and Replicates (steps 4 and 5)."],
+ ["See Design: the names are under Edit names or pairing."],
)
return False
if not self._save_description():
diff --git a/src/morphobricks_preprocessing/notebook_check_view.py b/src/morphobricks_preprocessing/notebook_check_view.py
index 74991f7..a2e4b00 100644
--- a/src/morphobricks_preprocessing/notebook_check_view.py
+++ b/src/morphobricks_preprocessing/notebook_check_view.py
@@ -26,15 +26,15 @@
"settings). They are then one biological replicate, compared with each other."
)
QUESTION_WORDS = {
- "id_pending": "a short name is missing for {subject} (Conditions or Replicates)",
+ "id_pending": "a short name is missing for {subject} (Design)",
"repeat_not_confirmed": "{subject}: answer “same way?” above",
"confirmed_on_missing": "{subject}: answer “same way?” again above",
- "unit_channels_missing": "the channels of {subject} are not tagged (Tag channels)",
- "unit_channel_untagged": "{subject} is not tagged (Tag channels)",
- "channel_undeclared": "the label {subject} is used but not declared (Stains)",
- "unit_image_type_unanswered": "the image type of {subject} is not answered (Image type)",
+ "unit_channels_missing": "the channels of {subject} are not tagged (Channels)",
+ "unit_channel_untagged": "{subject} is not tagged (Channels)",
+ "channel_undeclared": "the label {subject} is used but not declared (Channels)",
+ "unit_image_type_unanswered": "the image type of {subject} is not answered (Axes)",
"unit_channels_stale": (
- "the tags of {subject} were saved for another image: tag its channels again (Tag channels)"
+ "the tags of {subject} were saved for another image: tag its channels again (Channels)"
),
"unit_channel_count_unknown": (
"the channels of {subject} are not known from its images: confirm the axis roles "
@@ -115,6 +115,10 @@ def names_html(names: Sequence[tuple[str, Sequence[str]]]) -> str:
)
+TODO_SHOWN = 8
+"""The most missing answers listed at once on *Check and save*; the others are folded."""
+
+
def todo_html(questions: Sequence[OpenQuestion], extra: Sequence[str]) -> str:
"""Return what is still to answer before saving, or nothing."""
lines = [
@@ -126,7 +130,16 @@ def todo_html(questions: Sequence[OpenQuestion], extra: Sequence[str]) -> str:
lines += list(extra)
if not lines:
return ""
- return ui.status("warn", f"Still to answer before saving: {len(lines)}.", lines)
+ title = f"Still to answer before saving: {len(lines)}."
+ if len(lines) <= TODO_SHOWN:
+ return ui.status("warn", title, lines)
+ # Every line is still there: the first ones shown, the others one click away.
+ first = [html.escape(line) for line in lines[: TODO_SHOWN - 2]]
+ rest = "".join(f"
{html.escape(line)}" for line in lines[TODO_SHOWN - 2 :])
+ more = (
+ f"and {len(lines) - len(first)} more
"
+ )
+ return ui.status("warn", title, [*first, more], raw=True)
def _issue_lines(issues: Sequence[ValidationIssue]) -> list[str]:
diff --git a/src/morphobricks_preprocessing/notebook_compact.py b/src/morphobricks_preprocessing/notebook_compact.py
index 4f2322a..2846f7b 100644
--- a/src/morphobricks_preprocessing/notebook_compact.py
+++ b/src/morphobricks_preprocessing/notebook_compact.py
@@ -40,13 +40,11 @@
"Optional; nothing is written. Statistics are pooled from the histograms of all "
"planes of a group and channel: a group's median is the median of all its pixels."
)
-CHIPS = {
- "Images found": "Images",
- "Compared against": "Compared to",
- "Tag channels": "Tags",
- "Check and save": "Save",
- "Describe the masks": "Describe masks",
-}
+MASKS_HINT = (
+ "This run recorded masks. Bricks that use masks accept only masks that were read once: "
+ "Describe the masks on Masks (step 4) reads them and reports how the types relate."
+)
+CHIPS = {"Check and save": "Save"}
"""The shorter names on the chips, so that the strip is one row on a laptop screen."""
@@ -78,7 +76,6 @@ def _images_page(self) -> list[widgets.Widget]:
self.folder_status, # the check's progress, right under the button clicked
self.folder_lock.card,
self.folder_tiles,
- self.masks_card,
self.issues_card,
self.found_fold,
self.report_fold,
@@ -86,12 +83,13 @@ def _images_page(self) -> list[widgets.Widget]:
]
def _run_page(self) -> list[widgets.Widget]:
- """Return the run's widgets (the mask step has its own step after it)."""
+ """Return the run's widgets (the optional mask description is on the Masks step)."""
self.settings_fold = ui.accordion("Settings and mode", [self.settings_card])
self.plan_fold = ui.accordion("What will happen", [self.plan_card])
self.checks_fold = ui.accordion("Intensity checks and plots", [self.run_checks.card])
self.compare_fold = ui.accordion("Compare groups", [self._compare_box()])
self.run_report_fold = ui.accordion("Run report", [self.run_report.widget])
+ self.masks_hint = ui.help_text(MASKS_HINT) # after a run that recorded masks
self.log_text = widgets.HTML()
self.details.observe(lambda _: self._write_log_text(), names="value")
self.details_box.children = (self.log_text,)
@@ -107,6 +105,7 @@ def _run_page(self) -> list[widgets.Widget]:
self.run_lock.card,
self.run_tiles,
self.run_issues,
+ self.masks_hint,
self.checks_fold,
self.compare_fold,
self.run_report_fold,
@@ -163,6 +162,7 @@ def _refresh_folds(self) -> None:
ui.set_visible(self.checks_fold, written and ui.is_visible(self.run_checks.card))
ui.set_visible(self.compare_fold, written)
ui.set_visible(self.run_report_fold, bool(self.run_report.where.value))
+ ui.set_visible(self.masks_hint, written and not self.mask_step_button.disabled)
-__all__ = ["CHIPS", "COMPARE_HELP", "FOLDERS_HELP", "FOLDER_INTRO", "_CompactPages"]
+__all__ = ["CHIPS", "COMPARE_HELP", "FOLDERS_HELP", "FOLDER_INTRO", "MASKS_HINT", "_CompactPages"]
diff --git a/src/morphobricks_preprocessing/notebook_described.py b/src/morphobricks_preprocessing/notebook_described.py
index f9715e8..b2d412a 100644
--- a/src/morphobricks_preprocessing/notebook_described.py
+++ b/src/morphobricks_preprocessing/notebook_described.py
@@ -202,8 +202,8 @@ def _described_image_type(self) -> str | None:
def _refresh_axis_next(self) -> None:
self.axis_next.value = ui.next_steps(
- "Go to Stains.",
- "Name what you stained, then Tag channels, Masks and Check and save.",
+ "Go to Channels.",
+ "Name what you stained and tag the channels, then Masks, Design and Check and save.",
)
# The run -----------------------------------------------------------------------------
diff --git a/src/morphobricks_preprocessing/notebook_description.py b/src/morphobricks_preprocessing/notebook_description.py
index 2a40380..26b0ef3 100644
--- a/src/morphobricks_preprocessing/notebook_description.py
+++ b/src/morphobricks_preprocessing/notebook_description.py
@@ -342,7 +342,7 @@ def _build_references(self) -> None:
references = self.draft.references
for condition in conditions:
box = self.reference_boxes[condition]
- options, chosen = self._reference_options(condition), self._chosen_reference(condition)
+ options, chosen = self._reference_options(condition), self._shown_reference(condition)
if list(box.options) != options:
box.options = options
if box.value != chosen:
@@ -361,7 +361,7 @@ def _new_reference_rows(self, conditions: list[str]) -> None:
for condition in conditions:
box = widgets.Dropdown(
options=self._reference_options(condition),
- value=self._chosen_reference(condition),
+ value=self._shown_reference(condition),
layout=widgets.Layout(width="99%"),
)
box.observe(
@@ -386,6 +386,10 @@ def _chosen_reference(self, condition: str) -> str | None:
answer = self.draft_answers.conditions.get(condition, ConditionAnswer()).reference
return self.draft.references.get(condition) if answer is None else answer
+ def _shown_reference(self, condition: str) -> str | None:
+ """Return what the dropdown shows: the saved choice (the stepper shows a proposal)."""
+ return self._chosen_reference(condition)
+
def _references_answered(self) -> bool:
"""Return True once any condition was answered (including "no comparison")."""
answers = self.draft_answers
diff --git a/src/morphobricks_preprocessing/notebook_design_step.py b/src/morphobricks_preprocessing/notebook_design_step.py
new file mode 100644
index 0000000..7f0234b
--- /dev/null
+++ b/src/morphobricks_preprocessing/notebook_design_step.py
@@ -0,0 +1,298 @@
+"""The *Design* step of the shipped notebook: conditions, repeats and comparisons in one grid.
+
+Owner decision (10 November 2026, item (q)): the conditions, the replicates and "compared
+against" are one step with an easy default. The step shows one grid, conditions down and
+biological repeats across, each cell the folder that is that repeat of that condition, with
+what each condition is compared against in the last column. Everything in it is a proposal:
+
+- the IDs are the folder names (only where the reader accepts a folder name as an ID),
+- folders with the same name are the same biological repeat,
+- no condition is compared against another.
+
+Nothing of it is taken until the user clicks **Looks right: confirm the design**. That one
+click adopts every ID as shown (the same all-or-none adoption as *Yes, use these IDs*,
+:meth:`.notebook_description._DescriptionCard._confirm_ids`, with the reader's refusals listed)
+and records "no comparison" for each condition still showing that proposal. A name that cannot
+be proposed, a different pairing or a shown-as name is edited under **Edit names or
+pairing…**, which holds the rows of the earlier steps unchanged. *Check and save* waits for the
+click, and says so.
+
+The grid is built once for the conditions and repeats there are and then updated in place, so
+a choice never replaces the control the user is using.
+"""
+
+from __future__ import annotations
+
+import html
+
+import ipywidgets as widgets
+
+from . import notebook_style as ui
+from .notebook_description import _updated
+from .notebook_description_view import pill
+from .notebook_render import blocked_line
+from .notebook_values import natural_key
+from .validation import plural
+
+CONFIRM_LABEL = " Looks right: confirm the design"
+HELP = (
+ "Rows are conditions, columns are biological repeats, and a cell is the folder of that "
+ "repeat. The names are the folder names and nothing is compared, until you say otherwise."
+)
+NOT_CONFIRMED = "the design is not confirmed: click Looks right: confirm the design (Design)"
+
+
+class _DesignStep:
+ """The grid of the *Design* step, its one confirmation and the fold that edits it."""
+
+ def _build_design_step(self) -> None:
+ """Build the grid, the confirmation and the fold of the names and the pairing."""
+ self._design_shape: tuple | None = None
+ self._design_cells: dict[tuple, widgets.HTML] = {}
+ self._design_problem = ""
+ self.design_table = widgets.GridBox()
+ self.design_technical = widgets.HTML()
+ self.design_confirm = ui.button(
+ CONFIRM_LABEL,
+ style="success",
+ icon="check",
+ tooltip="Use these names, this pairing and these comparisons",
+ main=True,
+ )
+ self.design_blocker = widgets.HTML()
+ self.design_result = widgets.HTML()
+ self.design_change = ui.button(
+ " Change the design", icon="pencil", tooltip="Take the names back to edit them"
+ )
+ self.design_edit = ui.accordion(
+ "Edit names or pairing…",
+ [widgets.VBox([self._conditions_card(), self.design_card, self._replicates_card()])],
+ )
+ self.design_step_card = ui.card(
+ "Your design",
+ [
+ ui.help_text(HELP),
+ self.design_table,
+ self.design_technical,
+ self.design_result,
+ self.design_confirm,
+ self.design_blocker,
+ self.design_change,
+ ],
+ visible=False,
+ )
+ self.design_confirm.on_click(lambda _: self._confirm_design())
+ self.design_change.on_click(lambda _: self._change_design())
+
+ # State -------------------------------------------------------------------------------
+
+ def _design_confirmed(self) -> bool:
+ """Return True when every ID is adopted and every comparison answered."""
+ draft = self.draft
+ if draft is None or self.draft_answers is None or not self._ids_all_confirmed():
+ return False
+ if len(draft.conditions) < 2:
+ return True
+ return all(self._chosen_reference(c) is not None for c in draft.conditions)
+
+ def _shown_reference(self, condition: str) -> str | None:
+ """Show "no comparison" as the proposal of a condition nobody answered for."""
+ chosen = self._chosen_reference(condition)
+ return "" if chosen is None and self._in_steps() else chosen
+
+ def _reference_options(self, condition: str) -> list[tuple[str, str | None]]:
+ """Leave out "choose" in the stepper: the proposal there is "no comparison"."""
+ options = super()._reference_options(condition)
+ return options[1:] if self._in_steps() else options
+
+ def _in_steps(self) -> bool:
+ return getattr(self, "design_table", None) is not None
+
+ # The click ---------------------------------------------------------------------------
+
+ def _confirm_design(self) -> bool:
+ """Adopt the design as shown: every ID, and "no comparison" where nothing was chosen.
+
+ Returns
+ -------
+ bool
+ True when the design is now confirmed; False when an ID was refused (the step
+ lists each refusal and nothing was adopted) or the answers could not be saved.
+ """
+ if self.draft is None or not self._in_steps():
+ return False
+ self._names_refusal = self._draft_note = self._design_problem = ""
+ if not self._confirm_ids():
+ self._design_problem = self._names_refusal
+ self.design_edit.selected_index = 0
+ self._refresh_design_step()
+ return False
+ answers = self.draft_answers
+ if len(self.draft.conditions) > 1:
+ for condition in sorted(self.draft.conditions):
+ if self._chosen_reference(condition) is None:
+ answers = _updated(answers, "conditions", condition, reference="")
+ if answers != self.draft_answers and not self._answer(answers):
+ self._design_problem = ui.status("error", "Not taken.", [str(self._refusal)])
+ self._refresh_design_step()
+ return False
+ self._refresh_description()
+ self._refresh_design_status()
+ return self._design_confirmed()
+
+ def _change_design(self) -> None:
+ """Take the IDs back so the names can be edited (as *Change the IDs* does)."""
+ if self.draft is None:
+ return
+ self._design_problem = ""
+ self.ids_confirmed.set(False)
+ self.design_edit.selected_index = 0
+ self._refresh_description()
+
+ # Showing -----------------------------------------------------------------------------
+
+ def _refresh_design_step(self) -> None:
+ """Show the grid for the draft as it is answered now."""
+ if not self._in_steps():
+ return
+ draft = self.draft
+ ui.set_visible(self.design_step_card, draft is not None)
+ ui.set_visible(self.design_edit, draft is not None)
+ if draft is None:
+ self._design_shape = None
+ return
+ conditions = sorted(draft.conditions)
+ repeats = sorted(draft.repeats, key=natural_key)
+ compared = len(conditions) > 1
+ boxes = tuple(id(self.reference_boxes.get(c)) for c in conditions) if compared else ()
+ shape = (tuple(conditions), tuple(repeats), boxes)
+ if shape != self._design_shape:
+ self._design_shape = shape
+ self._build_design_table(conditions, repeats, compared)
+ self._fill_design_table(conditions, repeats)
+ confirmed = self._design_confirmed()
+ missing = [key for (_kind, key), text in self._wanted_ids().items() if not text]
+ ui.set_visible(self.design_confirm, not confirmed)
+ ui.set_visible(self.design_change, confirmed)
+ self.design_confirm.disabled = bool(missing)
+ self.design_blocker.value = blocked_line(
+ [f"{plural(len(missing), 'name')} missing ({', '.join(missing[:3])}): to type "
+ "under Edit names or pairing"]
+ if missing and not confirmed
+ else []
+ ) # fmt: skip
+ if missing and not confirmed:
+ self.design_edit.selected_index = 0
+ self.design_result.value = (
+ ui.status(
+ "ok",
+ f"Design confirmed: {plural(len(conditions), 'condition')} × "
+ f"{plural(len(repeats), 'biological repeat')}.",
+ )
+ if confirmed
+ else self._design_problem
+ )
+
+ def _build_design_table(
+ self, conditions: list[str], repeats: list[str], compared: bool
+ ) -> None:
+ """Build the grid's cells once: a header row, then one row per condition."""
+ cells: dict[tuple, widgets.HTML] = {}
+ children: list[widgets.Widget] = []
+
+ def cell(key: tuple) -> widgets.HTML:
+ cells[key] = widgets.HTML()
+ return cells[key]
+
+ children.append(widgets.HTML("Condition"))
+ children += [cell(("repeat", label)) for label in repeats]
+ if compared:
+ children.append(widgets.HTML("Compared against"))
+ for condition in conditions:
+ children.append(cell(("condition", condition)))
+ children += [cell((condition, label)) for label in repeats]
+ if compared:
+ children.append(self.reference_boxes[condition])
+ self._design_cells = cells
+ columns = f"minmax(120px, 1.3fr) repeat({len(repeats)}, minmax(90px, 1fr))"
+ if compared:
+ columns += " minmax(170px, 1.4fr)"
+ self.design_table.layout = widgets.Layout(
+ grid_template_columns=columns, grid_gap="4px 12px", width="99%", align_items="center"
+ )
+ self.design_table.children = children
+
+ def _fill_design_table(self, conditions: list[str], repeats: list[str]) -> None:
+ """Write the names and the folders into the grid's cells, in place."""
+ draft, wanted, cells = self.draft, self._wanted_ids(), self._design_cells
+ for condition in conditions:
+ slot = draft.conditions[condition]
+ cells[("condition", condition)].value = _named(
+ wanted.get(("conditions", condition), ""), condition, slot.confirmed is not None
+ )
+ for label in repeats:
+ repeat = draft.repeats[label]
+ cells[("repeat", label)].value = _named(
+ wanted.get(("repeats", label), ""), label, repeat.id.confirmed is not None
+ )
+ by_condition = {m.condition: m for m in repeat.members}
+ for condition in conditions:
+ cells[(condition, label)].value = _folder(by_condition.get(condition))
+ technical = [
+ wanted.get(("technical_repeats", folder), "") or f"{folder} (no name)"
+ for folder in sorted(draft.technical_repeats, key=natural_key)
+ if folder != "single"
+ ]
+ self.design_technical.value = (
+ f"Technical repeats (folders inside a repeat): "
+ f"{html.escape(', '.join(technical))}
"
+ if technical
+ else ""
+ )
+
+ # Hooks -------------------------------------------------------------------------------
+
+ def _refresh_description(self) -> None:
+ super()._refresh_description()
+ self._refresh_design_step()
+
+ def _hide_description(self) -> None:
+ super()._hide_description()
+ self._refresh_design_step()
+
+ def _id_typed(self, entry: object, state: widgets.HTML, text: str) -> None:
+ super()._id_typed(entry, state, text)
+ if not self._refreshing:
+ self._refresh_design_step() # the grid shows the names as they are typed
+
+ def _todo(self, applied: object, answers: object, problem: str) -> tuple[list, list[str]]:
+ """Ask for the design's one click instead of each comparison it would record."""
+ questions, extra = super()._todo(applied, answers, problem)
+ if not self._in_steps() or self._design_confirmed():
+ return questions, extra
+ kept = [line for line in extra if " is compared against (choose" not in line]
+ return questions, [NOT_CONFIRMED, *kept]
+
+
+def _named(shown: str, folder: str, confirmed: bool) -> str:
+ """Return a row or column title: the ID as shown, its folder when another, and its state."""
+ if not shown:
+ return f"{html.escape(folder)} {pill('warn', 'needs a name')}"
+ other = "" if shown == folder else f" folder {html.escape(folder)}"
+ state = "" if confirmed else f" {pill('info', 'proposed')}"
+ return f"{html.escape(shown)}{other}{state}"
+
+
+def _folder(member: object) -> str:
+ """Return a cell: the folder of one condition in one repeat, with its images, or a dash."""
+ if member is None:
+ return "no folder"
+ inside = len(member.units)
+ folders = f" · {inside} folders" if inside > 1 else ""
+ return (
+ f"{html.escape(member.source_repeat)}"
+ f"{plural(member.image_count, 'image')}{folders}
"
+ )
+
+
+__all__ = ["CONFIRM_LABEL", "NOT_CONFIRMED", "_DesignStep"]
diff --git a/src/morphobricks_preprocessing/notebook_image_type_line.py b/src/morphobricks_preprocessing/notebook_image_type_line.py
new file mode 100644
index 0000000..dd34a24
--- /dev/null
+++ b/src/morphobricks_preprocessing/notebook_image_type_line.py
@@ -0,0 +1,239 @@
+"""The image type in one line of the axes step: what the channel dimension holds.
+
+Owner decision (10 November 2026, item (p)): the image type says what the C dimension means
+(one stain or marker per channel, or the red, green and blue of a colour picture), so it is
+asked with the axes, in the pattern of the pixel size:
+
+- **one line for all folders**, its choice pre-filled from what the image headers say (colour
+ only when every folder's header says colour, separate channels only when none does). The
+ pre-fill is a proposal: nothing is saved until the user clicks **Use for all folders**;
+- **Set per folder…**, collapsed, holds the row of each folder
+ (:class:`.notebook_channels_type.ImageTypeRows`: the same cells, answers and refusals);
+- when the headers **differ**, nothing is pre-filled, one line says how many folders say what,
+ and the per-folder part opens.
+
+The answers are the per-unit ``image_type`` of the draft, saved through
+:func:`.channel_answers.with_type` exactly as the per-folder radio buttons save them: clicking
+**Use for all folders** records what choosing that type in every row records. A choice that
+contradicts a header is still allowed and said in words (the run refuses it,
+``R_IMAGE_TYPE_CONTRADICTS_HEADER``); folders answered differently stay refused by the save and
+the run (``R_HETEROGENEOUS_LAYOUT_NOT_ENABLED``, Spec Stages 1-2), and the line says so at once.
+Replacing answers already given asks first, as the pixel size does.
+"""
+
+from __future__ import annotations
+
+from collections import Counter
+from collections.abc import Sequence
+
+import ipywidgets as widgets
+
+from . import notebook_style as ui
+from .channel_answers import synced, with_type
+from .channel_labels import UnitLayout
+from .notebook_channels_type import ImageTypeColumn, ImageTypeRows
+from .notebook_render import blocked_line
+from .validation import plural
+
+OPTIONS = [("separate stains or markers, one per channel", "channels"), ("colour (RGB)", "rgb")]
+WORDS = {"channels": "separate channels", "rgb": "colour (RGB)"}
+WAITING = (
+ "Asked once the axis roles are confirmed: the channels of each folder are read through them."
+)
+MIXED = (
+ "This version runs one image type per experiment: Check and save refuses folders of "
+ "different types (R_HETEROGENEOUS_LAYOUT_NOT_ENABLED)."
+)
+
+
+def counted(values: Sequence[str]) -> str:
+ """Return ``colour (RGB) in 2 folders, separate channels in 10`` for these types."""
+ found = Counter(values)
+ return ", ".join(
+ f"{WORDS[value]} in {plural(found[value], 'folder')}" for value in WORDS if found[value]
+ )
+
+
+class ImageTypeLine:
+ """One image type for all folders, confirmed by a click; each folder on demand."""
+
+ def __init__(self, types: ImageTypeColumn, rows: ImageTypeRows) -> None:
+ self.types, self.rows = types, rows
+ self._pending: str | None = None
+ self._shown_for: object = None
+ self.line = widgets.HTML()
+ self.choice = widgets.Dropdown(
+ options=OPTIONS, value=None, layout=widgets.Layout(width="330px")
+ )
+ self.apply = ui.button(
+ " Use for all folders",
+ style="success",
+ icon="check",
+ tooltip="Record this image type for every folder of the experiment",
+ )
+ self.form = widgets.HBox(
+ [widgets.HTML("The channels are "), self.choice, self.apply],
+ layout=widgets.Layout(align_items="center", flex_flow="row wrap"),
+ )
+ self.blocker = widgets.HTML()
+ self.replace_text = widgets.HTML()
+ self.replace_yes = ui.button(" Replace them", style="warning", icon="check")
+ self.replace_no = ui.button(" Cancel", icon="times")
+ self.replace_box = widgets.VBox(
+ [self.replace_text, widgets.HBox([self.replace_yes, self.replace_no])]
+ )
+ ui.set_visible(self.replace_box, False)
+ self.message = widgets.HTML()
+ self.per_folder = ui.accordion("Set per folder…", [rows.widget])
+ self.widget = ui.card(
+ "Image type",
+ [self.line, self.form, self.blocker, self.replace_box, self.message, self.per_folder],
+ )
+ self.apply.on_click(lambda _: self._use_for_all())
+ self.replace_yes.on_click(lambda _: self._replace())
+ self.replace_no.on_click(lambda _: self._cancel())
+ self.choice.observe(lambda _: self._choice_changed(), names="value")
+ self.refresh()
+
+ # What there is -----------------------------------------------------------------------
+
+ def _usable(self) -> list[UnitLayout]:
+ return [u for u in self.types.layouts if u.usable]
+
+ @property
+ def opened(self) -> bool:
+ """True while the folders' channels are known (the axis roles are confirmed)."""
+ return ui.is_visible(self.rows.widget)
+
+ def suggestion(self) -> str | None:
+ """Return the type every header suggests, None when they differ or there is none."""
+ said = {"rgb" if u.rgb else "channels" for u in self._usable()}
+ return said.pop() if len(said) == 1 else None
+
+ def saved(self) -> list[str | None]:
+ """Return the type the user answered for each usable folder (None where not yet)."""
+ return [self.types.saved(u.key) for u in self._usable()]
+
+ @property
+ def complete(self) -> bool:
+ """True when every usable folder has an answer (the folders may still differ)."""
+ saved = self.saved()
+ return bool(saved) and None not in saved
+
+ # Showing -----------------------------------------------------------------------------
+
+ def refresh(self) -> None:
+ """Show the line for the folders as they are answered now."""
+ self._cancel()
+ usable = self._usable()
+ shown = self.opened and bool(usable)
+ for widget in (self.form, self.per_folder):
+ ui.set_visible(widget, shown)
+ if not shown:
+ self.line.value = ui.status("info", WAITING) if not self.opened else ""
+ self.blocker.value = self.message.value = ""
+ self._shown_for = None
+ return
+ saved, suggestion = self.saved(), self.suggestion()
+ answered = [value for value in saved if value is not None]
+ one = answered[0] if len(set(answered)) == 1 and len(answered) == len(saved) else None
+ key = (tuple(u.key for u in usable), tuple(saved), suggestion)
+ if key != self._shown_for: # the user's own choice in the box is kept otherwise
+ self._shown_for = key
+ self.choice.value = one or (suggestion if not answered else None)
+ self.line.value = self._line(saved, answered, one, suggestion)
+ if suggestion is None or len(set(answered)) > 1 or self.types.message:
+ self.per_folder.selected_index = 0 # they differ: each folder is shown
+ self._choice_changed()
+
+ def _line(
+ self, saved: list, answered: list[str], one: str | None, suggestion: str | None
+ ) -> str:
+ """Return the line: the answer, the proposal from the headers, or how they differ."""
+ folders = plural(len(saved), "folder")
+ said = [("rgb" if u.rgb else "channels") for u in self._usable()]
+ if one is not None:
+ lines = [] if one == suggestion else [f"The image headers say: {counted(said)}."]
+ return ui.status("ok", f"Image type: {WORDS[one]} in all {folders}.", lines)
+ if len(set(answered)) > 1:
+ left = len(saved) - len(answered)
+ more = f", {left} not answered" if left else ""
+ return ui.status("warn", f"Image types differ: {counted(answered)}{more}.", [MIXED])
+ if answered:
+ return ui.status(
+ "warn",
+ f"Not finished: {counted(answered)}, {len(saved) - len(answered)} not answered.",
+ )
+ if suggestion is None:
+ return ui.status(
+ "warn",
+ f"The image headers differ: {counted(said)}.",
+ ["Nothing is proposed: choose the type of each folder below. " + MIXED],
+ )
+ return ui.status(
+ "info",
+ f"Proposed from the image headers: {WORDS[suggestion]} in all {folders}.",
+ ["Not saved until you click Use for all folders."],
+ )
+
+ def _choice_changed(self) -> None:
+ """Offer the click for a type that is not already every folder's answer."""
+ self._cancel()
+ value, saved = self.choice.value, self.saved()
+ everywhere = value is not None and all(s == value for s in saved)
+ ui.set_visible(self.apply, not everywhere)
+ self.apply.disabled = value is None
+ self.blocker.value = blocked_line(
+ ["choose a type in the box, or set each folder below"] if value is None else []
+ )
+
+ # The click ---------------------------------------------------------------------------
+
+ def _use_for_all(self) -> None:
+ """Record the chosen type for every folder, asking first when it replaces answers."""
+ value = self.choice.value
+ if value is None:
+ return
+ others = [u for u in self._usable() if self.types.saved(u.key) not in (None, value)]
+ if not others:
+ self._write(value)
+ return
+ self._pending = value
+ self.replace_text.value = ui.status(
+ "warn",
+ f"Use {WORDS[value]} for all folders? {plural(len(others), 'folder')} answered "
+ "otherwise; nothing is written until you click Replace them.",
+ ["Their channel tags are cleared: a channel is tagged again after its type changes."],
+ )
+ ui.set_visible(self.replace_box, True)
+
+ def _replace(self) -> None:
+ pending = self._pending
+ self._cancel()
+ if pending is not None:
+ self._write(pending)
+
+ def _cancel(self) -> None:
+ self._pending = None
+ ui.set_visible(self.replace_box, False)
+
+ def _write(self, value: str) -> bool:
+ """Save ``value`` as the type of every usable folder, all or none; say a refusal."""
+ store = self.types.store
+ answers = store.answers()
+ try:
+ for layout in self._usable():
+ answers = with_type(answers, layout, value)
+ except ValueError as exc:
+ self.message.value = ui.status("error", "Nothing was changed.", [str(exc)])
+ return False
+ if not store.update(synced(answers, self.types.layouts)):
+ self.message.value = ui.status("error", "Not saved.", [store.refusal()])
+ return False
+ self.message.value = ""
+ self.types.message = ""
+ self.types._on_change()
+ return True
+
+
+__all__ = ["MIXED", "OPTIONS", "WAITING", "ImageTypeLine", "counted"]
diff --git a/src/morphobricks_preprocessing/notebook_mask_question.py b/src/morphobricks_preprocessing/notebook_mask_question.py
index f69d775..b21c95e 100644
--- a/src/morphobricks_preprocessing/notebook_mask_question.py
+++ b/src/morphobricks_preprocessing/notebook_mask_question.py
@@ -85,6 +85,7 @@ def __init__(
self.message = widgets.HTML()
self.facts = widgets.HTML()
self.pictures = widgets.HBox()
+ self.pictures.add_class("mb-mask-pictures") # drawn smaller in the stepper
self.id_box.observe(lambda _: self._id_typed(), names="value")
self.choice.observe(lambda change: self._chosen(change["new"]), names="value")
self.widget = widgets.VBox(
@@ -144,7 +145,7 @@ def _build_made_from(self, labels: list[str], chosen: tuple[str, ...]) -> None:
)
box.observe(lambda _: self._made_from_changed(), names="value")
self.made_from[label] = box
- empty = "no channel labels yet: declare them on Stains"
+ empty = "no channel labels yet: declare them on Channels"
hint = "answer the question first" if labels and self.saved is None else ""
tail = [
widgets.HTML(f"{hint or (empty if not labels else '')}")
diff --git a/src/morphobricks_preprocessing/notebook_mask_step.py b/src/morphobricks_preprocessing/notebook_mask_step.py
index c86f2cb..5a64f6d 100644
--- a/src/morphobricks_preprocessing/notebook_mask_step.py
+++ b/src/morphobricks_preprocessing/notebook_mask_step.py
@@ -84,13 +84,14 @@ def _build_mask_step_card(self) -> None:
"Describe the masks (optional)",
[
ui.help_text(
- "Reads every mask the run recorded and describes each mask type (one "
- "object per label, or labels in several pieces), how the types relate "
- "(inside, grouped by, the same objects) and their hierarchy, with the "
- f"counts behind each. Writes {MASK_REPORT_FILENAME} to the output "
- "folder, as the command line's masks step does. The relationship "
- "thresholds are the defaults; the command line's masks step can change "
- "them. Optional: the histogram table does not need it."
+ "Reads every mask the run recorded. Optional for the histogram table; "
+ "bricks that use masks accept only masks this step has read. It describes "
+ "each mask type (one object per label, or labels in several pieces), how "
+ "the types relate (inside, grouped by, the same objects) and their "
+ "hierarchy, with the counts behind each, and writes "
+ f"{MASK_REPORT_FILENAME} to the output folder, as the command "
+ "line's masks step does. The relationship thresholds are the defaults; "
+ "the command line's masks step can change them."
),
self.mask_step_hint,
self.mask_step_button,
@@ -289,8 +290,8 @@ def _recorded_hint(recorded: RecordedMasks | None) -> str:
"""Name the run the step describes, or say why the button is disabled."""
if recorded is None:
return (
- "Available when the output folder holds a run that recorded masks "
- "(step 1's masks folder)."
+ "Available after a run that recorded masks: it reads the masks that run recorded "
+ "(the masks folder is given in step 1)."
)
when = f", written {html.escape(recorded.written_at)}" if recorded.written_at else ""
run = (
diff --git a/src/morphobricks_preprocessing/notebook_masks.py b/src/morphobricks_preprocessing/notebook_masks.py
index 7779e60..f83de7a 100644
--- a/src/morphobricks_preprocessing/notebook_masks.py
+++ b/src/morphobricks_preprocessing/notebook_masks.py
@@ -226,10 +226,10 @@ def _masks_ready_item(self, review: AxisReview | None) -> tuple[bool, str] | Non
"""Return the "Before you run" item of the masks, or None without masks."""
plan = self._planned_masks() if self._show_output else None
if self._masks_stale(plan):
- return False, "Check the masks again with the folder and mask axes chosen (step 1)"
+ return False, "Check the masks again with the folder and mask axes chosen (Masks)"
if self.preflight is None or self.preflight.masks is None:
return None
- return not has_errors(self._mask_issues_now(review)), "Fix any mask errors (step 1, Masks)"
+ return not has_errors(self._mask_issues_now(review)), "Fix any mask errors (Masks)"
def _refresh_masks(self, review: AxisReview | None) -> None:
"""Show the recorded masks folder, the masks found, their axes and their messages."""
diff --git a/src/morphobricks_preprocessing/notebook_step_states.py b/src/morphobricks_preprocessing/notebook_step_states.py
index c963922..c48c491 100644
--- a/src/morphobricks_preprocessing/notebook_step_states.py
+++ b/src/morphobricks_preprocessing/notebook_step_states.py
@@ -17,8 +17,9 @@
UNCALIBRATED = ("uncalibrated", "pixels (stated)")
"""The X/Y size texts (:func:`.notebook_values.size_text`) that give no physical size."""
-ANSWER_STEPS = range(2, 10)
-"""The steps (0-based) whose answers the published file holds: conditions to check and save."""
+ANSWER_STEPS = range(1, 6)
+"""The steps (0-based) whose answers the published file holds: axes (the image type) to save."""
+TYPE_WORDS = (("separate channels", "channels"), ("colour", "rgb"))
def _ids(wanted: dict[tuple[str, str], str], kind: str) -> list[str]:
@@ -31,20 +32,15 @@ class _StepStates:
"""The :class:`.notebook_stepper.StepState` of each step, from the cards' state."""
def _step_states(self) -> list:
- """Return the state callables of the 12 steps, in order."""
+ """Return the state callables of the 7 steps, in order."""
return [
self._images_state,
self._axes_state,
- self._conditions_state,
- self._replicates_state,
- self._references_state,
- self._image_type_state,
- self._stains_state,
- self._tags_state,
+ self._channels_state,
self._masks_state,
+ self._design_state,
self._saved_state,
self._run_state,
- self._describe_state,
]
def _reopen_changed(self) -> bool:
@@ -76,13 +72,35 @@ def _images_state(self) -> StepState:
return StepState(pre.ok, text)
def _axes_state(self) -> StepState:
- """Return step 2: the confirmed roles, with the pixel size (its note when uncalibrated)."""
+ """Return step 2: the confirmed roles, the pixel size and the image type.
+
+ Done once the roles are confirmed and every folder's image type is answered; the chip
+ notes an uncalibrated experiment.
+ """
mapping = self.confirmed_mapping
sizes, note = self._sizes_words()
if mapping is None:
return StepState(False, "not confirmed")
roles = ", ".join(role for role in mapping.roles if role) + " confirmed"
- return StepState(True, f"{roles} · {sizes}" if sizes else roles, note=note)
+ types, typed = self._image_type_words()
+ text = " · ".join(part for part in (roles, sizes, types) if part)
+ return StepState(typed, text, self._changed(1, typed), note=note)
+
+ def _image_type_words(self) -> tuple[str, bool]:
+ """Return the image types answered, by kind, and whether none is left to answer."""
+ if self._channels_key is None:
+ return "image type: after the folder check", False
+ types = self.channel_types
+ saved = [types.saved(u.key) for u in self._channel_layouts if u.usable]
+ parts = [
+ f"{word} ({plural(saved.count(value), 'folder')})"
+ for word, value in TYPE_WORDS
+ if value in saved
+ ]
+ done = None not in saved
+ if not done:
+ parts.append(f"image type: {saved.count(None)} to choose")
+ return ", ".join(parts), done
def _sizes_words(self) -> tuple[str, str]:
"""Return the pixel sizes as the calibration card shows them, and the chip's note."""
@@ -110,97 +128,57 @@ def _sizes_words(self) -> tuple[str, str]:
def _changed(self, index: int, done: bool) -> bool:
return not done and index in ANSWER_STEPS and self._reopen_changed()
- def _conditions_state(self) -> StepState:
- """Return step 3: the condition IDs as shown, with their shown-as names."""
- if self.draft is None:
- return StepState(False, "after the folder check")
- wanted = self._wanted_ids()
- ids = _ids(wanted, "conditions")
- shown = {
- wanted.get(("conditions", folder), ""): answer.display_name
- for folder, answer in self.draft_answers.conditions.items()
- if answer.display_name
- }
- text = ", ".join(f"{i} ({shown[i]})" if i in shown else i or "?" for i in ids)
- done = all(ids)
- return StepState(done, text or "none", self._changed(2, done))
-
- def _replicates_state(self) -> StepState:
- """Return step 4: the replicate IDs, the pairing and the technical-repeat IDs."""
+ def _design_state(self) -> StepState:
+ """Return step 5: the conditions, the replicates and the comparisons, once confirmed."""
if self.draft is None:
return StepState(False, "after the folder check")
wanted = self._wanted_ids()
+ conditions = sorted(self.draft.conditions)
+ chosen = {c: self._chosen_reference(c) for c in conditions}
+ named = []
+ for folder in conditions:
+ text = wanted.get(("conditions", folder)) or "?"
+ target = chosen[folder]
+ named.append(f"{text} → {wanted.get(('conditions', target)) or target}"
+ if target else text) # fmt: skip
repeats, technical = _ids(wanted, "repeats"), _ids(wanted, "technical_repeats")
- text = ", ".join(i or "?" for i in repeats)
+ text = f"{', '.join(named)} × {', '.join(i or '?' for i in repeats)}"
paired = [
f"{condition}/{folder} = {wanted.get(('repeats', group)) or group}"
for (condition, folder), group in sorted(self.draft_answers.pairing.items())
]
if paired:
text += f" ({'; '.join(paired)})"
- named = [i or "?" for i in technical if i != "single"]
- if named:
- text += " · technical: " + ", ".join(named)
- done = all(repeats) and all(technical)
- return StepState(done, text or "none", self._changed(3, done))
-
- def _references_state(self) -> StepState:
- if self.draft is None:
- return StepState(False, "after the folder check")
- conditions = sorted(self.draft.conditions)
- if len(conditions) < 2:
- return StepState(True, "one condition: nothing to compare")
- chosen = {c: self._chosen_reference(c) for c in conditions}
- text = " · ".join(f"{c} → {'?' if t is None else t or 'none'}" for c, t in chosen.items())
- done = None not in chosen.values()
- return StepState(done, text, self._changed(4, done))
-
- def _image_type_state(self) -> StepState:
- """Return step 6: the image types answered, by kind, and how many are left."""
- if self._channels_key is None:
- return StepState(False, "after the axes")
- types = self.channel_types
- usable = [u for u in self._channel_layouts if u.usable]
- saved = [types.saved(u.key) for u in usable]
- parts = [
- f"{word} ({plural(saved.count(value), 'folder')})"
- for word, value in (("separate channels", "channels"), ("colour", "rgb"))
- if value in saved
- ]
- done = None not in saved
+ named_folders = [i or "?" for i in technical if i != "single"]
+ if named_folders:
+ text += " · technical: " + ", ".join(named_folders)
+ done = self._design_confirmed()
if not done:
- parts.append(f"{saved.count(None)} to choose")
- return StepState(done, ", ".join(parts), self._changed(5, done))
+ text += " · not confirmed"
+ return StepState(done, text, self._changed(4, done))
- def _stains_state(self) -> StepState:
- """Return step 7: the confirmed labels, or not applicable when every folder is colour."""
+ def _channels_state(self) -> StepState:
+ """Return step 3: the stains named and every channel of every folder tagged."""
if self._channels_key is None:
return StepState(False, "after the axes")
- if self._stains_not_applicable:
- return StepState(
- True, "not applicable: every folder is colour (RGB)", note="not applicable"
- )
- done = self.channel_labels.confirmed
- text = ", ".join(self.channel_labels.labels) if done else "not confirmed"
- return StepState(done, text, self._changed(6, done))
-
- def _tags_state(self) -> StepState:
- """Return step 8: whether every channel of every folder is tagged."""
- if self._channels_key is None or not (
- self.channel_labels.confirmed or self._stains_not_applicable
- ):
- return StepState(False, "after the stains")
layouts = self._channel_layouts
+ if self._stains_not_applicable:
+ stains = "no stains to name: every folder is colour (RGB)"
+ elif self.channel_labels.confirmed:
+ stains = ", ".join(self.channel_labels.labels)
+ else:
+ return StepState(False, "stains not confirmed", self._changed(2, False))
left = remaining(layouts, self.draft_answers.units, self.channel_labels.labels)
done = not left.cells and not left.blocked
- text = (
+ tags = (
f"every channel of {plural(len(layouts), 'folder')} tagged"
if done
else f"{left.cells} channel(s) still to tag"
)
- return StepState(done, text, self._changed(7, done))
+ return StepState(done, f"{stains} · {tags}", self._changed(2, done))
def _masks_state(self) -> StepState:
+ """Return step 4: what each mask type is, and whether the relationships are described."""
types = self._mask_types()
if not types:
return StepState(self.preflight is not None, "no masks")
@@ -208,8 +186,10 @@ def _masks_state(self) -> StepState:
return StepState(False, "after the folder check")
kinds = mask_kinds(self.draft_answers)
text = " · ".join(f"{t} = {kinds.get(t, '?')}" for t in types)
+ if self.mask_step_result.value:
+ text += " · relationships described"
done = not masks_unanswered(self.draft_answers, types)
- return StepState(done, text, self._changed(8, done))
+ return StepState(done, text, self._changed(3, done))
def _saved_state(self) -> StepState:
if self.draft is None:
@@ -217,7 +197,7 @@ def _saved_state(self) -> StepState:
if self._saved_current():
state = self._published()
return StepState(True, f"saved {self._saved_on or state.saved_on}")
- return StepState(False, "not saved yet", self._changed(9, False))
+ return StepState(False, "not saved yet", self._changed(5, False))
def _run_state(self) -> StepState:
result = self.result
@@ -230,10 +210,5 @@ def _run_state(self) -> StepState:
True, f"{plural(result.plane_count, 'plane')} · {plural(len(warnings), 'warning')}"
)
- def _describe_state(self) -> StepState:
- if self.mask_step_result.value:
- return StepState(True, "masks described")
- return StepState(False, "optional")
-
__all__ = ["ANSWER_STEPS", "UNCALIBRATED", "_StepStates"]
diff --git a/src/morphobricks_preprocessing/notebook_stepper.py b/src/morphobricks_preprocessing/notebook_stepper.py
index 8451bc7..1ae0721 100644
--- a/src/morphobricks_preprocessing/notebook_stepper.py
+++ b/src/morphobricks_preprocessing/notebook_stepper.py
@@ -55,10 +55,11 @@
.mb-compact .mb-header { font-size: 11px; padding-bottom: 3px; margin-bottom: 5px; }
.mb-compact .mb-help { font-size: 12px; margin: 1px 0 4px 0; line-height: 1.35; }
.mb-compact .mb-status { padding: 5px 10px; margin: 4px 0; line-height: 1.4; }
-.mb-compact .mb-tiles { gap: 6px; margin: 4px 0 2px 0; }
-.mb-compact .mb-tile { padding: 3px 10px; min-width: 96px; flex: 1 1 96px; }
-.mb-compact .mb-tile .mb-tile-value { font-size: 15px; }
-.mb-compact .mb-tile .mb-tile-label { font-size: 11px; }
+.mb-compact .mb-tiles { gap: 2px 18px; margin: 4px 0 4px 2px; }
+.mb-compact .mb-tile { display: flex; align-items: baseline; gap: 5px; padding: 0; min-width: 0;
+ flex: 0 0 auto; border: none; background: transparent; box-shadow: none; }
+.mb-compact .mb-tile .mb-tile-value { font-size: 13px; }
+.mb-compact .mb-tile .mb-tile-label { font-size: 12px; }
.mb-compact .mb-confirm { padding: 6px 10px; margin: 6px 0 2px 0; }
.mb-compact .mb-question { margin-bottom: 4px; }
.mb-compact .mb-table td { padding: 3px 10px; }
@@ -81,6 +82,7 @@
.mb-compact .mb-scan img.mb-grey, .mb-compact .mb-scan img.mb-colour,
.mb-compact .mb-scan img.mb-crop { width: 180px !important; }
.mb-channel-row { gap: 8px; }
+.mb-compact .mb-mask-pictures img { width: 170px !important; height: auto; }
.mb-compact .mb-channel-row .mb-channel img.mb-grey { width: 99% !important; }
.mb-about { margin: 0 0 6px 0; font-size: 12.5px; color: #475569; }
.mb-about summary { cursor: pointer; color: #2563EB; }
diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py
index cb3f047..2fec802 100644
--- a/src/morphobricks_preprocessing/notebook_steps.py
+++ b/src/morphobricks_preprocessing/notebook_steps.py
@@ -1,18 +1,25 @@
-"""The shipped notebook's 12 steps, one on screen at a time (owner decision 2026-11-09).
-
-Pure composition of the cards the notebook already has (UX proposal 2026-11-09, PR 17a): the
-order is images found, axes (with the pixel size, owner 2026-11-10), conditions, replicates,
-compared against, image type,
-stains, tag channels, masks, check and save (with the overview of steps 1-9), run, and describe
-the masks. Each answer is still saved, refused and checked by the card that asks it; the stepper
+"""The shipped notebook's 7 steps, one on screen at a time (owner decisions 2026-11-09/10).
+
+Pure composition of the cards the notebook already has. The order is the owner's of
+10 November 2026, what the files hold first and what it means after:
+
+1. **Images**: the folders, the quick scan, then every header read on an explicit click;
+2. **Axes**: the role of each dimension with the example, the pixel size, and the image type
+ (what the channel dimension holds: :mod:`.notebook_image_type_line`);
+3. **Channels**: name the stains, then tag each folder's channels;
+4. **Masks**: the masks paired with the images (and their axes), what each mask type is and,
+ once a run recorded them, the mask step that reads every mask and describes how the types
+ relate (it reads the masks the run recorded, so it cannot come earlier; bricks that use
+ masks still refuse a mask it has not read, ``A_MASK_UNCHECKED``);
+5. **Design**: conditions, biological repeats and comparisons in one grid, confirmed by one
+ click (:mod:`.notebook_design_step`);
+6. **Check and save**; 7. **Run**, with the collapsed *Compare groups*.
+
+Each answer is still saved, refused and checked by the card that asks it; the stepper
(:mod:`.notebook_stepper`) only shows one page and says, from each card's state
-(:mod:`.notebook_step_states`), which steps are done.
-
-Three things follow the layout and nothing else: the tag grid leaves the image type to its own
-page (:class:`.notebook_channels_type.ImageTypeRows`, the same answers), the condition names and
-the replicate names sit on their own steps (:mod:`.notebook_names`), and the consistency card
-follows the pairing, shown only when it warns. A page holding a card the saved reopening folded
-unfolds it when it is opened. ``folder_step`` and ``axis_step`` are unchanged for embedders.
+(:mod:`.notebook_step_states`), which steps are done. A page holding a card the saved
+reopening folded unfolds it when it is opened. ``folder_step`` and ``axis_step`` are unchanged
+for embedders.
"""
from __future__ import annotations
@@ -26,6 +33,8 @@
from .notebook_channels_type import ImageTypeRows
from .notebook_check_save import INTRO as CHECK_INTRO
from .notebook_compact import CHIPS, _CompactPages
+from .notebook_design_step import _DesignStep
+from .notebook_image_type_line import ImageTypeLine
from .notebook_masks_screen import INTRO as MASKS_INTRO
from .notebook_quick_scan import _QuickScan
from .notebook_step_states import _StepStates
@@ -39,7 +48,22 @@
"later steps are read only through the roles you confirm.
"
+ ROLES_HELP
+ " The pixel size is the size of one pixel along X and Y, from the image headers or stated "
- "by you; nothing is guessed, and an image without one is uncalibrated."
+ "by you; nothing is guessed, and an image without one is uncalibrated.
The image type "
+ "says what the channel dimension holds: one stain or marker per channel, or the red, green "
+ "and blue of a colour picture. It is proposed from the image headers and recorded only "
+ "when you click."
+)
+CHANNELS_INTRO = (
+ "What happens here: first name what you stained, then say, for every folder, which "
+ "stain each channel is, looking at its picture. Nothing is chosen for you: names found in "
+ "the image headers are offered only as suggestions, and each one needs your click."
+)
+DESIGN_INTRO = (
+ "What happens here: you say which folders are which condition and biological "
+ "repeat, and what each condition is compared against. A short name (ID) is what later "
+ "bricks use; it never changes once the experiment description is saved. Later bricks "
+ "normalise each biological repeat over all its conditions together, so folders of the same "
+ "preparation must be the same repeat."
)
CONDITIONS_HELP = (
"Each condition gets a short name (its ID), which later bricks use and which never changes "
@@ -51,17 +75,18 @@
"which never changes once the experiment description is saved. A replicate pairs one folder "
"per condition (above); its name is proposed from the folder name when it can be used."
)
-OVERVIEW_STEPS = 9
-"""The overview on *Check and save* restates steps 1 to 9."""
-UNFOLDING = {3: "design_card", 4: "references_card", 8: "masks_box"}
+OVERVIEW_STEPS = 5
+"""The overview on *Check and save* restates steps 1 to 5."""
+MASKS_STEP, DESIGN_STEP = 3, 4
+UNFOLDING = (MASKS_STEP, DESIGN_STEP)
"""The steps (0-based) that show a card the saved reopening folds."""
-class _StepsFlow(_CompactPages, _QuickScan, _QuickAxes, _StepStates):
+class _StepsFlow(_CompactPages, _DesignStep, _QuickScan, _QuickAxes, _StepStates):
"""``steps()``: the stepper of the shipped notebook, and the hooks that keep it current."""
def steps(self) -> widgets.Widget:
- """Return the notebook's 12 steps, one shown at a time, with a strip and Back/Next.
+ """Return the notebook's 7 steps, one shown at a time, with a strip and Back/Next.
The shipped notebook shows this instead of the stacked
pages. Every answer is given and saved on the card that asks it, as on the pages;
@@ -74,13 +99,14 @@ def steps(self) -> widgets.Widget:
"""
if getattr(self, "_stepper", None) is not None:
return self._stepper.widget
- self.channel_grid.place_types = False # the image type has its own step
+ self.channel_grid.place_types = False # the image type is asked with the axes
if ui.is_visible(self.channel_grid.widget):
self.channel_grid.refresh()
for box in (self.folder_next, self.axis_next):
ui.set_visible(box, False) # Next and the strip say where to go
self.type_rows = ImageTypeRows(self.channel_types, lambda: self._channel_thumbs)
self.type_rows.reopen = self.channel_grid.reopen = self._reopen_units
+ self.type_line = ImageTypeLine(self.channel_types, self.type_rows)
self.overview_box = widgets.VBox()
self.overview_fold = ui.accordion("Your answers, step by step", [self.overview_box])
self._build_quick_axes()
@@ -88,39 +114,36 @@ def steps(self) -> widgets.Widget:
ui.set_visible(self.roles_help, False) # said under About this step
self._build_quick_scan()
self.consistency_slot = widgets.VBox([self.consistency_card])
+ self._build_design_step()
self._stepper = Stepper(self._step_list(), self._step_shown)
self.overview_box.children = [self._stepper.overview(OVERVIEW_STEPS)]
+ if self.draft is not None:
+ self._refresh_description() # the comparisons show their proposal
self._refresh_type_rows()
self._refresh_quick_axes()
self._refresh_steps()
return self._stepper.widget
def _step_list(self) -> list[Step]:
- """Return the 12 steps: titles, purposes, the existing cards, and their states."""
+ """Return the 7 steps: titles, purposes, the existing cards, and their states."""
states = self._step_states()
pages = [
- ("Images found", "Choose the folder with your images and check it.",
+ ("Images", "Choose the folders, look at one image, then read every header.",
self._images_page()),
- ("Axes", "Say which image dimension is width, height, channel and time.",
- [about(AXES_INTRO), self.axis_box, self.quick_card]),
- ("Conditions", "A short name for each condition.", [self._conditions_card()]),
- ("Replicates", "Which folders are one replicate, and their names.",
- [self.design_card, self._replicates_card(), self.consistency_slot]),
- ("Compared against", "One question per condition.", [self.references_card]),
- ("Image type", "Separate channels or a colour (RGB) picture, per folder.",
- [self.type_rows.widget]),
- ("Stains", "Name what you stained.",
- [self.channel_intro, self.channel_labels.widget]),
- ("Tag channels", "Say which stain each channel is, looking at its picture.",
- [self.channel_grid.widget]),
- ("Masks", "Say what each mask type is.", [about(MASKS_INTRO), self.masks_box]),
+ ("Axes", "What each image dimension is, the pixel size and the image type.",
+ [about(AXES_INTRO), self.axis_box, self.quick_card, self.type_line.widget]),
+ ("Channels", "Name what you stained, then tag each folder's channels.",
+ [about(CHANNELS_INTRO), self.channel_labels.widget, self.channel_grid.widget]),
+ ("Masks", "What each mask type is; after a run, how the types relate.",
+ [about(MASKS_INTRO), self.masks_card, self.masks_box, self.mask_step_card]),
+ ("Design", "Conditions, biological repeats and comparisons.",
+ [about(DESIGN_INTRO), self.design_step_card, self.design_edit,
+ self.consistency_slot]),
("Check and save", "Check your experiment, confirm each replicate, then save.",
[about(CHECK_INTRO), self.fold_bar, self.check_waiting, self.check_card,
self.overview_fold]),
- ("Run", "Read the images one plane at a time and write the histogram table.",
+ ("Run", "Compute the histogram table; then check and compare the groups.",
self._run_page()),
- ("Describe the masks", "Optional: how the mask types relate.",
- [self.mask_step_card]),
] # fmt: skip
waits = self._step_waits()
return [
@@ -140,16 +163,10 @@ def _replicates_card(self) -> widgets.Widget:
def _step_waits(self) -> dict:
"""Return, by step (0-based), why it cannot be answered yet (HTML, empty when it can)."""
- after_check = self._wait_for_check
return {
0: self._consistency_note,
- 2: after_check,
- 3: after_check,
- 4: self._wait_for_references,
- 5: self._wait_for_channels,
- 6: self._wait_for_channels,
- 7: self._wait_for_tags,
- 11: self._wait_for_mask_step,
+ 2: self._wait_for_channels,
+ DESIGN_STEP: self._wait_for_check,
}
def _consistency_note(self) -> str:
@@ -158,33 +175,26 @@ def _consistency_note(self) -> str:
return ""
return ui.status(
"info",
- "The consistency warnings are on Replicates.",
- ["They depend on which folders are one replicate: pair the folders there first."],
+ "The consistency warnings are on Design.",
+ ["They depend on which folders are one replicate: see the pairing there first."],
)
def _wait_for_check(self) -> str:
if self.draft is not None:
return ""
- return ui.status("info", "Waiting for step 1.", ["Check the experiment folder first."])
-
- def _wait_for_references(self) -> str:
- if self.draft is not None and len(self.draft.conditions) < 2:
- return ui.status("ok", "There is one condition: nothing to compare.")
- return self._wait_for_check()
+ return ui.status("info", "Waiting for step 1.", ["Read the image headers first (Images)."])
def _wait_for_channels(self) -> str:
- return "" if self._channels_key is not None else self.channels_waiting.value
-
- def _wait_for_tags(self) -> str:
- waiting = self._wait_for_channels()
- if waiting or self.channel_labels.confirmed or self._stains_not_applicable:
- return waiting
- return ui.status("info", "Waiting for the stains.", ["Confirm the stains first (Stains)."])
-
- def _wait_for_mask_step(self) -> str:
- if ui.is_visible(self.mask_step_card):
- return ""
- return ui.status("info", "Not offered.", ["The output folder is not shown."])
+ """Say what the channels wait for: the folder check, the axis roles, the image type."""
+ if self._channels_key is None:
+ return self.channels_waiting.value
+ if not self.type_line.complete:
+ return ui.status(
+ "info",
+ "Waiting for the image type.",
+ ["Say on Axes (step 2) what the channels hold: a folder is tagged after that."],
+ )
+ return ""
# Hooks -------------------------------------------------------------------------------
@@ -249,7 +259,7 @@ def _toggle_confirmation(self) -> None:
self._refresh_quick_axes()
def _refresh_type_rows(self) -> None:
- """Open the image-type step for the folders the channel step opened, or close it."""
+ """Open the image type for the folders the channel step opened, or close it."""
rows = getattr(self, "type_rows", None)
if rows is None:
return
@@ -259,6 +269,7 @@ def _refresh_type_rows(self) -> None:
rows.open(self._channel_layouts)
else:
rows.refresh()
+ self.type_line.refresh()
def _refresh_channels(self) -> None:
super()._refresh_channels()
diff --git a/tests/test_notebook_axes_quick.py b/tests/test_notebook_axes_quick.py
index 463e8e6..fc64d0d 100644
--- a/tests/test_notebook_axes_quick.py
+++ b/tests/test_notebook_axes_quick.py
@@ -77,7 +77,7 @@ def test_an_unchanged_reopen_offers_the_run_s_roles_and_moves_on(exp): # noqa:
assert "the earlier run of this experiment recorded" in nb.quick_text.value
nb.quick_confirm.click()
assert nb.confirmed_mapping == first.confirmed_mapping
- assert nb._stepper.current == 10 # every answer is saved: the run is next
+ assert nb._stepper.current == 6 # every answer is saved: the run is next
def test_a_role_edited_after_the_offer_is_not_confirmed_by_the_old_offer(exp): # noqa: F811
diff --git a/tests/test_notebook_check_save.py b/tests/test_notebook_check_save.py
index cff9c67..6d31ab1 100644
--- a/tests/test_notebook_check_save.py
+++ b/tests/test_notebook_check_save.py
@@ -108,10 +108,13 @@ def answer_all(nb):
question = nb.mask_card.questions.get("Nuclei")
if question is not None and question.choice.value is None:
question.choice.value = "instances"
- if nb.reference_boxes["drug"].value is None:
+ if nb._chosen_reference("drug") is None:
nb.reference_boxes["drug"].value = "ctrl"
- if nb.reference_boxes["ctrl"].value is None:
+ if nb._chosen_reference("ctrl") is None and nb.reference_boxes["ctrl"].value is None:
nb.reference_boxes["ctrl"].value = ""
+ if getattr(nb, "_stepper", None) is not None and not nb._design_confirmed():
+ # The stepper's Design step: the names and "no comparison" as shown, in one click.
+ nb.design_confirm.click()
for toggle in list(nb.same_way.values()):
if not toggle.value:
toggle.button.click()
diff --git a/tests/test_notebook_compact.py b/tests/test_notebook_compact.py
index f19968a..ab2c001 100644
--- a/tests/test_notebook_compact.py
+++ b/tests/test_notebook_compact.py
@@ -56,10 +56,10 @@ def test_the_strip_is_short_names_and_the_page_keeps_the_full_title(exp): # noq
assert "mb-compact" in st.widget._dom_classes
names = [re.sub(r"^(✓ )?\d+ ", "", chip.description).split(" · ")[0] for chip in st.chips]
assert names == [CHIPS.get(step.title, step.title) for step in st.steps]
- assert names[0] == "Images" and names[9] == "Save" and names[11] == "Describe masks"
+ assert names == ["Images", "Axes", "Channels", "Masks", "Design", "Save", "Run"]
assert sum(len(chip.description) for chip in st.chips) < 150 # one row at 1366 px
- head = st.pages[9].children[1].children[0].value
- assert "Step 10 of 12 · Check and save" in head and "mb-purpose" in head
+ head = st.pages[5].children[1].children[0].value
+ assert "Step 6 of 7 · Check and save" in head and "mb-purpose" in head
def test_each_page_is_a_title_line_and_one_column_that_never_scrolls_by_itself(exp): # noqa: F811
@@ -141,7 +141,7 @@ def test_the_run_log_is_text_in_the_page_not_a_box_that_scrolls(exp): # noqa: F
def test_explanations_are_folded_behind_about_this_step(exp): # noqa: F811
nb = stepper(*exp)
- for index in (0, 1, 8, 9):
+ for index in range(6):
first = nb._stepper.pages[index].children[1].children[1].children[1]
assert first.value.startswith(
"About this step
"
@@ -169,6 +169,7 @@ def test_the_folders_are_one_card_of_three_labelled_rows(exp): # noqa: F811
assert _holds(page, box)
for card in (nb.folder_card, nb.output_card, nb.masks_input_card):
assert not _holds(page, card) # their help paragraphs are under About this step
+ assert not _holds(page, nb.masks_card) # what the check paired is on Masks
labels = [w.value for w in field_rows([("Images", []), ("Masks (optional)", [])]).children[::2]]
assert [_text(v) for v in labels] == ["Images", "Masks (optional)"]
@@ -227,7 +228,7 @@ def test_a_refusal_is_one_sentence_with_the_files_and_the_fix_on_demand():
def test_the_run_step_shows_only_what_is_missing_and_folds_the_rest(exp): # noqa: F811
nb = stepper(*exp)
- page = nb._stepper.pages[10]
+ page = nb._stepper.pages[6]
assert ui.is_visible(nb.readiness_card) and nb.run_button.disabled
for fold, inner in (
(nb.settings_fold, nb.settings_card),
@@ -245,7 +246,7 @@ def test_the_run_step_shows_only_what_is_missing_and_folds_the_rest(exp): # noq
def test_compare_groups_appears_collapsed_after_a_run_for_that_run_s_table(exp): # noqa: F811
nb = _ran(exp)
- page = nb._stepper.pages[10]
+ page = nb._stepper.pages[6]
assert ui.is_visible(nb.compare_fold) and nb.compare_fold.selected_index is None
assert ui.is_visible(nb.checks_fold) and ui.is_visible(nb.run_report_fold)
assert not ui.is_visible(
diff --git a/tests/test_notebook_mask_step.py b/tests/test_notebook_mask_step.py
index 488a122..6f9ace9 100644
--- a/tests/test_notebook_mask_step.py
+++ b/tests/test_notebook_mask_step.py
@@ -78,9 +78,7 @@ def test_the_button_is_on_step_3_and_waits_for_a_run_that_recorded_masks(tmp_pat
assert nb.mask_step_card in page.children[1].children
assert ui.is_visible(nb.mask_step_card)
assert nb.mask_step_button.disabled
- assert "Available when the output folder holds a run that recorded masks" in (
- nb.mask_step_hint.value
- )
+ assert "Available after a run that recorded masks" in (nb.mask_step_hint.value)
assert nb._on_describe_masks() is None
assert nb.run().written
assert not nb.mask_step_button.disabled
@@ -289,7 +287,7 @@ def test_a_reopened_notebook_follows_the_output_folder_of_step_1(tmp_path):
assert not nb.mask_step_button.disabled
nb.output.value = str(tmp_path / "elsewhere")
assert nb.mask_step_button.disabled
- assert "Available when the output folder holds" in nb.mask_step_hint.value
+ assert "Available after a run that recorded masks" in nb.mask_step_hint.value
nb.output.value = str(output_of(root))
assert not nb.mask_step_button.disabled
diff --git a/tests/test_notebook_masks.py b/tests/test_notebook_masks.py
index 4b777b5..eba32ee 100644
--- a/tests/test_notebook_masks.py
+++ b/tests/test_notebook_masks.py
@@ -113,7 +113,7 @@ def test_mask_errors_are_shown_on_the_card_and_block_the_run(tmp_path):
assert "some masks cannot be used" in nb.masks_status.value
assert "Action required" in nb.folder_status.value
confirm_everything(nb)
- assert still_to_do(nb) == "fix any mask errors (step 1, Masks)"
+ assert still_to_do(nb) == "fix any mask errors (Masks)"
assert nb.run() is None
@@ -151,7 +151,7 @@ def test_plain_stack_masks_get_their_axes_stated_never_guessed(tmp_path):
assert offered[:3] == ["", "TYX", "ZYX"] and "YX" not in offered
assert "Cells (axes in the files: IYX)" in nb.mask_axes_box.children[1].value
confirm_everything(nb)
- assert still_to_do(nb) == "fix any mask errors (step 1, Masks)"
+ assert still_to_do(nb) == "fix any mask errors (Masks)"
choice.value = "TYX" # chosen, not yet applied
assert nb.preflight.masks.files[0].axes == ""
assert still_to_do(nb).startswith("check the masks again")
@@ -182,7 +182,7 @@ def test_a_2d_mask_of_a_time_lapse_is_refused_without_a_question(tmp_path, named
assert "mask planes mismatch" in nb.masks_issues.value
assert "same T as their image" in nb.masks_issues.value
assert "Cells" not in nb._mask_choices and nb.mask_axes_box.children == ()
- assert still_to_do(nb) == "fix any mask errors (step 1, Masks)"
+ assert still_to_do(nb) == "fix any mask errors (Masks)"
assert nb.run_button.disabled
diff --git a/tests/test_notebook_owner_fixes.py b/tests/test_notebook_owner_fixes.py
index 04101a2..ae5f12a 100644
--- a/tests/test_notebook_owner_fixes.py
+++ b/tests/test_notebook_owner_fixes.py
@@ -78,9 +78,12 @@ def test_choosing_a_reference_keeps_the_dropdown_and_shows_the_choice_at_once(ex
assert boxes["drug"].value == "ctrl"
assert nb.draft_answers.conditions["drug"].reference == "ctrl" # saved as before
assert "the reference of drug" in nb.reference_notes["ctrl"].value
- nb.reference_boxes["ctrl"].value = ""
+ # "no comparison" is the proposal the Design step shows: saved by its one click, not before.
+ assert boxes["ctrl"].value == "" and "ctrl" not in nb.draft_answers.conditions
+ nb.design_confirm.click()
assert nb.reference_boxes["ctrl"] is boxes["ctrl"] and boxes["ctrl"].value == ""
assert nb.draft_answers.conditions["ctrl"].reference == ""
+ assert nb.draft_answers.conditions["drug"].reference == "ctrl" # the choice is kept
def test_a_refused_reference_is_shown_and_the_dropdown_goes_back_in_place(exp): # noqa: F811
@@ -91,7 +94,8 @@ def test_a_refused_reference_is_shown_and_the_dropdown_goes_back_in_place(exp):
nb.reference_boxes["ctrl"].value = "drug" # a loop: refused by the draft's own rule
assert "This answer was not taken" in nb.references_status.value
assert nb.reference_boxes["ctrl"] is boxes["ctrl"]
- assert boxes["ctrl"].value is None and boxes["drug"].value == "ctrl"
+ # Back to the proposal the Design step shows ("no comparison"), which is not saved.
+ assert boxes["ctrl"].value == "" and boxes["drug"].value == "ctrl"
assert nb.draft_answers.conditions.get("ctrl") is None or (
nb.draft_answers.conditions["ctrl"].reference is None
)
diff --git a/tests/test_notebook_seven_steps.py b/tests/test_notebook_seven_steps.py
new file mode 100644
index 0000000..c3423a2
--- /dev/null
+++ b/tests/test_notebook_seven_steps.py
@@ -0,0 +1,407 @@
+"""The 7-step flow of the shipped notebook (owner, 10 November 2026, items (o), (p) and (q)).
+
+- the image type is asked with the axes, in one line pre-filled from the image headers and
+ recorded only by a click; each folder on demand; folders that differ are said at once;
+- the conditions, the replicates and "compared against" are one grid with one confirmation;
+- the mask step (*Describe the masks*) is on the Masks step and waits, visibly, for a run.
+
+The proposals are never answers: nothing is saved before the click. For the same answers the
+stepper publishes the bytes the stacked pages publish.
+"""
+
+import re
+
+import numpy as np
+import pytest
+import tifffile
+
+from channel_images import write_channels, write_rgb
+from morphobricks_preprocessing.description_file import DESCRIPTION_FILENAME
+from morphobricks_preprocessing.design import DESIGN_FILENAME
+from test_notebook_check_save import add_folder, answer_all, exp, opened, tag # noqa: F401
+
+pytest.importorskip("ipywidgets")
+pytest.importorskip("matplotlib")
+
+from morphobricks_preprocessing import notebook_style as ui # noqa: E402
+from morphobricks_preprocessing.channel_labels import UnitLayout # noqa: E402
+from morphobricks_preprocessing.draft_answers import DraftAnswers # noqa: E402
+from morphobricks_preprocessing.notebook_channels_type import ( # noqa: E402
+ ImageTypeColumn,
+ ImageTypeRows,
+)
+from morphobricks_preprocessing.notebook_check_view import TODO_SHOWN, todo_html # noqa: E402
+from morphobricks_preprocessing.notebook_design_step import NOT_CONFIRMED # noqa: E402
+from morphobricks_preprocessing.notebook_image_type_line import ( # noqa: E402
+ MIXED,
+ WAITING,
+ ImageTypeLine,
+)
+from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402
+from test_notebook_axes_quick import stepper # noqa: E402
+
+FOLDERS = ["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/rep2"]
+
+
+def _text(html):
+ return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html)).strip()
+
+
+def _types(nb):
+ return {key: unit.image_type for key, unit in nb.draft_answers.units.items()}
+
+
+def _colour(tmp_path, colour=FOLDERS, *, confirm=True):
+ """Return a stepper on four folders, those of ``colour`` holding colour (RGB) pictures."""
+ root = tmp_path / "exp"
+ for i, folder in enumerate(FOLDERS):
+ if folder in colour:
+ write_rgb(root / folder / "a.tif", seed=i + 1)
+ else:
+ write_channels(root / folder / "a.tif", ["DAPI", "Actin", "Ch3"], ["blobs"] * 3, seed=i)
+ nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out"))
+ nb.steps()
+ nb.check_button.click()
+ if confirm:
+ nb.quick_confirm.click()
+ return nb
+
+
+# --- (p) the image type, with the axes ----------------------------------------------------------
+
+
+def test_the_image_type_waits_for_the_axis_roles_and_says_so(exp): # noqa: F811
+ nb = stepper(*exp)
+ line = nb.type_line
+ assert _text(line.line.value) == WAITING
+ assert not ui.is_visible(line.form) and not ui.is_visible(line.per_folder)
+ nb.quick_confirm.click()
+ assert ui.is_visible(line.form) and ui.is_visible(line.per_folder)
+ assert line.per_folder.selected_index is None # each folder on demand
+
+
+def test_the_header_s_type_is_a_proposal_saved_only_by_the_click(exp): # noqa: F811
+ nb = stepper(*exp)
+ nb.quick_confirm.click()
+ line = nb.type_line
+ assert line.choice.value == "channels" and line.suggestion() == "channels"
+ said = _text(line.line.value)
+ assert "Proposed from the image headers: separate channels in all 4 folders" in said
+ assert "Not saved until you click Use for all folders" in said
+ assert set(_types(nb).values()) <= {None} and not line.complete # nothing was applied
+ assert not nb._stepper.states()[1].done
+ assert ui.is_visible(line.apply) and not line.apply.disabled
+ line.apply.click()
+ assert set(_types(nb).values()) == {"channels"} and len(_types(nb)) == 4 and line.complete
+ assert "Image type: separate channels in all 4 folders." in _text(line.line.value)
+ assert not ui.is_visible(line.apply) # nothing left to record
+ assert nb._stepper.states()[1].done
+
+
+def test_one_click_for_all_folders_records_what_each_folder_s_choice_records(exp, tmp_path): # noqa: F811
+ root, masks, out = exp
+ one = stepper(root, masks, out)
+ one.quick_confirm.click()
+ one.type_line.apply.click()
+ each = stepper(root, masks, tmp_path / "each")
+ each.quick_confirm.click()
+ for radio in list(each.channel_types.radios.values()):
+ radio.value = "channels"
+ assert one.draft_answers.units == each.draft_answers.units
+ assert one.draft_answers.channels == each.draft_answers.channels
+
+
+def test_headers_that_all_say_colour_propose_colour_and_the_click_confirms_it(tmp_path):
+ nb = _colour(tmp_path)
+ line = nb.type_line
+ assert line.choice.value == "rgb" and "colour (RGB) in all 4 folders" in _text(line.line.value)
+ assert not nb._stains_not_applicable and set(_types(nb).values()) <= {None}
+ line.apply.click()
+ assert set(_types(nb).values()) == {"rgb"} and nb._stains_not_applicable
+ assert all(unit.channels == ("R", "G", "B") for unit in nb.draft_answers.units.values())
+
+
+class _Store:
+ """The answers of a card, kept in memory (the cards' :class:`AnswerStore`)."""
+
+ def __init__(self):
+ self.saved = DraftAnswers("0" * 64, "spec")
+
+ def answers(self):
+ return self.saved
+
+ def update(self, new):
+ self.saved = new
+ return True
+
+ def refusal(self):
+ return ""
+
+ def used_channels(self):
+ return ()
+
+
+def _line(colour):
+ """Return the image-type line over four units, those of ``colour`` with a colour header."""
+ layouts = tuple(
+ UnitLayout((c, r, "single"), "a.tif", 3, (None,) * 3, rgb=(c, r) in colour)
+ for c in ("ctrl", "drug")
+ for r in ("rep1", "rep2")
+ )
+ types = ImageTypeColumn(_Store(), lambda: line.rows.refresh() or line.refresh())
+ line = ImageTypeLine(types, ImageTypeRows(types))
+ line.rows.open(layouts)
+ line.refresh()
+ return line
+
+
+def test_headers_that_differ_propose_nothing_and_open_each_folder():
+ line = _line({("drug", "rep2")})
+ assert line.suggestion() is None and line.choice.value is None
+ said = _text(line.line.value)
+ assert "The image headers differ: separate channels in 3 folders, colour (RGB) in 1" in said
+ assert "Nothing is proposed" in said and "R_HETEROGENEOUS_LAYOUT_NOT_ENABLED" in said
+ assert line.per_folder.selected_index == 0 # the menu is open
+ assert line.apply.disabled and "choose a type in the box" in _text(line.blocker.value)
+ assert not line.types.store.saved.units # nothing was applied
+
+
+def test_folders_answered_differently_are_said_at_once_with_the_refusal_to_come():
+ line = _line({("drug", "rep2")})
+ for layout in line.types.layouts:
+ if not layout.rgb:
+ line.types.radios[layout.key].value = "channels"
+ line.types.confirm_header([u.key for u in line.types.layouts if u.rgb]) # the pre-tick
+ said = _text(line.line.value)
+ assert "Image types differ: separate channels in 3 folders, colour (RGB) in 1" in said
+ assert MIXED in said and line.complete
+ one = _line(set())
+ key = one.types.layouts[0].key
+ one.types.radios[key].value = "channels"
+ assert "Not finished: separate channels in 1 folder, 3 not answered" in _text(one.line.value)
+
+
+def test_colour_pictures_beside_separate_channels_stay_refused_at_the_axes(tmp_path):
+ nb = _colour(tmp_path, colour=["drug/rep2"], confirm=False)
+ assert nb.mapping is None and nb.confirmed_mapping is None # as before: no roles to confirm
+ assert "No axis roles can be suggested" in nb.axis_box.children[0].value
+ assert _text(nb.type_line.line.value) == WAITING and not ui.is_visible(nb.type_line.form)
+ assert nb.run_button.disabled and not nb._stepper.states()[1].done
+
+
+def test_replacing_answers_already_given_asks_first(exp): # noqa: F811
+ nb = stepper(*exp)
+ nb.quick_confirm.click()
+ line = nb.type_line
+ line.apply.click()
+ before = nb.draft_answers
+ line.choice.value = "rgb" # three channels: colour can be declared (the run checks headers)
+ assert ui.is_visible(line.apply)
+ line.apply.click()
+ assert ui.is_visible(line.replace_box) and nb.draft_answers == before # nothing written yet
+ assert "4 folders answered otherwise" in _text(line.replace_text.value)
+ line.replace_no.click()
+ assert not ui.is_visible(line.replace_box) and nb.draft_answers == before
+ line.apply.click()
+ line.replace_yes.click()
+ assert set(_types(nb).values()) == {"rgb"}
+ # The per-folder rows say what the run will do with a type the header contradicts.
+ assert "The header does not say colour" in repr(nb.type_rows.rows.children)
+
+
+def test_a_type_the_images_cannot_have_changes_nothing(tmp_path):
+ root = tmp_path / "exp"
+ for folder in ("ctrl/rep1", "drug/rep1"):
+ (root / folder).mkdir(parents=True)
+ data = np.zeros((2, 16, 16), np.uint8)
+ tifffile.imwrite(root / folder / "a.tif", data, metadata={"axes": "CYX"})
+ nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out"))
+ nb.steps()
+ nb.check_button.click()
+ nb.quick_confirm.click()
+ line = nb.type_line
+ line.choice.value = "rgb"
+ line.apply.click()
+ assert "Nothing was changed" in line.message.value
+ assert "a colour image has 3 channels" in line.message.value
+ assert set(_types(nb).values()) <= {None}
+
+
+def test_the_channels_step_waits_for_the_image_type_and_says_where(exp): # noqa: F811
+ nb = stepper(*exp)
+ nb._stepper.go(2)
+ assert "Waiting for the axis roles" in nb._stepper.waits[2].value
+ nb.quick_confirm.click()
+ nb._stepper.go(2)
+ assert "Waiting for the image type" in nb._stepper.waits[2].value
+ assert "Axes (step 2)" in nb._stepper.waits[2].value
+ nb.type_line.apply.click()
+ nb._stepper.go(2)
+ assert nb._stepper.waits[2].value == ""
+
+
+# --- (q) the design: one grid, one click --------------------------------------------------------
+
+
+def test_the_design_is_proposed_and_nothing_is_saved_before_the_click(exp): # noqa: F811
+ nb = stepper(*exp)
+ nb.quick_confirm.click()
+ assert ui.is_visible(nb.design_step_card) and ui.is_visible(nb.design_confirm)
+ cells = nb._design_cells
+ assert "ctrl" in cells[("condition", "ctrl")].value
+ assert "proposed" in cells[("condition", "ctrl")].value
+ assert "rep1" in cells[("repeat", "rep1")].value
+ assert _text(cells[("drug", "rep2")].value) == "rep2 1 image"
+ assert [box.value for box in nb.reference_boxes.values()] == ["", ""] # no comparison
+ assert all(None not in [v for _, v in box.options] for box in nb.reference_boxes.values())
+ # A proposal is not an answer: no ID is adopted and no comparison is saved.
+ assert not nb.draft_answers.conditions and not nb._ids_all_confirmed()
+ assert not nb._design_confirmed() and not nb._stepper.states()[4].done
+ assert "not confirmed" in nb._stepper.states()[4].text
+
+
+def test_the_save_waits_for_the_design_s_click_and_says_so(exp): # noqa: F811
+ nb = stepper(*exp)
+ nb.quick_confirm.click()
+ tag(nb)
+ nb.mask_card.questions["Nuclei"].choice.value = "instances"
+ for toggle in list(nb.same_way.values()):
+ toggle.button.click()
+ assert nb.save_button.disabled and not nb.save_experiment()
+ assert NOT_CONFIRMED in _text(nb.todo.value)
+ assert "is compared against (choose" not in nb.todo.value # the one click answers them
+ assert "1 answer still missing" in _text(nb.save_blocker.value)
+ nb.design_confirm.click()
+ assert nb.todo.value == "" and not nb.save_button.disabled
+ assert nb.save_experiment()
+
+
+def test_the_click_adopts_every_name_and_no_comparison_as_shown(exp): # noqa: F811
+ nb = stepper(*exp)
+ nb.quick_confirm.click()
+ nb.reference_boxes["drug"].value = "ctrl" # a choice in the grid is saved at once
+ assert nb.draft_answers.conditions["drug"].reference == "ctrl"
+ table, boxes = nb.design_table.children, dict(nb.reference_boxes)
+ nb.design_confirm.click()
+ answers = nb.draft_answers
+ assert answers.conditions["ctrl"].reference == "" and answers.conditions["ctrl"].id == "ctrl"
+ assert (
+ answers.conditions["drug"].reference == "ctrl" and answers.conditions["drug"].id == "drug"
+ )
+ assert {k: v.id for k, v in answers.repeats.items()} == {"rep1": "rep1", "rep2": "rep2"}
+ assert nb._design_confirmed() and nb._stepper.states()[4].done
+ assert not ui.is_visible(nb.design_confirm) and ui.is_visible(nb.design_change)
+ assert "Design confirmed: 2 conditions × 2 biological repeats" in _text(nb.design_result.value)
+ assert "proposed" not in nb._design_cells[("condition", "ctrl")].value
+ # The grid is updated in place: the same cells and the same dropdowns.
+ assert nb.design_table.children == table and dict(nb.reference_boxes) == boxes
+ assert nb._stepper.states()[4].text.startswith("ctrl, drug → ctrl × rep1, rep2")
+ nb.design_change.click() # the names can be taken back to edit them
+ assert not nb._design_confirmed() and ui.is_visible(nb.design_confirm)
+ assert not nb.id_inputs[("conditions", "ctrl")].disabled
+
+
+def test_a_folder_that_cannot_be_a_name_blocks_the_click_with_a_visible_reason(tmp_path):
+ root, masks = tmp_path / "exp", tmp_path / "masks"
+ for i, folder in enumerate(["NA/rep1", "NA/rep2", "drug/rep1", "drug/rep2"]):
+ add_folder(root, masks, folder, i + 1)
+ nb = stepper(root, masks, tmp_path / "out")
+ nb.quick_confirm.click()
+ assert nb.design_confirm.disabled
+ assert "1 name missing (NA)" in _text(nb.design_blocker.value)
+ assert "Edit names or pairing" in _text(nb.design_blocker.value)
+ assert nb.design_edit.selected_index == 0 # opened: that is where the name is typed
+ assert "needs a name" in nb._design_cells[("condition", "NA")].value
+ nb.id_inputs[("conditions", "NA")].value = "untreated"
+ assert not nb.design_confirm.disabled and nb.design_blocker.value == ""
+ assert "untreated" in nb._design_cells[("condition", "NA")].value
+ assert "folder NA" in nb._design_cells[("condition", "NA")].value
+ nb.design_confirm.click()
+ assert nb.draft_answers.conditions["NA"].id == "untreated" and nb._design_confirmed()
+
+
+def test_a_refused_name_confirms_nothing_and_lists_the_refusal(exp): # noqa: F811
+ nb = stepper(*exp)
+ nb.quick_confirm.click()
+ nb.id_inputs[("conditions", "drug")].value = "ctrl" # the ID of the other condition
+ nb.design_confirm.click()
+ assert "nothing was confirmed" in _text(nb.design_result.value)
+ assert not nb._design_confirmed() and not nb.draft_answers.conditions
+ assert nb.design_edit.selected_index == 0
+
+
+def test_re_pairing_shows_in_the_grid_and_asks_for_the_click_again(tmp_path):
+ root, masks = tmp_path / "exp", tmp_path / "masks"
+ for i, folder in enumerate(["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/day2"]):
+ add_folder(root, masks, folder, i + 1)
+ nb = stepper(root, masks, tmp_path / "out")
+ nb.quick_confirm.click()
+ assert "no folder" in nb._design_cells[("drug", "rep2")].value
+ nb.design_confirm.click()
+ assert nb._design_confirmed()
+ (dropdown,) = [
+ row.children[2]
+ for row in nb.pairing_grid.children[1:]
+ if "day2" in row.children[1].children[0].value
+ ]
+ dropdown.value = "rep2"
+ assert ("drug", "day2") not in nb._design_cells
+ assert _text(nb._design_cells[("drug", "rep2")].value) == "day2 1 image"
+ assert nb.draft_answers.pairing == {("drug", "day2"): "rep2"}
+
+
+def test_one_condition_has_no_comparison_column(tmp_path):
+ root, masks = tmp_path / "exp", tmp_path / "masks"
+ for i, folder in enumerate(["ctrl/rep1", "ctrl/rep2"]):
+ add_folder(root, masks, folder, i + 1)
+ nb = stepper(root, masks, tmp_path / "out")
+ nb.quick_confirm.click()
+ assert "Compared against" not in repr(nb.design_table.children)
+ nb.design_confirm.click()
+ assert nb._design_confirmed() and not nb.draft_answers.conditions.get("ctrl").reference
+
+
+def test_the_same_answers_publish_the_bytes_the_stacked_pages_publish(exp, tmp_path): # noqa: F811
+ root, masks, out = exp
+ steps = stepper(root, masks, out)
+ steps.quick_confirm.click()
+ steps.type_line.apply.click() # separate channels, for all folders
+ answer_all(steps) # drug against ctrl; the Design click records the rest as shown
+ assert steps.save_experiment()
+ pages = opened(root, masks, tmp_path / "pages") # the pages other bricks embed
+ answer_all(pages) # the same answers, each given by hand
+ assert pages.save_experiment()
+ for name in (DESCRIPTION_FILENAME, DESIGN_FILENAME):
+ assert (out / name).read_bytes() == (tmp_path / "pages" / name).read_bytes(), name
+
+
+# --- (o) masks: what each is, then the mask step once a run recorded them ----------------------
+
+
+def test_the_mask_step_is_on_masks_and_waits_visibly_for_a_run(exp): # noqa: F811
+ nb = stepper(*exp)
+ nb.quick_confirm.click()
+ assert nb.mask_step_button.disabled
+ assert "Available after a run that recorded masks" in _text(nb.mask_step_hint.value)
+ assert not ui.is_visible(nb.masks_hint)
+ answer_all(nb)
+ assert nb.save_experiment() and nb.run().written
+ assert not nb.mask_step_button.disabled # offered once the run recorded the masks
+ assert ui.is_visible(nb.masks_hint) and "Masks (step 4)" in nb.masks_hint.value
+ assert "relationships described" not in nb._stepper.states()[3].text
+ assert nb._on_describe_masks() is not None
+ assert "relationships described" in nb._stepper.states()[3].text
+
+
+# --- shorter, never hidden ----------------------------------------------------------------------
+
+
+def test_a_long_list_of_missing_answers_is_folded_and_complete():
+ lines = [f"answer {i} " for i in range(TODO_SHOWN + 5)]
+ html = todo_html([], lines)
+ shown, folded = html.split("")
+ assert f"Still to answer before saving: {len(lines)}." in shown
+ assert shown.count("answer ") == TODO_SHOWN - 2 and "and 7 more" in folded
+ assert all(f"answer {i} <b>" in html for i in range(len(lines))) # each one is there
+ short = todo_html([], lines[:TODO_SHOWN])
+ assert "" not in short and short.count("answer ") == TODO_SHOWN
diff --git a/tests/test_notebook_stains_na.py b/tests/test_notebook_stains_na.py
index fe77517..7777fcf 100644
--- a/tests/test_notebook_stains_na.py
+++ b/tests/test_notebook_stains_na.py
@@ -1,7 +1,8 @@
"""An all-RGB experiment needs no channel label (issue 240, experiment-v1 section 4.4b).
R, G and B are the fixed channel IDs of a unit the user declared ``rgb``; no label applies. So
-the Stains step is not applicable when every folder's saved image type is ``rgb``, and Check and
+naming the stains (on the Channels step) is not applicable when every folder's saved image type
+is ``rgb``, and Check and
save completes and saves without one. Only the user's answer counts: a header's colour pre-tick,
a mixed experiment or an unanswered image type keeps the labels required.
"""
@@ -25,8 +26,8 @@
from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402
FOLDERS = ["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/rep2"]
-STAINS = 6 # step 7, 0-based
-TAGS = 7
+CHANNELS = 2 # step 3, 0-based: the stains and the tags
+STAINS = TAGS = CHANNELS
def _open(tmp_path, *, channel_folders=()):
@@ -54,7 +55,7 @@ def _answer(nb, *, skip=()):
types.radios[layout.key].value = "channels"
types.confirm_header([u.key for u in types.layouts if types.proposed(u) and u.key not in skip])
nb.reference_boxes["drug"].value = "ctrl"
- nb.reference_boxes["ctrl"].value = ""
+ nb.design_confirm.click() # the names, and "no comparison" for ctrl, as the Design step shows
for toggle in list(nb.same_way.values()):
if not toggle.value:
toggle.button.click()
@@ -68,17 +69,16 @@ def test_an_all_rgb_experiment_completes_stains_and_check_and_save_without_a_lab
nb = _open(tmp_path)
_answer(nb)
stains = _state(nb, STAINS)
- assert stains.done and stains.note == "not applicable"
- assert stains.text == "not applicable: every folder is colour (RGB)"
- assert "not applicable" in nb._stepper.chips[STAINS].description
- assert "mb-chip-noted" in nb._stepper.chips[STAINS]._dom_classes
+ assert stains.done
+ assert stains.text.startswith("no stains to name: every folder is colour (RGB) · every channel")
+ assert "mb-chip-done" in nb._stepper.chips[STAINS]._dom_classes
assert _state(nb, TAGS).done and not nb.channel_labels.confirmed
assert "Not applicable" in nb.channel_labels.na_note.value
assert not ui.is_visible(nb.channel_labels.body[0])
assert not ui.is_visible(nb.channel_intro) # "first name what you stained" would contradict
assert nb.todo.value == "" and not nb.save_button.disabled
assert nb.save_experiment()
- assert _state(nb, 9).done
+ assert _state(nb, 5).done
def test_the_saved_file_declares_exactly_r_g_b_and_the_rgb_image_type(tmp_path):
@@ -103,7 +103,7 @@ def test_the_header_pre_tick_alone_does_not_make__stains_not_applicable(tmp_path
nb = _open(tmp_path) # the headers say colour; nothing is answered
assert not nb._stains_not_applicable
stains = _state(nb, STAINS)
- assert not stains.done and stains.text == "not confirmed"
+ assert not stains.done and stains.text == "stains not confirmed"
assert ui.is_visible(nb.channel_labels.body[0])
assert "Not applicable" not in nb.channel_labels.na_note.value
diff --git a/tests/test_notebook_stepper.py b/tests/test_notebook_stepper.py
index 01f9041..2366213 100644
--- a/tests/test_notebook_stepper.py
+++ b/tests/test_notebook_stepper.py
@@ -1,4 +1,4 @@
-"""The shipped notebook's stepper: 13 steps, one on screen at a time (PR 17a, 2026-11-09).
+"""The shipped notebook's stepper: 7 steps, one on screen at a time (owner, 2026-11-10).
Widgets are driven headless, as the user would: the strip's chips, Back and Next, and the
existing cards on each page. The stepper is pure composition, so the answers are those of the
@@ -24,20 +24,9 @@
)
from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402
-ORDER = [
- "Images found",
- "Axes",
- "Conditions",
- "Replicates",
- "Compared against",
- "Image type",
- "Stains",
- "Tag channels",
- "Masks",
- "Check and save",
- "Run",
- "Describe the masks",
-]
+ORDER = ["Images", "Axes", "Channels", "Masks", "Design", "Check and save", "Run"]
+"""What the files hold first (images, axes, channels, masks), what it means after (design)."""
+AXES, CHANNELS, MASKS, DESIGN, SAVE, RUN = range(1, 7)
def stepped(root, masks, out, *, axes=True):
@@ -130,7 +119,7 @@ def test_the_overview_restates_each_step_with_a_change_button():
assert stepper.current == 2
-# --- the notebook's 13 steps --------------------------------------------------------------------
+# --- the notebook's 7 steps ---------------------------------------------------------------------
def test_the_steps_are_in_the_decided_order_with_one_page_on_screen(exp): # noqa: F811
@@ -142,23 +131,32 @@ def test_the_steps_are_in_the_decided_order_with_one_page_on_screen(exp): # noq
nb._stepper.chips[1].click()
assert shown(nb) == 1 and kinds(nb)[0] == "done"
page = nb._stepper.pages[1]
- assert "Step 2 of 12 · Axes" in page.children[1].children[0].value
+ assert "Step 2 of 7 · Axes" in page.children[1].children[0].value
def test_the_pages_hold_the_existing_cards_in_the_new_places(exp): # noqa: F811
nb = stepped(*exp)
contains = lambda i, w: _holds(nb._stepper.pages[i], w) # noqa: E731
- assert contains(0, nb.check_button) and contains(0, nb.masks_card)
- assert contains(1, nb.axis_box) and contains(1, nb.calibration_card) # one step (owner)
- assert _holds(nb.axis_box, nb.calibration_card) and contains(1, nb.quick_card)
- assert contains(2, nb.condition_rows) and not contains(2, nb.replicate_rows)
- assert contains(3, nb.design_card) and contains(3, nb.replicate_rows)
- assert contains(4, nb.references_card) and contains(5, nb.type_rows.widget)
- assert contains(6, nb.channel_labels.widget) and contains(7, nb.channel_grid.widget)
- assert contains(8, nb.masks_box) and contains(9, nb.check_card)
- assert contains(9, nb.overview_box) and contains(10, nb.run_button)
- assert contains(11, nb.mask_step_card) and not contains(10, nb.mask_step_card)
- # The names rows are split between steps 3 and 4, and still the names card's grid.
+ assert contains(0, nb.check_button) and contains(0, nb.scan_button)
+ # Axes: the roles, the pixel size and the image type (what the channel dimension holds).
+ assert contains(AXES, nb.axis_box) and contains(AXES, nb.calibration_card)
+ assert _holds(nb.axis_box, nb.calibration_card) and contains(AXES, nb.quick_card)
+ assert contains(AXES, nb.type_line.widget) and contains(AXES, nb.type_rows.widget)
+ # Channels: the stains, then the tags.
+ page = nb._stepper.pages[CHANNELS].children[1].children[1].children
+ assert list(page[-2:]) == [nb.channel_labels.widget, nb.channel_grid.widget]
+ # Masks: the masks paired, what each is, then the mask step (it needs the run's record).
+ page = nb._stepper.pages[MASKS].children[1].children[1].children
+ assert list(page[-3:]) == [nb.masks_card, nb.masks_box, nb.mask_step_card]
+ assert not contains(0, nb.masks_card) and not contains(RUN, nb.mask_step_card)
+ # Design: one grid, then the names and the pairing to edit, and the consistency warnings.
+ assert contains(DESIGN, nb.design_table) and contains(DESIGN, nb.design_confirm)
+ for card in (nb.condition_rows, nb.replicate_rows, nb.design_card):
+ assert _holds(nb.design_edit, card) and contains(DESIGN, card)
+ assert all(_holds(nb.design_table, box) for box in nb.reference_boxes.values())
+ assert contains(SAVE, nb.check_card) and contains(SAVE, nb.overview_box)
+ assert contains(RUN, nb.run_button) and contains(RUN, nb.compare_fold)
+ # The names rows are still the names card's grid (the pages other bricks embed).
(conditions,) = nb.condition_rows.children
assert "Condition" in conditions.children[0].children[0].value
assert list(nb.names_grid.children) == [conditions, *nb.replicate_rows.children]
@@ -170,12 +168,12 @@ def _holds(widget, target):
return any(_holds(child, target) for child in getattr(widget, "children", ()))
-def test_the_image_type_has_its_own_step_and_the_grid_no_type_column(exp): # noqa: F811
+def test_the_image_type_is_asked_with_the_axes_and_the_grid_has_no_type_column(exp): # noqa: F811
nb = stepped(*exp)
for row, text in zip(nb.channel_labels.rows, TAGS, strict=True):
row.id_box.value = text
nb.channel_labels.confirm.button.click()
- assert len(nb.channel_types.radios) == 4 # one per folder, on the image-type step
+ assert len(nb.channel_types.radios) == 4 # one per folder, under Set per folder…
assert not any(_holds(nb.channel_grid.widget, r) for r in nb.channel_types.radios.values())
assert "Not finished: 4 image type(s)" in nb.type_rows.status.value
key = next(iter(nb.channel_types.radios))
@@ -199,10 +197,10 @@ def test_moving_through_every_page_keeps_every_answer(exp): # noqa: F811
chip.click()
assert snapshot(nb) == before
assert nb.draft_answers.conditions["drug"].display_name == "Drug A"
- assert all(k in ("done", "noted") for k in kinds(nb)[1:9]) and kinds(nb)[0] == "current"
- assert kinds(nb)[9] == "todo" # not saved yet
+ assert all(k in ("done", "noted") for k in kinds(nb)[1:SAVE]) and kinds(nb)[0] == "current"
+ assert kinds(nb)[SAVE] == "todo" # not saved yet
assert nb.save_experiment()
- assert kinds(nb)[9] == "done"
+ assert kinds(nb)[SAVE] == "done"
def test_a_reopened_experiment_opens_on_the_axes_its_only_open_step(exp): # noqa: F811
@@ -210,15 +208,16 @@ def test_a_reopened_experiment_opens_on_the_axes_its_only_open_step(exp): # noq
answer_all(first)
assert first.save_experiment()
nb = stepped(*exp, axes=False)
- assert nb._stepper.current == 1 and shown(nb) == 1
+ assert nb._stepper.current == AXES and shown(nb) == AXES
nb.preview_checked.set(True)
nb.show_preview()
nb.confirm_button.click()
- assert all(k in ("done", "noted") for k in kinds(nb)[:10] if k != "current")
- assert "saved " in nb._stepper.chips[9].tooltip
+ assert all(k in ("done", "noted") for k in kinds(nb)[:RUN] if k != "current")
+ assert "saved " in nb._stepper.chips[SAVE].tooltip
+ assert nb._design_confirmed() and not ui.is_visible(nb.design_confirm) # as saved
# The saved reopening folds the cards; a page that shows one unfolds it.
assert nb._folded and not ui.is_visible(nb.design_card)
- nb._stepper.chips[3].click()
+ nb._stepper.chips[DESIGN].click()
assert not nb._folded and ui.is_visible(nb.design_card)
@@ -233,10 +232,11 @@ def test_a_changed_reopen_turns_the_affected_steps_orange(tmp_path):
nb = stepped(root, masks, out)
states = nb._stepper.states()
assert "since saved: 1 new, 0 gone" in states[0].text
- assert states[7].changed and states[9].changed # the new folder's tags; save again
- assert not states[2].changed and states[2].done # the conditions did not change
- assert kinds(nb)[7] == "changed" and kinds(nb)[9] == "changed"
- assert nb._stepper.current == 1 # landed on the axes: each session confirms them again
+ assert states[CHANNELS].changed and states[SAVE].changed # the new folder's tags; save again
+ assert states[AXES].changed and "image type: 1 to choose" in states[AXES].text
+ assert not states[DESIGN].changed and states[DESIGN].done # the names did not change
+ assert kinds(nb)[CHANNELS] == "changed" and kinds(nb)[SAVE] == "changed"
+ assert nb._stepper.current == AXES # landed on the axes: each session confirms them again
def test_consistency_follows_the_pairing_and_shows_only_when_it_warns(tmp_path):
@@ -244,10 +244,10 @@ def test_consistency_follows_the_pairing_and_shows_only_when_it_warns(tmp_path):
for i, folder in enumerate(["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/day2"]):
add_folder(root, masks, folder, i + 1)
nb = stepped(root, masks, out)
- assert _holds(nb._stepper.pages[3], nb.consistency_card)
+ assert _holds(nb._stepper.pages[DESIGN], nb.consistency_card)
assert not _holds(nb._stepper.pages[0], nb.consistency_card)
assert ui.is_visible(nb.consistency_slot) # rep2 and day2 are each in one condition only
- assert "on Replicates" in nb._stepper.waits[0].value # step 1 says where
+ assert "on Design" in nb._stepper.waits[0].value # step 1 says where
(dropdown,) = [
row.children[2]
for row in nb.pairing_grid.children[1:]
@@ -256,7 +256,7 @@ def test_consistency_follows_the_pairing_and_shows_only_when_it_warns(tmp_path):
dropdown.value = "rep2"
assert not ui.is_visible(nb.consistency_slot)
assert nb._stepper.waits[0].value == ""
- assert "drug/day2 = rep2" in nb._stepper.states()[3].text
+ assert "drug/day2 = rep2" in nb._stepper.states()[DESIGN].text
def test_the_axes_page_has_no_stale_image_type_sentence_and_no_broken_picture(exp): # noqa: F811
@@ -277,9 +277,13 @@ def test_the_axes_page_has_no_stale_image_type_sentence_and_no_broken_picture(ex
def test_pixel_sizes_without_a_physical_size_say_uncalibrated(exp): # noqa: F811
nb = stepped(*exp)
- state = nb._stepper.states()[1] # the pixel size is part of the axes step
+ state = nb._stepper.states()[AXES] # the pixel size is part of the axes step
+ assert not state.done and "image type: 4 to choose" in state.text # and the image type
+ nb.type_line.apply.click()
+ state = nb._stepper.states()[AXES]
assert state.done and state.note == "uncalibrated"
assert "confirmed · uncalibrated (pixels)" in state.text
+ assert state.text.endswith("separate channels (4 folders)")
nb._stepper.go(0)
assert nb._stepper.chips[1].description == "✓ 2 Axes · uncalibrated"
assert kinds(nb)[1] == "noted"
@@ -298,6 +302,8 @@ def test_pixel_sizes_from_the_headers_are_a_plain_done(tmp_path):
nb.check_button.click()
assert not nb._stepper.states()[1].done # the axes are not confirmed yet
nb.quick_confirm.click()
+ assert not nb._stepper.states()[1].done # the image type is still a proposal
+ nb.type_line.apply.click()
nb._stepper.go(0)
state = nb._stepper.states()[1]
assert state.done and not state.note and "0.65" in state.text
diff --git a/tests/test_notebook_tag_blocks.py b/tests/test_notebook_tag_blocks.py
index 8108c2c..9a1e3ae 100644
--- a/tests/test_notebook_tag_blocks.py
+++ b/tests/test_notebook_tag_blocks.py
@@ -136,12 +136,12 @@ def test_colour_is_confirmed_on_image_type_only_and_the_tag_page_restates_it(tmp
grid = nb.channel_grid
assert not grid.confirm_buttons # no second confirmation of the colour pre-tick
text = repr(grid.blocks.children)
- assert "confirm it on Image type" in text
+ assert "confirm it on Axes (Image type)" in text
buttons = [w for w in nb.type_rows.rows.children if isinstance(w, widgets.Button)]
(button,) = [w for w in buttons if "colour (RGB) is right" in w.description]
button.click()
assert all(nb.draft_answers.units[k].image_type == "rgb" for k in KEYS)
- assert "Answered colour (RGB) (Image type)" in repr(grid.blocks.children)
+ assert "Answered colour (RGB) (Axes)" in repr(grid.blocks.children)
assert not grid.cells and grid.complete