From 4e371c58b5a9c527cccb16c03a7864ab78a07331 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Mon, 9 Nov 2026 13:39:13 +0100 Subject: [PATCH 1/2] feat(experiments): delete from list, launch new runs into an existing experiment - Experiments list: per-row Delete (admin/auditor; admin for public), runs kept. - New Experiment: 'Add to experiment' picker to launch into an existing experiment instead of creating one; public targets need explicit consent. Works for single-run, multi-run review and repeating launches. - Experiment page: '+ New runs' button opens New Experiment preset via ?into=. - Same role rules as manual add_runs; run cap enforced under a row lock. Co-Authored-By: Claude --- audits/experiments.py | 64 +++++++++++-- infra/tests/test_experiment_attach.py | 89 +++++++++++++++++++ infra/ui.py | 85 ++++++++++++++++-- templates/experiment_new.html | 40 +++++++++ templates/experiment_review.html | 10 +++ templates/experiments.html | 14 +++ .../partials/experiment_detail_body.html | 1 + 7 files changed, 289 insertions(+), 14 deletions(-) create mode 100644 infra/tests/test_experiment_attach.py diff --git a/audits/experiments.py b/audits/experiments.py index 3be7bf8e..195f4f4b 100644 --- a/audits/experiments.py +++ b/audits/experiments.py @@ -478,8 +478,54 @@ def spec_to_row(spec: dict) -> str: return json.dumps(row) -def launch_experiment(*, project, user, name: str, runs: list[dict], repeat: dict | None = None) -> Experiment: - """Create the Experiment and its frozen runs, then submit them. +def resolve_into_experiment(project, raw) -> Experiment | None: + """The existing experiment a New Experiment launch should join, or None for a new one. + + ``raw`` is the form's ``into_experiment`` value. Blank means "create a new + experiment"; anything else must name an experiment in this workspace. + """ + raw = (raw or "").strip() + if not raw: + return None + if not raw.isdigit(): + raise DesignError("The selected experiment is not valid.") + experiment = Experiment.objects.filter(pk=int(raw), project=project).first() + if experiment is None: + raise DesignError("The selected experiment no longer exists in this workspace.") + return experiment + + +def prepare_into_experiment(*, experiment: Experiment, user, n_new_runs: int, confirm_public: bool) -> None: + """Check that ``n_new_runs`` may be launched into an existing experiment. + + Same rules as adding runs by hand (add_runs): admin/auditor, and admin plus + confirmation for a public experiment, since the new runs become public. + Call inside the launch transaction: the experiment row is locked so + concurrent launches cannot exceed the run cap. + """ + _require_membership_role(user, experiment, confirm_public=confirm_public) + Experiment.objects.select_for_update().filter(pk=experiment.pk).first() + if experiment.memberships.count() + n_new_runs > MAX_RUNS_PER_EXPERIMENT: + raise DesignError( + f"“{experiment.name}” would exceed {MAX_RUNS_PER_EXPERIMENT} runs; " + "pick fewer runs or start a new experiment." + ) + + +def launch_experiment( + *, + project, + user, + name: str, + runs: list[dict], + repeat: dict | None = None, + into: Experiment | None = None, + confirm_public: bool = False, +) -> Experiment: + """Create the Experiment (or join ``into``) and its frozen runs, then submit them. + + With ``into`` the runs are added to that existing experiment instead of a + new one (``name`` is then ignored); see prepare_into_experiment for the rules. ``runs`` items: {"name", "version", "target", "auditor", "judge_model", "judge", "max_turns", "language", "n_repetitions", "gen_config"}. With ``repeat`` @@ -505,9 +551,17 @@ def launch_experiment(*, project, user, name: str, runs: list[dict], repeat: dic # audit the agent's bare base model instead of the agent. raise DesignError("Repeating an agent experiment is not supported yet; launch it once instead.") with transaction.atomic(): - experiment = Experiment.objects.create( - project=project, name=name.strip()[:250] or "Experiment", created_by=user - ) + if into is not None: + run_now_count = len(runs) if (not repeat or repeat["start"] == "now") else 0 + prepare_into_experiment( + experiment=into, user=user, n_new_runs=run_now_count, confirm_public=confirm_public + ) + experiment = into + experiment.save(update_fields=["updated_at"]) + else: + experiment = Experiment.objects.create( + project=project, name=name.strip()[:250] or "Experiment", created_by=user + ) run_now = not repeat or repeat["start"] == "now" created = [ create_audit_run( diff --git a/infra/tests/test_experiment_attach.py b/infra/tests/test_experiment_attach.py new file mode 100644 index 00000000..066f8c8e --- /dev/null +++ b/infra/tests/test_experiment_attach.py @@ -0,0 +1,89 @@ +"""Experiments list delete, and New Experiment launching into an existing experiment. + +Run: + SIMPLEAUDIT_LOCAL_SQLITE=1 uv run manage.py test infra.tests.test_experiment_attach +""" +from django.test import Client + +from audits.models import AuditRun, Experiment +from infra.tests.factories import MembershipFactory, UserFactory +from infra.tests.test_experiments import _ExperimentBase + + +class ExperimentListDeleteTests(_ExperimentBase): + def test_list_shows_delete_and_deletes_keeping_runs(self): + self.client.post("/experiments/new/", self._design(target_model=[self.t1.id], max_turns="3")) + exp = Experiment.objects.get(project=self.project) + page = self.client.get("/experiments/") + self.assertContains(page, f"/experiments/{exp.id}/delete/") + resp = self.client.post(f"/experiments/{exp.id}/delete/", {"next": "list"}) + self.assertRedirects(resp, "/experiments/", fetch_redirect_response=False) + self.assertFalse(Experiment.objects.filter(pk=exp.pk).exists()) + self.assertEqual(AuditRun.objects.filter(project=self.project).count(), 1) + + def test_viewer_sees_no_delete(self): + exp = Experiment.objects.create(project=self.project, name="E", created_by=self.user) + viewer = UserFactory() + viewer.set_password("pw") + viewer.save() + MembershipFactory(user=viewer, project=self.project, role="viewer") + c = Client() + c.login(username=viewer.username, password="pw") + page = c.get("/experiments/") + self.assertNotContains(page, f"/experiments/{exp.id}/delete/") + + +class LaunchIntoExistingTests(_ExperimentBase): + def setUp(self): + super().setUp() + self.client.post("/experiments/new/", self._design(target_model=[self.t1.id], max_turns="3", name="Base")) + self.exp = Experiment.objects.get(project=self.project) + + def test_design_page_offers_existing_experiments_and_prefills_into(self): + page = self.client.get(f"/experiments/new/?into={self.exp.id}") + self.assertContains(page, 'name="into_experiment"') + self.assertContains(page, f'value="{self.exp.id}" data-public="" selected') + + def test_detail_page_links_to_new_runs(self): + page = self.client.get(f"/experiments/{self.exp.id}/") + self.assertContains(page, f"/experiments/new/?into={self.exp.id}") + + def test_single_run_joins_existing(self): + resp = self.client.post("/experiments/new/", self._design( + target_model=[self.t2.id], max_turns="3", into_experiment=str(self.exp.id))) + self.assertRedirects(resp, f"/experiments/{self.exp.id}/", fetch_redirect_response=False) + self.assertEqual(Experiment.objects.filter(project=self.project).count(), 1) + self.assertEqual(self.exp.runs.count(), 2) + self.assertEqual(Experiment.objects.get(pk=self.exp.pk).name, "Base") + + def test_multi_run_review_and_launch_join_existing(self): + review = self.client.post("/experiments/new/", self._design(into_experiment=str(self.exp.id))) + self.assertEqual(review.context["into_experiment"], self.exp) + self.assertContains(review, "Adding to experiment") + resp = self.client.post("/experiments/new/", self._review_to_launch_payload(review)) + self.assertRedirects(resp, f"/experiments/{self.exp.id}/", fetch_redirect_response=False) + self.assertEqual(Experiment.objects.filter(project=self.project).count(), 1) + self.assertEqual(self.exp.runs.count(), 5) + + def test_public_experiment_needs_confirmation(self): + self.exp.visibility = Experiment.Visibility.PUBLIC + self.exp.save() + resp = self.client.post("/experiments/new/", self._design( + target_model=[self.t2.id], max_turns="3", into_experiment=str(self.exp.id))) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Confirm the change") + self.assertEqual(self.exp.runs.count(), 1) + self.client.post("/experiments/new/", self._design( + target_model=[self.t2.id], max_turns="3", into_experiment=str(self.exp.id), into_confirm_public="1")) + self.assertEqual(self.exp.runs.count(), 2) + + def test_other_workspace_experiment_rejected(self): + from infra.tests.factories import ProjectFactory + + other = Experiment.objects.create(project=ProjectFactory(), name="Other") + resp = self.client.post("/experiments/new/", self._design( + target_model=[self.t2.id], max_turns="3", into_experiment=str(other.id))) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "no longer exists") + self.assertEqual(other.runs.count(), 0) + self.assertEqual(AuditRun.objects.filter(project=self.project).count(), 1) diff --git a/infra/ui.py b/infra/ui.py index 27c4d19a..b69888fe 100644 --- a/infra/ui.py +++ b/infra/ui.py @@ -49,6 +49,7 @@ submit_audit_run, ) from infra.chat_feature import chat_enabled +from infra.exceptions import StableAPIError from infra.export_utils import csv_safe from infra.seo import SITE_NAME, _share_context from scenarios.models import ( @@ -86,6 +87,29 @@ def _public_experiment_filter(request) -> dict: return {"visibility": Experiment.Visibility.PUBLIC} +def _into_permission_error(into) -> str: + """Why a launch into an existing experiment was refused (role check failed).""" + if into is not None and into.is_public: + return f"Adding runs to the public experiment “{into.name}” needs the workspace admin role." + return "Adding runs to an experiment needs the workspace admin or auditor role." + + +def _attach_choices(user, project) -> list: + """Experiments a New Experiment launch can join: private ones for admins/auditors, public ones for admins.""" + from accounts.models import ProjectMembership + from audits.models import Experiment + + if not _can_edit_membership(user, project): + return [] + is_admin = user.is_superuser or ProjectMembership.objects.filter( + user=user, project=project, role=ProjectMembership.Role.ADMIN + ).exists() + qs = Experiment.objects.filter(project=project).order_by("-updated_at") + if not is_admin: + qs = qs.exclude(visibility=Experiment.Visibility.PUBLIC) + return list(qs.only("id", "name", "visibility")[:200]) + + def _can_edit_membership(user, project) -> bool: """Whether ``user`` may add/remove experiment runs in ``project`` (admin/auditor). @@ -1410,6 +1434,7 @@ def get_context_data(self, **kw): "start": "baseline" if source and get.get("repeat") else "now", "first_run_at": "", "clone_from": str(source.id) if source else "", + "into_experiment": get.get("into", "") if is_id(get.get("into", "")) else "", }) # Connections the picker offers: this workspace's own plus any shared # into it (public / admin-shared / explicitly shared). Each carries a @@ -1484,6 +1509,7 @@ def get_context_data(self, **kw): repeat_choices=REPEAT_CHOICES, timezones=_timezone_choices(), source_run=source, + attach_choices=_attach_choices(self.request.user, p), # Same rule as launching a run: admin or auditor (superusers too). can_launch=has_write_role(self.request.user, p), ) @@ -1491,7 +1517,7 @@ def get_context_data(self, **kw): def _redesign(self, post, error=None): rep = {k: post.get(k, "") for k in ("repeat", "timezone", "cron_expression", "start", "first_run_at", "clone_from", - "name")} + "name", "into_experiment", "into_confirm_public")} rep["repeat"] = rep["repeat"] or "once" source = None if is_id(rep["clone_from"]): @@ -1539,21 +1565,36 @@ def _launch_single(self, request, spec, repeat): """ from django.db import transaction - from audits.experiments import spec_to_run + from audits.experiments import ( + prepare_into_experiment, + resolve_into_experiment, + spec_to_run, + ) from audits.models import Experiment from audits.monitors import create_monitor p = request.project run_spec = spec_to_run(spec, name="") custom_name = request.POST.get("name", "").strip()[:250] - run = monitor = None + run = monitor = into = None try: + into = resolve_into_experiment(p, request.POST.get("into_experiment")) with transaction.atomic(): - experiment = Experiment.objects.create( - project=p, - name=(custom_name or f"{spec['target'].display_name} · {spec['scenario_set'].label}")[:250], - created_by=request.user, - ) + if into is not None: + prepare_into_experiment( + experiment=into, + user=request.user, + n_new_runs=1 if (not repeat or repeat["start"] == "now") else 0, + confirm_public=request.POST.get("into_confirm_public") == "1", + ) + experiment = into + experiment.save(update_fields=["updated_at"]) + else: + experiment = Experiment.objects.create( + project=p, + name=(custom_name or f"{spec['target'].display_name} · {spec['scenario_set'].label}")[:250], + created_by=request.user, + ) if not repeat or repeat["start"] == "now": run = create_audit_run( project=p, @@ -1587,10 +1628,14 @@ def _launch_single(self, request, spec, repeat): experiment=experiment, first_point=first_point, ) + except StableAPIError: + return self._redesign(request.POST, _into_permission_error(into)) except Exception as e: # noqa: BLE001 - surface any creation failure to the user return self._redesign(request.POST, str(e)) if run: submit_audit_run(run) + if into is not None and run: + messages.success(request, f"Run added to “{experiment.name}”.") if monitor: messages.success(request, f"Monitor “{monitor.name}” created: next run {monitor.next_run_at:%Y-%m-%d %H:%M} UTC.") return redirect(f"/experiments/{experiment.id}/") @@ -1645,8 +1690,15 @@ def _render_review(self, request, *, rows, experiment_name, design_post, repeat= for row in rows: row["cells"] = [row["values"][k] for k, _ in columns] row["warnings"] = [w for w in row["warnings"] if w not in shared] + into = None + into_raw = dict(design_post).get("into_experiment", "") + if is_id(into_raw): + from audits.models import Experiment + + into = Experiment.objects.filter(pk=int(into_raw), project=request.project).first() return render(request, "experiment_review.html", { "experiment_name": experiment_name, + "into_experiment": into, "columns": columns, "fixed": fixed, "rows": rows, @@ -1847,12 +1899,24 @@ def _launch(self, request): repeat = None errors.append(str(e)) name = post.get("experiment_name", "") + into = None + try: + into = ex.resolve_into_experiment(p, design.get("into_experiment")) + except ex.DesignError as e: + errors.append(str(e)) if not errors: try: - experiment = ex.launch_experiment(project=p, user=request.user, name=name, runs=runs, repeat=repeat) + experiment = ex.launch_experiment( + project=p, user=request.user, name=name, runs=runs, repeat=repeat, into=into, + confirm_public=design.get("into_confirm_public") == "1", + ) + except StableAPIError: + errors.append(_into_permission_error(into)) except Exception as e: # noqa: BLE001 - show the reason on the review screen errors.append(str(e)) else: + if into is not None and not repeat: + messages.success(request, f"{len(runs)} runs added to “{experiment.name}”.") if repeat: messages.success(request, f"Experiment launched; its {len(runs)} run setups now repeat {ex_repeat(repeat)}.") return redirect(f"/experiments/{experiment.id}/") @@ -1932,6 +1996,7 @@ def get_context_data(self, **kw): e.factor_labels = [FACTORS.get(f, f) for f in factors_for_runs(runs)] kw["experiments"] = experiments kw["can_manage_visibility"] = self._is_admin() + kw["can_edit_membership"] = _can_edit_membership(self.request.user, self.request.project) return super().get_context_data(**kw) def _is_admin(self) -> bool: @@ -3823,6 +3888,8 @@ def post(self, request, experiment_id): return HttpResponseForbidden("Workspace admin or auditor access required.") except MembershipError as e: messages.error(request, str(e)) + if request.POST.get("next") == "list": + return redirect("experiments") return redirect("experiment_detail", experiment_id) messages.success(request, f"Experiment “{name}” deleted. Its runs are kept.") return redirect("experiments") diff --git a/templates/experiment_new.html b/templates/experiment_new.html index dfa8dee6..e12cf038 100644 --- a/templates/experiment_new.html +++ b/templates/experiment_new.html @@ -145,6 +145,25 @@

Schedule

+ {% if attach_choices %} + +
+ +

Launch into a new experiment, or add these runs to an existing one to compare them with its runs.

+ + +
+ {% endif %} +
@@ -243,6 +262,27 @@

Schedule

: repeating ? 'Names the monitor and its runs. Left empty, it is named after the target and scenario set.' : 'Left empty, the run is named after its start time.'; + // Destination: joining an existing experiment keeps its name, so the + // experiment-name meaning of the field goes away; a public one needs consent. + const intoSel = form.querySelector('[data-into-select]'); + if (intoSel) { + const opt = intoSel.selectedOptions[0]; + const joining = !!intoSel.value; + const pub = joining && opt && opt.dataset.public === '1'; + const box = form.querySelector('[data-into-public]'); + box.classList.toggle('hidden', !pub); + box.classList.toggle('flex', pub); + box.querySelector('input').required = pub; + // Several runs joining keep the experiment's name; each run is named on review. + document.getElementById('design-name').closest('.bg-surface-raised').classList.toggle('hidden', joining && runs > 1); + if (joining) { + form.querySelector('[data-name-hint]').textContent = repeating + ? `Names the monitor and its runs, which join “${opt.textContent.trim()}”.` + : `The run joins “${opt.textContent.trim()}”. Left empty, it is named after its start time.`; + if (runs === 1 && ok) btn.textContent = !repeating ? 'Add run to experiment' : btn.textContent; + } + } + // Schedule card: reveal details only when repeating. form.querySelectorAll('[data-repeat-only]').forEach(el => el.classList.toggle('hidden', !repeating)); form.querySelectorAll('[data-cron-only]').forEach(el => { diff --git a/templates/experiment_review.html b/templates/experiment_review.html index 2d283718..7a771a5e 100644 --- a/templates/experiment_review.html +++ b/templates/experiment_review.html @@ -19,11 +19,21 @@

Review experiment

{% endif %}
+ {% if into_experiment %} +
+

Adding to experiment

+

{{ into_experiment.name }} + {% if into_experiment.is_public %}Public{% endif %} + · {{ into_experiment.memberships.count }} runs now

+ +
+ {% else %}
+ {% endif %} {% if fixed %}

Same in every run

diff --git a/templates/experiments.html b/templates/experiments.html index f1aa3c53..d9e6b395 100644 --- a/templates/experiments.html +++ b/templates/experiments.html @@ -18,6 +18,7 @@

Experiments

Progress Visibility Created + {% if can_edit_membership %}Actions{% endif %} @@ -46,6 +47,19 @@

Experiments

{% endif %} {% if e.created_by %} · {{ e.created_by.username }}{% endif %} + {% if can_edit_membership %} + + {% if not e.is_public or can_manage_visibility %} +
+ {% csrf_token %} + + + +
+ {% endif %} + + {% endif %} {% endfor %} diff --git a/templates/partials/experiment_detail_body.html b/templates/partials/experiment_detail_body.html index 9bc35cc4..fd43ba50 100644 --- a/templates/partials/experiment_detail_body.html +++ b/templates/partials/experiment_detail_body.html @@ -25,6 +25,7 @@ {% endif %} {% if can_edit_membership %} + + New runs
{% csrf_token %} From 4353b53670f6b679bdee2680df58b5c1d2241573 Mon Sep 17 00:00:00 2001 From: Sushant Gautam Date: Mon, 9 Nov 2026 14:01:53 +0100 Subject: [PATCH 2/2] feat(experiments): searchable destination picker in New Experiment Replace the 'Add to experiment' select with a New/Existing toggle and a searchable list showing each experiment's run count, last update and public badge. Enter picks the first match; launching is blocked until an experiment is picked when 'Existing' is chosen. --- infra/tests/test_experiment_attach.py | 3 +- infra/ui.py | 6 +- templates/experiment_new.html | 102 +++++++++++++++++++++----- 3 files changed, 89 insertions(+), 22 deletions(-) diff --git a/infra/tests/test_experiment_attach.py b/infra/tests/test_experiment_attach.py index 066f8c8e..3121dc40 100644 --- a/infra/tests/test_experiment_attach.py +++ b/infra/tests/test_experiment_attach.py @@ -42,7 +42,8 @@ def setUp(self): def test_design_page_offers_existing_experiments_and_prefills_into(self): page = self.client.get(f"/experiments/new/?into={self.exp.id}") self.assertContains(page, 'name="into_experiment"') - self.assertContains(page, f'value="{self.exp.id}" data-public="" selected') + self.assertContains(page, "1 run · updated") + self.assertRegex(page.content.decode(), rf'value="{self.exp.id}" data-public=""[^>]*checked') def test_detail_page_links_to_new_runs(self): page = self.client.get(f"/experiments/{self.exp.id}/") diff --git a/infra/ui.py b/infra/ui.py index b69888fe..be38f8dc 100644 --- a/infra/ui.py +++ b/infra/ui.py @@ -104,10 +104,12 @@ def _attach_choices(user, project) -> list: is_admin = user.is_superuser or ProjectMembership.objects.filter( user=user, project=project, role=ProjectMembership.Role.ADMIN ).exists() - qs = Experiment.objects.filter(project=project).order_by("-updated_at") + from django.db.models import Count + + qs = Experiment.objects.filter(project=project).annotate(n_runs=Count("memberships")).order_by("-updated_at") if not is_admin: qs = qs.exclude(visibility=Experiment.Visibility.PUBLIC) - return list(qs.only("id", "name", "visibility")[:200]) + return list(qs.only("id", "name", "visibility", "updated_at")[:200]) def _can_edit_membership(user, project) -> bool: diff --git a/templates/experiment_new.html b/templates/experiment_new.html index e12cf038..25900c4d 100644 --- a/templates/experiment_new.html +++ b/templates/experiment_new.html @@ -148,19 +148,41 @@

Schedule

{% if attach_choices %}
- -

Launch into a new experiment, or add these runs to an existing one to compare them with its runs.

- -
{% endif %} @@ -264,22 +286,26 @@

Schedule

// Destination: joining an existing experiment keeps its name, so the // experiment-name meaning of the field goes away; a public one needs consent. - const intoSel = form.querySelector('[data-into-select]'); - if (intoSel) { - const opt = intoSel.selectedOptions[0]; - const joining = !!intoSel.value; - const pub = joining && opt && opt.dataset.public === '1'; - const box = form.querySelector('[data-into-public]'); + const destCard = document.getElementById('destination-card'); + if (destCard) { + const existing = !destCard.querySelector('[data-into-panel]').classList.contains('hidden'); + const opt = destCard.querySelector('input[name="into_experiment"]:checked'); + const joining = !!(opt && opt.value); + const pub = joining && opt.dataset.public === '1'; + const box = destCard.querySelector('[data-into-public]'); box.classList.toggle('hidden', !pub); box.classList.toggle('flex', pub); box.querySelector('input').required = pub; + destCard.querySelector('[data-into-pick]').classList.toggle('hidden', !(existing && !joining)); + if (existing && !joining) ok = false; // Several runs joining keep the experiment's name; each run is named on review. document.getElementById('design-name').closest('.bg-surface-raised').classList.toggle('hidden', joining && runs > 1); if (joining) { form.querySelector('[data-name-hint]').textContent = repeating - ? `Names the monitor and its runs, which join “${opt.textContent.trim()}”.` - : `The run joins “${opt.textContent.trim()}”. Left empty, it is named after its start time.`; - if (runs === 1 && ok) btn.textContent = !repeating ? 'Add run to experiment' : btn.textContent; + ? `Names the monitor and its runs, which join “${opt.dataset.label}”.` + : `The run joins “${opt.dataset.label}”. Left empty, it is named after its start time.`; + if (runs === 1 && ok && !repeating) btn.textContent = 'Add run to experiment'; + if (runs > 1 && ok) btn.textContent = `Review ${runs} runs for “${opt.dataset.label}” →`; } } @@ -292,6 +318,44 @@

Schedule

btn.disabled = !canLaunch || !ok; } + // Destination: New / Existing toggle, and a searchable list of experiments. + const destCard = document.getElementById('destination-card'); + if (destCard) { + const panel = destCard.querySelector('[data-into-panel]'); + const filter = document.getElementById('into-filter'); + const ON = ['bg-accent', 'text-white'], OFF = ['text-gray-400', 'hover:text-gray-200']; + destCard.querySelectorAll('[data-dest]').forEach(b => b.addEventListener('click', () => { + const existing = b.dataset.dest === 'existing'; + destCard.querySelectorAll('[data-dest]').forEach(x => { + const on = x === b; + x.classList.remove(...(on ? OFF : ON)); x.classList.add(...(on ? ON : OFF)); + }); + panel.classList.toggle('hidden', !existing); + if (!existing) destCard.querySelector('[data-into-new]').checked = true; + else { destCard.querySelector('[data-into-new]').checked = false; filter.focus(); } + update(); + })); + filter.addEventListener('input', () => { + const q = filter.value.trim().toLowerCase(); + let shown = 0; + destCard.querySelectorAll('[data-into-row]').forEach(r => { + const keep = !q || r.dataset.name.includes(q); + r.classList.toggle('hidden', !keep); r.classList.toggle('flex', keep); + if (keep) shown++; + }); + destCard.querySelector('[data-into-empty]').classList.toggle('hidden', shown > 0); + }); + // Enter picks the first match instead of submitting the design. + filter.addEventListener('keydown', e => { + if (e.key !== 'Enter') return; + e.preventDefault(); + const first = destCard.querySelector('[data-into-row]:not(.hidden) input'); + if (first) { first.checked = true; update(); } + }); + const picked = destCard.querySelector('[data-into-row] input:checked'); + if (picked) picked.closest('[data-into-row]').scrollIntoView({block: 'nearest'}); + } + form.addEventListener('change', update); form.addEventListener('input', e => { if (e.target.matches('[data-axis-list]')) update(); });