diff --git a/AGENTS.md b/AGENTS.md
index 014b700..782cd75 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -305,8 +305,10 @@ src/morphobricks_preprocessing/
comparable questions and CSV writes, the IDs toggle and the Image type card,
and reopen from the published file
notebook_check_save.py the Check and save screen (sentences, one "same way?" per replicate,
- Yes, remove, the save click, the folded reopen); notebook_check_view.py its
- HTML; description_review.py (pure) the sentences and the CSV row diff;
+ Yes, remove, the save click, the folded reopen); notebook_replicates.py its
+ replicate boxes (rebuilt only when what they show changed);
+ notebook_check_view.py its HTML; description_review.py (pure) the
+ sentences and the CSV row diff;
description_candidate.py the checks on the exact bytes; description_reopen.py
the published file against the folders; published_answers.py (pure) the
published file as draft answers, retired IDs, reopened replicates
@@ -1099,7 +1101,8 @@ jupyter lab notebooks/MorphoBricks_Preprocessing.ipynb
[docs/archive/plan-milestones-v0.1-v0.4.md](docs/archive/plan-milestones-v0.1-v0.4.md); the older
status log of this file is in
[docs/archive/agents-status-history-v0.1-v0.3.md](docs/archive/agents-status-history-v0.1-v0.3.md).
-- **Unreleased:** none yet; later changes go in a new "Unreleased" section of the changelog.
+- **Unreleased:** the faster *Looks right: confirm the design* click (see the changelog's
+ "Unreleased" section).
- **Scope:** 2-D per plane (a Z-stack is refused as `z_stack_not_supported`; since v0.5.2 the
notebook saves Z-stacks as recorded projections); acquisitions may have different channel
lists (v0.5.2, Stage 3 S3.3: manifest version 5, histogram table schema version 5; units of
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6cd532a..6268cee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,21 @@ The design is `docs/restart/preprocessing-design-v0.4.md` in MorphoFeature, on b
are in [docs/plan.md](docs/plan.md) §12. (The v0.3 to v0.4 migration guide was removed in
v0.5.1: no run before v0.5.1 is read.)
+## Unreleased
+
+### Fixed
+
+- **Looks right: confirm the design** seemed to do nothing, or was very slow (owner, v0.5.2
+ Colab test). The click saved the answers twice and refreshed every page three times, each
+ time rebuilding one image-type row per folder and every replicate box of *Check and save*,
+ so it grew with the number of folders (headless: 1.0 s for 36 images, 5.3 s for 384, 7.7 s
+ for 900; about 2,400 to 14,500 widgets sent to the page). It now saves one answer, refreshes
+ once, and rebuilds the image-type rows and the replicate boxes only when what they show
+ changed (0.14 s, 0.29 s and 0.46 s; about 250 to 450 widgets). The button is disabled and
+ says *Confirming the design…* with a working line before any work, and the click ends with
+ the result, or with the reason it was not confirmed; an unexpected failure is said there,
+ the full report in Technical details. Nothing recorded changed.
+
## v0.5.2 (12 November 2026)
Acquisitions may now have different channels (Stage 3 S3.3, owner decision of 11 November
diff --git a/pyproject.toml b/pyproject.toml
index 3799c07..9bf1884 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -179,6 +179,7 @@ baseline = "tools/code_rules_baseline.json"
"morphobricks_preprocessing.notebook_masks_screen" = "ui"
"morphobricks_preprocessing.notebook_check_save" = "ui"
"morphobricks_preprocessing.notebook_check_view" = "ui"
+"morphobricks_preprocessing.notebook_replicates" = "ui"
"morphobricks_preprocessing.notebook_described" = "ui"
"morphobricks_preprocessing.notebook_mask_question" = "ui"
"morphobricks_preprocessing.notebook_mask_step" = "ui"
diff --git a/src/morphobricks_preprocessing/notebook_channels_type.py b/src/morphobricks_preprocessing/notebook_channels_type.py
index f6e3561..37baa77 100644
--- a/src/morphobricks_preprocessing/notebook_channels_type.py
+++ b/src/morphobricks_preprocessing/notebook_channels_type.py
@@ -189,10 +189,21 @@ def answered(self) -> int:
return sum(1 for u in self.types.layouts if u.usable and self.types.saved(u.key))
def refresh(self) -> None:
- """Rebuild the rows from the saved answers (without pictures while they are folded)."""
- self.types.open(self.types.layouts) # a fresh set of cells, built from the answers
- self.pictured = not self.folded()
- self._build_rows()
+ """Rebuild the rows from the saved answers (without pictures while they are folded).
+
+ The rows are rebuilt only when something they show changed: the folders, a saved
+ type, the pictures or the saved experiment's statuses. Every answer elsewhere (a
+ name, a pairing, the design's confirmation) refreshes this page too, and rebuilding
+ one row per folder each time made those clicks slow on large experiments (owner,
+ 12 November 2026: *Looks right: confirm the design* seemed to do nothing).
+ """
+ pictured = not self.folded()
+ shown = self._shown(pictured)
+ if not self._same_rows(shown):
+ self.types.open(self.types.layouts) # a fresh set of cells, built from the answers
+ self.pictured = pictured
+ self._build_rows()
+ self._built = (self.types.radios, shown)
self.problem.value = self.types.message
usable = sum(1 for u in self.types.layouts if u.usable)
done = self.answered
@@ -202,6 +213,35 @@ def refresh(self) -> None:
else ui.status("warn", f"Not finished: {usable - done} image type(s) to choose.")
)
+ def _shown(self, pictured: bool) -> tuple:
+ """Return what the rows show, to tell whether they must be rebuilt."""
+ layouts = self.types.layouts
+ units = self.reopen()
+ return (
+ layouts,
+ pictured,
+ self._thumbs() if pictured else None,
+ tuple((self.types.saved(u.key), self.types.note(u)) for u in layouts),
+ None if units is None else tuple(sorted(units.items())),
+ )
+
+ def _same_rows(self, shown: tuple) -> bool:
+ """Return True when the rows on the page were built for exactly ``shown``.
+
+ Objects (the layouts, the pictures) are compared by identity, the answers by value;
+ cells replaced since (:meth:`ImageTypeColumn.open`) are never kept.
+ """
+ built = getattr(self, "_built", None)
+ if built is None or built[0] is not self.types.radios:
+ return False
+ before = built[1]
+ return (
+ before[0] is shown[0]
+ and before[1] == shown[1]
+ and before[2] is shown[2]
+ and before[3:] == shown[3:]
+ )
+
def unfolded(self) -> None:
"""Rebuild the rows with their pictures now that they are shown, unless they have them."""
if not self.pictured and not self.folded() and ui.is_visible(self.widget):
diff --git a/src/morphobricks_preprocessing/notebook_check_save.py b/src/morphobricks_preprocessing/notebook_check_save.py
index 735429a..bd27097 100644
--- a/src/morphobricks_preprocessing/notebook_check_save.py
+++ b/src/morphobricks_preprocessing/notebook_check_save.py
@@ -18,7 +18,6 @@
from __future__ import annotations
-import html
from dataclasses import replace
from datetime import date
from pathlib import Path
@@ -31,7 +30,7 @@
from .description import DescriptionError
from .description_candidate import CandidateCheck, check_candidate
from .description_removal import Removal, reconfirmations
-from .description_review import comparisons, replicates, short_names
+from .description_review import comparisons, short_names
from .draft import DraftError
from .draft_answers import (
RepeatAnswer,
@@ -43,28 +42,20 @@
from .fingerprint import changed_since_check
from .mask_answers import masks_unanswered
from .name_issues import NAME_CODES
-from .notebook_channel_thumbs import Thumb
from .notebook_check_view import (
- SAME_WAY,
- SAME_WAY_HELP,
blocked_html,
changes_html,
checks_html,
comparisons_html,
- member_html,
- missing_html,
names_html,
removal_text,
- replicate_title,
run_stop_html,
saved_html,
todo_html,
)
from .notebook_description import _with_ids
from .notebook_render import blocked_line
-from .notebook_row_fold import RowFold, row_status
-from .notebook_thumb_tile import ThumbTile, show_detail
-from .preview_crop import TilePictures
+from .notebook_replicates import _ReplicateBoxes
from .published_answers import kept_date
from .run import OutputLockedError
from .run.description_file import PublicationRefused
@@ -79,15 +70,7 @@
FOLDED = ("design_card", "names_card", "references_card", "channels_box", "masks_box")
-def _enlarge(detail: widgets.VBox, title: str, found: TilePictures | Thumb) -> None:
- """Show a folder's enlarged picture in ``detail``, or why there is none."""
- if isinstance(found, Thumb):
- show_detail(detail, title, None, found.note)
- else:
- show_detail(detail, title, found, "")
-
-
-class _CheckSaveScreen:
+class _CheckSaveScreen(_ReplicateBoxes):
"""The *Check and save* screen, its save click and the folded reopening."""
def _build_check_save(self) -> None:
@@ -319,92 +302,6 @@ def _candidate_check(self, answers: object) -> CandidateCheck | None:
_, confirmations = self._publication_terms(state, candidate)
return check_candidate(candidate, self.preflight, roles, published, confirmations)
- def _show_replicates(self, applied: object, answers: object, labels: list[str]) -> None:
- """One box per replicate: folders, pictures, channels, and its own question."""
- boxes, toggles = [], {}
- units, counts = self._reopen_units(), self._channel_counts()
- state = self._published()
- saved_dates = (
- {}
- if state is None
- else {key: r.confirmed_on for key, r in state.description.repeats.items()}
- )
- views = replicates(applied, answers)
- for view in views:
- toggle = ui.ConfirmToggle(
- SAME_WAY,
- SAME_WAY_HELP,
- "Yes, the same way",
- f"Confirmed on {view.confirmed_on}: one replicate." if view.confirmed_on else "",
- "Undo",
- )
- toggle.set(view.confirmed_on is not None, notify=False)
- toggle.observe(lambda value, label=view.label: self._same_way(label, value))
- toggles[view.label] = toggle
- members = [
- widgets.HBox(
- [
- self._member_picture(
- (m.condition, m.source_repeat), self.replicates_detail
- ),
- widgets.HTML(member_html(m, labels, counts)),
- ]
- )
- for m in view.members
- ]
- body = [widgets.HTML(replicate_title(view)), *members, widgets.HTML(missing_html(view))]
- box = widgets.VBox(
- [*body, toggle.widget],
- layout=widgets.Layout(
- border="1px solid #E2E8F0", padding="8px 12px", margin="0 0 10px 0"
- ),
- )
- keys = [u.key for m in view.members for u in m.units]
- # A box with a warning (an acquisition lacking a label) never folds, nor one whose
- # date is not the published one (publication's R_CONFIRMED_ON_CHANGED names it).
- settled = (
- view.confirmed_on is not None
- and not view.missing
- and view.confirmed_on == saved_dates.get(view.label, view.confirmed_on)
- )
- boxes.append((box, row_status(units, keys, settled)))
- self.same_way = toggles
- shown = (
- self._channel_thumbs,
- [[(m.condition, m.source_repeat) for m in v.members] for v in views],
- )
- if shown != getattr(self, "_replicates_shown", None): # other folders or pictures (#252)
- self.replicates_detail.children = [] # an Enlarge of the boxes before is closed
- self._replicates_shown = shown
- if not hasattr(self, "_replicate_fold"):
- self._replicate_fold = RowFold("replicate")
- self.replicates_box.children = self._replicate_fold.arrange(boxes)
-
- def _member_picture(self, key: tuple[str, str], detail: widgets.VBox) -> widgets.Widget:
- """Return the picture of a folder's first image (channel 1, or in colour when RGB).
-
- A large image gets its crop at native pixels beside it; **Enlarge** shows both in
- ``detail``.
- """
- thumbs, layouts = self._channel_thumbs, self._channel_layouts
- layout = next((u for u in layouts if u.key[:2] == key), None)
- if thumbs is None or layout is None:
- return widgets.HTML(
- "
picture once the axis roles are "
- "confirmed
"
- )
- colour = self.channel_types.shown(layout) == "rgb"
- found = thumbs.tile(layout, colour)
- if isinstance(found, Thumb):
- return ThumbTile(None, problem=found.note, width="120px").widget
- title = f"{html.escape('/'.join(key))} · {html.escape(layout.image or '')}"
- return ThumbTile(
- found,
- colour=colour,
- enlarge=lambda: _enlarge(detail, title, thumbs.enlarged(layout, colour)),
- width="120px",
- ).widget
-
def _folder_cell(self, key: tuple[str, str], text: str) -> widgets.Widget:
"""Return a folder of the pairing card, with its picture."""
return widgets.VBox([widgets.HTML(text), self._member_picture(key, self.pairing_detail)])
@@ -464,6 +361,7 @@ def _same_way(self, label: str, value: bool) -> None:
repeats = {**answers.repeats, label: replace(entry, confirmed_on=day)}
if not self._answer(replace(answers, repeats=repeats)):
self.save_status.value = ui.status("error", "Not taken.", [str(self._refusal)])
+ self._replicates_built = None # the clicked box shows an answer that was not taken
self._refresh_check_save()
def _remove(self, condition_id: str) -> None:
diff --git a/src/morphobricks_preprocessing/notebook_description.py b/src/morphobricks_preprocessing/notebook_description.py
index 26b0ef3..51849e9 100644
--- a/src/morphobricks_preprocessing/notebook_description.py
+++ b/src/morphobricks_preprocessing/notebook_description.py
@@ -313,6 +313,14 @@ def _wanted_ids(self) -> dict[tuple[str, str], str]:
def _confirm_ids(self) -> bool:
"""Adopt every ID as shown, or none: each refusal is listed with the reader's reason."""
wanted = self._wanted_ids()
+ return self._adopt_ids(wanted, _with_ids(self.draft_answers, wanted))
+
+ def _adopt_ids(self, wanted: dict[tuple[str, str], str], answers: DraftAnswers) -> bool:
+ """Save ``answers`` (which adopt ``wanted``) in one answer, unless an ID is refused.
+
+ Each ID is first tried on its own, so every refusal is listed with the reader's
+ reason; then ``answers`` are saved once. Nothing is adopted when anything is refused.
+ """
problems = []
for (kind, key), text in wanted.items():
try:
@@ -320,7 +328,7 @@ def _confirm_ids(self) -> bool:
except DraftError as exc:
problems.append((f"{KIND_NAMES[kind].capitalize()} {key}", exc))
if not problems:
- if self._answer(_with_ids(self.draft_answers, wanted)):
+ if self._answer(answers):
return True
problems.append(("These IDs together", self._refusal))
self._names_refusal = refusal_card(
diff --git a/src/morphobricks_preprocessing/notebook_design_step.py b/src/morphobricks_preprocessing/notebook_design_step.py
index 7f0234b..38d008b 100644
--- a/src/morphobricks_preprocessing/notebook_design_step.py
+++ b/src/morphobricks_preprocessing/notebook_design_step.py
@@ -24,17 +24,19 @@
from __future__ import annotations
import html
+import traceback
import ipywidgets as widgets
from . import notebook_style as ui
-from .notebook_description import _updated
+from .notebook_description import _updated, _with_ids
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"
+CONFIRMING = "Confirming 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."
@@ -81,7 +83,7 @@ def _build_design_step(self) -> None:
],
visible=False,
)
- self.design_confirm.on_click(lambda _: self._confirm_design())
+ self.design_confirm.on_click(lambda _: self._design_clicked())
self.design_change.on_click(lambda _: self._change_design())
# State -------------------------------------------------------------------------------
@@ -110,9 +112,46 @@ def _in_steps(self) -> bool:
# The click ---------------------------------------------------------------------------
+ def _design_clicked(self) -> None:
+ """Run the confirmation behind a busy button, then say what came of it.
+
+ Owner (12 November 2026): the click must never look as if nothing happened. The button
+ is disabled and says *Confirming…* and a working line shows before any of the work
+ (the widgets' state is sent to the page as it is set); the result line then says that
+ the design is confirmed, or why not. An unexpected failure is said there too, the full
+ report in Technical details, and the button is given back.
+ """
+ button = self.design_confirm
+ if button.disabled:
+ return
+ saved = (button.description, button.icon)
+ self._busy(button, CONFIRMING)
+ self.design_result.value = ui.working(CONFIRMING)
+ try:
+ self._confirm_design()
+ except Exception as exc: # unexpected failures still get a readable message
+ self.log.lines.append(traceback.format_exc())
+ self._show_log()
+ self._design_problem = ui.status(
+ "error",
+ "The design was not confirmed: an unexpected problem stopped it.",
+ [
+ f"{type(exc).__name__}: {exc}",
+ "Open Technical details for the full report, then click the button again.",
+ ],
+ )
+ finally:
+ button.description, button.icon = saved
+ button.disabled = False
+ self._refresh_design_step()
+
def _confirm_design(self) -> bool:
"""Adopt the design as shown: every ID, and "no comparison" where nothing was chosen.
+ One answer, saved once: the IDs and the comparisons together, so the pages that follow
+ the answers are refreshed once (they were refreshed three times, which made the click
+ slow on large experiments).
+
Returns
-------
bool
@@ -122,23 +161,27 @@ def _confirm_design(self) -> bool:
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
+ wanted = self._wanted_ids()
+ answers = _with_ids(self.draft_answers, wanted)
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)])
+ if not self._adopt_ids(wanted, answers):
+ self._design_problem = self._names_refusal
+ self.design_edit.selected_index = 0
self._refresh_design_step()
return False
- self._refresh_description()
self._refresh_design_status()
- return self._design_confirmed()
+ if not self._design_confirmed():
+ self._design_problem = ui.status(
+ "warn",
+ "The design is not confirmed yet.",
+ ["Read the names and comparisons under Edit names or pairing, then click again."],
+ )
+ self._refresh_design_step()
+ return False
+ return True
def _change_design(self) -> None:
"""Take the IDs back so the names can be edited (as *Change the IDs* does)."""
diff --git a/src/morphobricks_preprocessing/notebook_replicates.py b/src/morphobricks_preprocessing/notebook_replicates.py
new file mode 100644
index 0000000..a868007
--- /dev/null
+++ b/src/morphobricks_preprocessing/notebook_replicates.py
@@ -0,0 +1,166 @@
+"""The replicate boxes of *Check and save*: folders, pictures, channels and "same way?".
+
+One box per replicate (biological repeat): its folders with a picture of each folder's first
+image, the channels, what an acquisition lacks, and the replicate's own comparability question
+(the N35 answer, :data:`.notebook_check_view.SAME_WAY`). Moved out of
+:mod:`.notebook_check_save` (code rules: 600 lines a module).
+
+Every answer anywhere in the notebook refreshes *Check and save*, so the boxes are rebuilt only
+when something they show changed (owner, 12 November 2026: *Looks right: confirm the design*
+rebuilt every box twice and seemed to do nothing on large experiments).
+"""
+
+from __future__ import annotations
+
+import html
+
+import ipywidgets as widgets
+
+from . import notebook_style as ui
+from .description_review import replicates
+from .notebook_channel_thumbs import Thumb
+from .notebook_check_view import (
+ SAME_WAY,
+ SAME_WAY_HELP,
+ member_html,
+ missing_html,
+ replicate_title,
+)
+from .notebook_row_fold import RowFold, row_status
+from .notebook_thumb_tile import ThumbTile, show_detail
+from .preview_crop import TilePictures
+
+
+def _enlarge(detail: widgets.VBox, title: str, found: TilePictures | Thumb) -> None:
+ """Show a folder's enlarged picture in ``detail``, or why there is none."""
+ if isinstance(found, Thumb):
+ show_detail(detail, title, None, found.note)
+ else:
+ show_detail(detail, title, found, "")
+
+
+def _same(before: tuple, now: tuple) -> bool:
+ """Return True when two states of the replicate boxes are the same.
+
+ The pictures and the folders' layouts are compared by identity, the rest by value.
+ """
+ return before[0] is now[0] and before[1] is now[1] and before[2:] == now[2:]
+
+
+class _ReplicateBoxes:
+ """One box per replicate on *Check and save*, rebuilt only when what it shows changed."""
+
+ def _show_replicates(self, applied: object, answers: object, labels: list[str]) -> None:
+ """One box per replicate: folders, pictures, channels, and its own question."""
+ boxes, toggles = [], {}
+ units, counts = self._reopen_units(), self._channel_counts()
+ state = self._published()
+ saved_dates = (
+ {}
+ if state is None
+ else {key: r.confirmed_on for key, r in state.description.repeats.items()}
+ )
+ views = replicates(applied, answers)
+ shown_as = self._replicates_state(views, labels, counts, units, saved_dates)
+ built = getattr(self, "_replicates_built", None)
+ if built is not None and built[0] is self.same_way and _same(built[1], shown_as):
+ return # the boxes on the page show exactly this (each answer refreshes the screen)
+ for view in views:
+ toggles[view.label], box = self._replicate_box(view, labels, counts)
+ keys = [u.key for m in view.members for u in m.units]
+ # A box with a warning (an acquisition lacking a label) never folds, nor one whose
+ # date is not the published one (publication's R_CONFIRMED_ON_CHANGED names it).
+ settled = (
+ view.confirmed_on is not None
+ and not view.missing
+ and view.confirmed_on == saved_dates.get(view.label, view.confirmed_on)
+ )
+ boxes.append((box, row_status(units, keys, settled)))
+ self.same_way = toggles
+ shown = (
+ self._channel_thumbs,
+ [[(m.condition, m.source_repeat) for m in v.members] for v in views],
+ )
+ if shown != getattr(self, "_replicates_shown", None): # other folders or pictures (#252)
+ self.replicates_detail.children = [] # an Enlarge of the boxes before is closed
+ self._replicates_shown = shown
+ if not hasattr(self, "_replicate_fold"):
+ self._replicate_fold = RowFold("replicate")
+ self.replicates_box.children = self._replicate_fold.arrange(boxes)
+ self._replicates_built = (toggles, shown_as)
+
+ def _member_picture(self, key: tuple[str, str], detail: widgets.VBox) -> widgets.Widget:
+ """Return the picture of a folder's first image (channel 1, or in colour when RGB).
+
+ A large image gets its crop at native pixels beside it; **Enlarge** shows both in
+ ``detail``.
+ """
+ thumbs, layouts = self._channel_thumbs, self._channel_layouts
+ layout = next((u for u in layouts if u.key[:2] == key), None)
+ if thumbs is None or layout is None:
+ return widgets.HTML(
+ "picture once the axis roles are "
+ "confirmed
"
+ )
+ colour = self.channel_types.shown(layout) == "rgb"
+ found = thumbs.tile(layout, colour)
+ if isinstance(found, Thumb):
+ return ThumbTile(None, problem=found.note, width="120px").widget
+ title = f"{html.escape('/'.join(key))} · {html.escape(layout.image or '')}"
+ return ThumbTile(
+ found,
+ colour=colour,
+ enlarge=lambda: _enlarge(detail, title, thumbs.enlarged(layout, colour)),
+ width="120px",
+ ).widget
+
+ def _replicate_box(
+ self, view: object, labels: list[str], counts: object
+ ) -> tuple[ui.ConfirmToggle, widgets.VBox]:
+ """Return one replicate's "same way?" question and its box (folders, pictures)."""
+ toggle = ui.ConfirmToggle(
+ SAME_WAY,
+ SAME_WAY_HELP,
+ "Yes, the same way",
+ f"Confirmed on {view.confirmed_on}: one replicate." if view.confirmed_on else "",
+ "Undo",
+ )
+ toggle.set(view.confirmed_on is not None, notify=False)
+ toggle.observe(lambda value, label=view.label: self._same_way(label, value))
+ members = [
+ widgets.HBox(
+ [
+ self._member_picture((m.condition, m.source_repeat), self.replicates_detail),
+ widgets.HTML(member_html(m, labels, counts)),
+ ]
+ )
+ for m in view.members
+ ]
+ body = [widgets.HTML(replicate_title(view)), *members, widgets.HTML(missing_html(view))]
+ box = widgets.VBox(
+ [*body, toggle.widget],
+ layout=widgets.Layout(
+ border="1px solid #E2E8F0", padding="8px 12px", margin="0 0 10px 0"
+ ),
+ )
+ return toggle, box
+
+ def _replicates_state(
+ self, views: tuple, labels: list[str], counts: object, units: object, dates: dict
+ ) -> tuple:
+ """Return everything the replicate boxes show, to tell whether they must be rebuilt."""
+ layouts = self._channel_layouts
+ return (
+ self._channel_thumbs,
+ layouts,
+ self._cards_show_pictures(),
+ tuple(self.channel_types.shown(u) for u in layouts or ()),
+ views,
+ tuple(labels),
+ None if counts is None else tuple(sorted(counts.items())),
+ None if units is None else tuple(sorted(units.items())),
+ tuple(sorted(dates.items())),
+ )
+
+
+__all__ = ["_ReplicateBoxes"]
diff --git a/tests/test_notebook_check_save.py b/tests/test_notebook_check_save.py
index 6d31ab1..22493b6 100644
--- a/tests/test_notebook_check_save.py
+++ b/tests/test_notebook_check_save.py
@@ -341,3 +341,19 @@ def test_replicates_are_listed_in_natural_order_rep2_before_rep10(tmp_path):
assert "Replicates: rep2, rep10" in nb.short_names.value
rows = [key for key in nb.id_inputs if key[0] == "repeats"]
assert rows == [("repeats", "rep2"), ("repeats", "rep10")]
+
+
+def test_a_refused_same_way_answer_is_not_left_shown_as_confirmed(exp, monkeypatch):
+ nb = opened(*exp)
+ tag(nb)
+ nb.mask_card.questions["Nuclei"].choice.value = "instances"
+ nb.reference_boxes["drug"].value = "ctrl"
+ nb.reference_boxes["ctrl"].value = ""
+ monkeypatch.setattr(type(nb), "_save_description", lambda self: False)
+ nb._save_problem = "disk full"
+ nb.same_way["rep1"].button.click() # the answer cannot be saved
+ assert (
+ "rep1" not in nb.draft_answers.repeats or not nb.draft_answers.repeats["rep1"].confirmed_on
+ )
+ assert not nb.same_way["rep1"].value # the boxes are rebuilt from the answers, not kept
+ assert "Not taken" in nb.save_status.value
diff --git a/tests/test_notebook_design_confirm_speed.py b/tests/test_notebook_design_confirm_speed.py
new file mode 100644
index 0000000..723d789
--- /dev/null
+++ b/tests/test_notebook_design_confirm_speed.py
@@ -0,0 +1,143 @@
+"""*Looks right: confirm the design* on a sized experiment (owner bug, 12 November 2026).
+
+On the v0.5.2 notebook the click seemed to do nothing, or was very slow. It saved the answers
+twice and refreshed every page three times, each time rebuilding one image-type row per folder
+and every replicate box of *Check and save*; the time grew with the number of folders. Now the
+click saves one answer, refreshes once, rebuilds only rows whose content changed, shows a busy
+button and a working line before any work, and ends with a result line.
+"""
+
+import shutil
+import time
+
+import pytest
+
+from channel_images import write_channels
+
+pytest.importorskip("ipywidgets")
+pytest.importorskip("matplotlib")
+
+from morphobricks_preprocessing import notebook_style as ui # noqa: E402
+from morphobricks_preprocessing.notebook_channels_type import ImageTypeRows # noqa: E402
+from morphobricks_preprocessing.notebook_design_step import ( # noqa: E402
+ CONFIRM_LABEL,
+ CONFIRMING,
+)
+from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402
+
+CONDITIONS, REPEATS, TECHNICAL, FOVS = 4, 4, 2, 2
+BUDGET = 2.0
+"""Seconds for the click on 64 images; it took about 1.5 s here before the fix, 0.15 s after.
+
+Generous for slow CI runners: the call counts below are the exact check.
+"""
+
+
+@pytest.fixture
+def sized(tmp_path):
+ """Return a stepper with its axes confirmed on 4 conditions x 4 repeats x 2 x 2 images."""
+ root = tmp_path / "exp"
+ one = write_channels(tmp_path / "one.tif", ["DAPI", "Actin", "Ch3"], ["blobs"] * 3, size=32)
+ for c in range(CONDITIONS):
+ for r in range(REPEATS):
+ for t in range(TECHNICAL):
+ for f in range(FOVS):
+ path = root / f"cond{c}" / f"rep{r + 1}" / f"tech{t + 1}" / f"fov{f + 1}.tif"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy(one, path)
+ nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out"))
+ nb.welcome()
+ nb.steps()
+ nb.check_button.click()
+ nb.quick_confirm.click()
+ assert ui.is_visible(nb.design_confirm) and not nb.design_confirm.disabled
+ return nb
+
+
+def _count(monkeypatch, owner, name):
+ calls = []
+ original = getattr(owner, name)
+
+ def spy(self, *args, **kwargs):
+ calls.append(name)
+ return original(self, *args, **kwargs)
+
+ monkeypatch.setattr(owner, name, spy)
+ return calls
+
+
+def test_the_click_saves_once_refreshes_once_and_keeps_the_unchanged_rows(sized, monkeypatch):
+ nb = sized
+ cls = type(nb)
+ answers = _count(monkeypatch, cls, "_answer")
+ refreshes = _count(monkeypatch, cls, "_refresh_description")
+ type_rows = _count(monkeypatch, ImageTypeRows, "_build_rows")
+ started = time.perf_counter()
+ nb.design_confirm.click()
+ took = time.perf_counter() - started
+ assert nb._design_confirmed()
+ assert len(answers) == 1 and len(refreshes) == 1
+ assert type_rows == [] # the image types did not change: their rows are kept
+ assert took < BUDGET, f"the click took {took:.2f} s on 64 images"
+ assert "Design confirmed: 4 conditions × 4 biological repeats" in nb.design_result.value
+
+
+def test_the_click_shows_a_busy_button_and_a_working_line_before_any_work(sized, monkeypatch):
+ nb = sized
+ seen = {}
+ original = type(nb)._confirm_design
+
+ def watched(self):
+ button = self.design_confirm
+ seen.update(
+ disabled=button.disabled,
+ label=button.description,
+ icon=button.icon,
+ line=self.design_result.value,
+ )
+ return original(self)
+
+ monkeypatch.setattr(type(nb), "_confirm_design", watched)
+ nb.design_confirm.click()
+ assert seen["disabled"] and seen["icon"] == "spinner"
+ assert seen["label"].strip() == CONFIRMING and "Confirming the design" in seen["line"]
+ # Afterwards: the result, and the button given back (hidden once confirmed).
+ assert nb.design_confirm.description == CONFIRM_LABEL and nb.design_confirm.icon == "check"
+ assert "Design confirmed" in nb.design_result.value
+ assert not ui.is_visible(nb.design_confirm) and ui.is_visible(nb.design_change)
+
+
+def test_an_unexpected_failure_is_said_and_the_button_is_given_back(sized, monkeypatch):
+ nb = sized
+
+ def broken(self):
+ raise RuntimeError("disk on fire")
+
+ monkeypatch.setattr(type(nb), "_confirm_design", broken)
+ nb.design_confirm.click()
+ said = nb.design_result.value
+ assert "The design was not confirmed" in said and "RuntimeError: disk on fire" in said
+ assert "Technical details" in said
+ assert nb.design_confirm.description == CONFIRM_LABEL and not nb.design_confirm.disabled
+ assert ui.is_visible(nb.design_confirm) and not nb._design_confirmed()
+ assert any("disk on fire" in line for line in nb.log.lines)
+
+
+def test_a_disabled_button_does_nothing(sized, monkeypatch):
+ nb = sized
+ calls = _count(monkeypatch, type(nb), "_confirm_design")
+ nb.design_confirm.disabled = True
+ nb._design_clicked()
+ assert calls == [] and not nb._design_confirmed()
+
+
+def test_a_changed_image_type_still_rebuilds_its_rows(sized):
+ nb = sized
+ rows = nb.type_rows
+ before = rows.rows.children
+ nb.type_line.apply.click() # separate channels for all folders: what the rows show changed
+ assert rows.rows.children is not before
+ assert all(radio.value == "channels" for radio in nb.channel_types.radios.values())
+ kept = rows.rows.children
+ nb.design_confirm.click()
+ assert rows.rows.children is kept # the design does not change them
diff --git a/tests/test_notebook_row_fold.py b/tests/test_notebook_row_fold.py
index ae189a1..fa7dfd1 100644
--- a/tests/test_notebook_row_fold.py
+++ b/tests/test_notebook_row_fold.py
@@ -160,15 +160,15 @@ def test_a_row_with_a_warning_never_folds(reopened, monkeypatch):
assert nb.type_rows.fold.count == 2 # ctrl/rep1 stays open, with its warning
assert "The header says colour (RGB)" not in _text(nb.type_rows.fold.folded)
- from morphobricks_preprocessing import notebook_check_save
+ from morphobricks_preprocessing import notebook_replicates
- real = notebook_check_save.replicates
+ real = notebook_replicates.replicates
def lacking(applied, answers):
views = real(applied, answers)
return [replace(v, missing=((key, "DNA"),)) if v.repeat_id == "rep1" else v for v in views]
- monkeypatch.setattr(notebook_check_save, "replicates", lacking)
+ monkeypatch.setattr(notebook_replicates, "replicates", lacking)
nb._refresh_check_save()
assert nb._replicate_fold.count == 0
assert "A_SCOPE_INCOMPLETE" in _text(nb.replicates_box)