-
-
-
- @if (catalog.availabilityDetails(catalog.tooltipData()).length) {
-
- }
+
}
} @else {
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts
index 9ce73c71..22328e2e 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts
@@ -1,6 +1,7 @@
import type { MockedObject, Mock } from 'vitest';
import { provideZonelessChangeDetection, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
import { ErrorService } from '../../../services/error.service';
@@ -12,6 +13,7 @@ import { AlgorithmConfig } from '../../../models/algorithm-definition.model';
import { AlgorithmPanelComponent } from './algorithm-panel.component';
import { createSpyObj } from '../../../testing/spy-obj.testing';
import { AlgorithmCatalogService } from './algorithm-selection/algorithm-catalog.service';
+import { AlgorithmCatalogComponent } from './algorithm-selection/algorithm-catalog/algorithm-catalog.component';
import { AlgorithmConfigFormService } from './algorithm-selection/algorithm-config-form.service';
import { normalizeParameterValues } from './utils/algorithm-parameter-form.utils';
@@ -158,6 +160,9 @@ describe('AlgorithmPanelComponent', () => {
/** The panel's feature services, as its template sees them. */
const catalog = () => fixture.debugElement.injector.get(AlgorithmCatalogService);
const configForm = () => fixture.debugElement.injector.get(AlgorithmConfigFormService);
+ /** Picks a method as a click on its catalog tile does, even when its group is collapsed. */
+ const chooseAlgorithm = (next: AlgorithmConfig) =>
+ fixture.debugElement.query(By.directive(AlgorithmCatalogComponent)).componentInstance.onAlgorithmClick(next);
it('groups default optional parameter fields under a closed Advanced disclosure', async () => {
fixture.componentInstance.setStudioSubstep('parameters');
@@ -378,7 +383,7 @@ describe('AlgorithmPanelComponent', () => {
const unavailableListItem = (fixture.nativeElement as HTMLElement).querySelector('.algo-tile.disabled-algo') as HTMLElement;
expect(getComputedStyle(unavailableListItem).cursor).toBe('pointer');
- fixture.componentInstance.onAlgorithmClick(unavailableAlgorithm);
+ chooseAlgorithm(unavailableAlgorithm);
fixture.detectChanges();
await fixture.whenStable();
@@ -829,7 +834,7 @@ describe('AlgorithmPanelComponent', () => {
expect(root.querySelector('.algo-tile')).toBeTruthy();
expect(root.querySelector('.documentation-panel')).toBeNull();
- fixture.componentInstance.onAlgorithmClick(algorithm);
+ chooseAlgorithm(algorithm);
fixture.detectChanges();
await fixture.whenStable();
@@ -845,7 +850,7 @@ describe('AlgorithmPanelComponent', () => {
});
it('renders setup and configuration in one unified card', () => {
- fixture.componentInstance.onAlgorithmClick(algorithm);
+ chooseAlgorithm(algorithm);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
@@ -878,7 +883,7 @@ describe('AlgorithmPanelComponent', () => {
expect(root.querySelector('.algorithm-details')).toBeNull();
expect(column?.contains(params ?? null)).toBe(true);
- fixture.componentInstance.onAlgorithmClick(algorithm);
+ chooseAlgorithm(algorithm);
fixture.detectChanges();
await fixture.whenStable();
@@ -1022,7 +1027,7 @@ describe('AlgorithmPanelComponent', () => {
fixture.detectChanges();
expect(rows[0].querySelector('.algo-tile')).toBeNull();
- fixture.componentInstance.onAlgorithmClick(groupComparison[1]);
+ chooseAlgorithm(groupComparison[1]);
fixture.detectChanges();
await fixture.whenStable();
@@ -1045,7 +1050,7 @@ describe('AlgorithmPanelComponent', () => {
expect(root.querySelector('.algorithm-readonly-fieldset')).toBeNull();
expect(root.querySelector('.documentation-panel')).toBeNull();
- fixture.componentInstance.onAlgorithmClick(algorithm);
+ chooseAlgorithm(algorithm);
fixture.detectChanges();
expect(root.querySelector('.documentation-panel')).toBeTruthy();
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts
index 05415f11..9d967245 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts
@@ -1,30 +1,22 @@
-import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
-import { CommonModule } from '@angular/common';
-import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ExperimentStudioService } from '../../../services/experiment-studio.service';
-import { ExperimentStudioNavigationService } from '../../../services/experiment-studio-navigation.service';
-import { AlgorithmConfig } from '../../../models/algorithm-definition.model';
-import { OUTLIER_STRATEGIES, OUTLIER_TAILS } from '../../../core/outlier-rules';
-import { AlgorithmRoleAssignmentComponent } from './algorithm-role-assignment/algorithm-role-assignment.component';
-import { availabilityRequirementText } from './utils/algorithm-availability-text.utils';
-import { runnableCount } from './utils/algorithm-catalog.utils';
-import { allowsDecimal, isAdvancedField, optionLabel } from './utils/algorithm-parameter-form.utils';
-import { tooltipPosition } from './utils/tooltip-position.utils';
+import { AlgorithmSelectionComponent } from './algorithm-selection/algorithm-selection.component';
+import { AlgorithmAvailabilityTooltipComponent } from './algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component';
import { AlgorithmCatalogService } from './algorithm-selection/algorithm-catalog.service';
import { AlgorithmConfigFormService } from './algorithm-selection/algorithm-config-form.service';
import { AlgorithmRunService } from './experiment-result/algorithm-run.service';
import { AlgorithmStudioSubstepService } from './algorithm-studio-substep.service';
-import { AlgorithmRunRequirement, AlgorithmStudioSubstep, OutlierReportVariable } from './algorithm-panel.models';
+import { AlgorithmStudioSubstep } from './algorithm-panel.models';
+/**
+ * The studio's Algorithm step. A shell: it provides the feature services its sections share,
+ * lays out the algorithm card and the availability tooltip, and keeps the API the studio calls
+ * (run state for the run button, the run itself, the exports and the sub-step).
+ */
@Component({
selector: 'app-algorithm-panel',
- imports: [
- CommonModule,
- FormsModule,
- ReactiveFormsModule,
- AlgorithmRoleAssignmentComponent,
- ],
+ imports: [AlgorithmSelectionComponent, AlgorithmAvailabilityTooltipComponent],
providers: [
AlgorithmStudioSubstepService,
AlgorithmCatalogService,
@@ -36,12 +28,11 @@ import { AlgorithmRunRequirement, AlgorithmStudioSubstep, OutlierReportVariable
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AlgorithmPanelComponent {
- private readonly studio = inject(ExperimentStudioService);
- private readonly studioNavigation = inject(ExperimentStudioNavigationService);
private readonly substep = inject(AlgorithmStudioSubstepService);
protected readonly catalog = inject(AlgorithmCatalogService);
- protected readonly form = inject(AlgorithmConfigFormService);
protected readonly run = inject(AlgorithmRunService);
+ /** The card shows once the studio has variables to assign. */
+ protected readonly assignablePool = inject(ExperimentStudioService).algorithmAssignableVariables;
/** Studio: setup (roles + methods), then parameters (configure + run). Results live in the Execution step. */
readonly studioSubstep = this.substep.current;
@@ -51,20 +42,6 @@ export class AlgorithmPanelComponent {
readonly canRun = this.run.canRun;
readonly runDisabledReason = this.run.runDisabledReason;
- protected readonly selectedAlgorithm = this.studio.selectedAlgorithm;
- protected readonly selectedAlgorithmDocumentation = computed(() => this.selectedAlgorithm()?.documentation?.trim() ?? '');
- protected readonly selectedAlgorithmCategory = computed(() => this.selectedAlgorithm()?.category?.trim() ?? '');
- /** Assignable pool drives the role-assignment UI (shown without a selected algorithm). */
- protected readonly assignablePool = this.studio.algorithmAssignableVariables;
-
- protected readonly outlierStrategies = OUTLIER_STRATEGIES;
- protected readonly outlierTails = OUTLIER_TAILS;
- protected readonly categoryRunnableCount = runnableCount;
- protected readonly isAdvancedField = isAdvancedField;
- protected readonly optionLabel = optionLabel;
- protected readonly allowsDecimal = allowsDecimal;
- protected readonly availabilityRequirementText = availabilityRequirementText;
-
setStudioSubstep(step: AlgorithmStudioSubstep): void {
this.substep.set(step);
}
@@ -83,85 +60,4 @@ export class AlgorithmPanelComponent {
onExportResultZip(): void {
this.run.exportZip();
}
-
- selectAlgorithm(algorithm: AlgorithmConfig): void {
- this.form.resetForNewAlgorithm();
- // Use service method so it enriches configSchema and persists to sessionStorage
- this.studio.setAlgorithm(algorithm);
- this.run.clearRunState();
- }
-
- onAlgorithmClick(algorithm: AlgorithmConfig): void {
- this.selectAlgorithm(algorithm);
- // Parent bookkeeping only: the details and parameters bands render from
- // selectedAlgorithm() below the row, so no substep navigation or page scroll.
- this.setStudioSubstep('parameters');
- this.catalog.hideTooltip();
- }
-
- onSearchInput(event: Event): void {
- this.catalog.algoSearchQuery.set((event.target as HTMLInputElement)?.value ?? '');
- }
-
- toggleCrossValidation(event: Event): void {
- this.form.setCrossValidation(!!(event.target as HTMLInputElement | null)?.checked);
- }
-
- outlierVariableLabel(variable: OutlierReportVariable): string {
- return variable?.label ?? variable?.name ?? variable?.code ?? '';
- }
-
- resolveRunRequirement(requirement: AlgorithmRunRequirement): void {
- if (requirement.id === 'preprocessing') {
- this.studioNavigation.goToPreprocessing();
- return;
- }
- // Availability issues are fixed on the setup board, which stays on screen above.
- this.setStudioSubstep('setup');
- requestAnimationFrame(() =>
- this.scrollStudioTargetIntoView(
- document.querySelector
('[data-guide="guide-role-assignment"]'),
- true
- )
- );
- }
-
- /** Scrolls a studio anchor; `scroll-margin-top` on the target clears sticky chrome. */
- private scrollStudioTargetIntoView(target: HTMLElement | null, focus = false): void {
- if (!target) return;
- target.scrollIntoView({ behavior: 'smooth', block: 'start' });
- if (focus) target.focus({ preventScroll: true });
- }
-
- showTooltip(algorithm: AlgorithmConfig, event: MouseEvent): void {
- event.stopPropagation();
- const item = (event.currentTarget as HTMLElement)?.closest('li') || (event.currentTarget as HTMLElement);
- const rect = item.getBoundingClientRect();
- const container = item.closest('.experiment-container') as HTMLElement | null;
- const containerRect = container?.getBoundingClientRect() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight);
-
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
- const padding = 16;
- const offset = 12;
-
- this.catalog.tooltipVisible.set(true);
- this.catalog.tooltipData.set(algorithm);
-
- // Immediate initial placement
- this.catalog.tooltipPosition.set(
- tooltipPosition(rect, containerRect, { width: 320, height: 200, viewportWidth, viewportHeight, padding, offset })
- );
-
- // Refinement after render
- setTimeout(() => {
- const el = document.querySelector('.tooltip') as HTMLElement | null;
- if (!el) return;
- const w = el.offsetWidth || 320;
- const h = el.offsetHeight || 200;
- this.catalog.tooltipPosition.set(
- tooltipPosition(rect, containerRect, { width: w, height: h, viewportWidth, viewportHeight, padding, offset })
- );
- }, 0);
- }
}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css
new file mode 100644
index 00000000..abcd3188
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css
@@ -0,0 +1,54 @@
+.tooltip-title {
+ margin: 0 0 8px;
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--primary-color);
+ line-height: 1.3;
+}
+
+.tooltip-description {
+ font-size: 0.82rem;
+ line-height: 1.5;
+ color: var(--text-muted);
+ margin-bottom: 0;
+}
+
+.tooltip-requirements {
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid rgba(226, 232, 240, 0.6);
+}
+
+.tooltip-requirements-header {
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #94a3b8;
+ margin-bottom: 8px;
+}
+
+.req-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ margin-bottom: 8px;
+}
+
+.req-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.req-text {
+ font-size: 0.78rem;
+ color: var(--chart-label-color);
+ line-height: 1.4;
+}
+
+.req-error {
+ font-size: 0.72rem;
+ color: #b45309;
+ line-height: 1.35;
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html
new file mode 100644
index 00000000..6048a213
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html
@@ -0,0 +1,18 @@
+
+
+
+@if (details().length) {
+
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts
new file mode 100644
index 00000000..580479d6
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts
@@ -0,0 +1,18 @@
+import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
+import { AlgorithmConfig } from '../../../../../models/algorithm-definition.model';
+import { availabilityRequirementText } from '../../utils/algorithm-availability-text.utils';
+import { AlgorithmCatalogService } from '../algorithm-catalog.service';
+
+/** A method's description and the role checks behind its availability. */
+@Component({
+ selector: 'div[app-algorithm-availability-tooltip]',
+ templateUrl: './algorithm-availability-tooltip.component.html',
+ styleUrl: './algorithm-availability-tooltip.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AlgorithmAvailabilityTooltipComponent {
+ readonly algorithm = input(null);
+ private readonly catalog = inject(AlgorithmCatalogService);
+ protected readonly details = computed(() => this.catalog.availabilityDetails(this.algorithm()));
+ protected readonly availabilityRequirementText = availabilityRequirementText;
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.css
new file mode 100644
index 00000000..76ea82c8
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.css
@@ -0,0 +1,352 @@
+@import '../../styles/sr-only.shared.css';
+@import '../../styles/step-kicker.shared.css';
+
+/* LEFT OF THE BOTTOM ROW: ALGORITHM CATALOG */
+/* The catalog title mirrors the pool's pane head so the two panes read as peers. */
+.catalog-title {
+ margin: 0;
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--primary-color);
+}
+
+.algorithm-match-empty {
+ margin: 0;
+ padding: 14px 12px;
+ font-size: 0.78rem;
+ color: var(--text-muted);
+ line-height: 1.45;
+ border: 1px dashed rgba(148, 163, 184, 0.4);
+ border-radius: var(--radius-md, 8px);
+}
+
+/* Search */
+.catalog-search {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+/* The clear button overlays the field, so the field — not the row — is its containing
+ block; the filter chip sits beside the field instead of under it. */
+.catalog-search-field {
+ position: relative;
+ display: block;
+ flex: 1 1 auto;
+ /* Below this the chip drops to its own line rather than crushing the field: the
+ row wraps on this minimum plus the chip, so the field never shrinks under a
+ readable width when the catalog is narrow. */
+ min-width: 8rem;
+}
+
+.catalog-search-input {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+ padding: 8px 34px 8px 12px;
+ border-radius: var(--radius-md, 8px);
+ border: 1px solid rgba(148, 163, 184, 0.34);
+ background: var(--studio-card-bg);
+ color: var(--text-main);
+ font: inherit;
+ font-size: 0.85rem;
+ line-height: 1.4;
+}
+
+.catalog-search-input:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 3px rgba(127, 156, 232, 0.16);
+}
+
+.catalog-search-input::placeholder {
+ color: #94a3b8;
+}
+
+.catalog-search-clear {
+ position: absolute;
+ right: 6px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ padding: 0;
+ border: 0;
+ border-radius: var(--radius-sm, 4px);
+ background: transparent;
+ color: var(--text-muted);
+ font: inherit;
+ font-size: 1rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.catalog-search-clear:hover,
+.catalog-search-clear:focus-visible {
+ background: color-mix(in srgb, var(--primary-color) 8%, white);
+ color: var(--text-main);
+}
+
+/* One status chip (All / Runnable) sits on the search row — see .pill-toggle below. */
+/* Method list. Category kicker: chevron first, then the group name (per the mock). */
+.algorithm-match-rows {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: auto;
+ padding-right: 2px;
+}
+
+.algorithm-match-row {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+/* Category kicker doubles as the disclosure control for its tiles. */
+.algorithm-match-cat {
+ appearance: none;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ margin: 0;
+ padding: 2px 0;
+ border: 0;
+ background: none;
+ font-family: inherit;
+ font-size: 0.8rem;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ color: var(--primary-color);
+ line-height: 1.3;
+ text-align: left;
+ cursor: pointer;
+}
+
+.algorithm-match-cat-name {
+ min-width: 0;
+}
+
+.algorithm-match-cat-meta {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ order: -1; /* chevron sits before the label */
+ color: var(--primary-color);
+}
+
+/* Collapsed points right (›), expanded points down (⌄). */
+.algorithm-match-cat-meta::after {
+ content: "";
+ width: 0.4rem;
+ height: 0.4rem;
+ border-right: 1.5px solid currentColor;
+ border-bottom: 1.5px solid currentColor;
+ transform: rotate(-45deg);
+ transition: transform 0.2s ease;
+}
+
+.algorithm-match-cat[aria-expanded="true"] .algorithm-match-cat-meta::after {
+ transform: rotate(45deg);
+}
+
+.algorithm-match-cat:hover .algorithm-match-cat-meta,
+.algorithm-match-cat:focus-visible .algorithm-match-cat-meta {
+ color: var(--primary-color);
+}
+
+.algorithm-match-tiles {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+/* Algorithm row: plain label with trailing role chips; no selected highlight —
+ the chosen method is named in the details and parameters bands below. */
+.algo-tile {
+ appearance: none;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 2px;
+ width: 100%;
+ max-width: 100%;
+ border-radius: var(--radius-md, 8px);
+ border: 1px solid transparent;
+ background: #ffffff;
+ color: var(--text-main);
+ font: inherit;
+ font-size: 0.8rem;
+ font-weight: 500;
+ line-height: 1.45;
+ text-align: left;
+ cursor: pointer;
+ transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
+}
+
+.algo-tile:hover:not(.disabled-algo),
+.algo-tile:focus-visible:not(.disabled-algo) {
+ border-color: rgba(43, 51, 233, 0.22);
+ background: color-mix(in srgb, var(--primary-light) 10%, white);
+}
+
+/* Selected method: accent bar, primary border, and a subtle tint so the tile the
+ configuration column on the right refers to is unmistakable. */
+.algo-tile.selected:not(.disabled-algo) {
+ border-color: var(--primary-color);
+ background: color-mix(in srgb, var(--primary-color) 8%, white);
+ box-shadow: inset 3px 0 0 var(--primary-color);
+}
+
+/* The chevron points from the chosen tile into the configuration column, so the eye
+ lands on the details without scanning. Hidden until the tile is the selection. */
+.algo-tile-cue {
+ display: none;
+ flex: 0 0 auto;
+ width: 6px;
+ height: 6px;
+ margin-left: auto;
+ border-top: 2px solid var(--primary-color);
+ border-right: 2px solid var(--primary-color);
+ transform: rotate(45deg);
+}
+
+.algo-tile.selected:not(.disabled-algo) .algo-tile-cue {
+ display: block;
+}
+
+.algo-tile.selected:not(.disabled-algo) .algo-label {
+ color: var(--primary-color);
+ font-weight: 700;
+}
+
+.algo-tile-top {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ min-width: 0;
+}
+
+.algo-tile .algo-label {
+ min-width: 0;
+ color: var(--text-main);
+ font-weight: 500;
+ overflow-wrap: anywhere;
+}
+
+/* Required-input chips: amber y (outcome), lavender x (predictor) */
+.algo-why {
+ display: block;
+ font-weight: 500;
+ line-height: 1.4;
+}
+
+.algo-tile.disabled-algo {
+ cursor: pointer;
+ background: color-mix(in srgb, var(--bg-neutral, #f8fafc) 55%, white);
+ border-color: transparent;
+ box-shadow: none;
+ color: var(--text-muted);
+}
+
+.algo-tile.disabled-algo .algo-label {
+ color: var(--text-muted);
+}
+
+.algo-tile.disabled-algo:hover,
+.algo-tile.disabled-algo:focus-visible {
+ background: color-mix(in srgb, var(--text-muted) 8%, white);
+}
+
+/* A read-only preview selection of an unavailable method: same accent bar, muted. */
+.algo-tile.disabled-algo.selected {
+ border-color: rgba(100, 116, 139, 0.55);
+ background: color-mix(in srgb, var(--text-muted) 8%, white);
+ box-shadow: inset 3px 0 0 rgba(100, 116, 139, 0.55);
+}
+
+@media (max-width: 640px) {
+ .algo-why {
+ max-width: 100%;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .algo-tile,
+ .algorithm-match-cat-meta::after {
+ transition: none;
+ }
+}
+
+/* =========================================================================
+ Algorithm panel review pass: step kickers, compact role band, catalog,
+ needs checklist, parameter layout, advanced grouping, option switches,
+ and the readiness bar.
+ ========================================================================= */
+.algorithm-step-kicker {
+ padding: 10px 14px 0;
+}
+
+.catalog-mode-tabs {
+ flex: 0 0 auto;
+}
+
+.catalog-mode-tabs button {
+ gap: 4px;
+ min-height: 26px;
+ padding: 0 10px;
+ font-size: 0.74rem;
+}
+
+.catalog-title.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+}
+
+.algorithm-match-cat-count {
+ margin-left: auto;
+ padding-left: 10px;
+ color: var(--text-muted);
+ font-size: 0.72rem;
+ font-weight: 600;
+}
+
+.algo-tile {
+ min-height: 36px;
+ padding: 8px 10px;
+}
+
+.algo-tile-top::before {
+ content: '';
+ flex: 0 0 auto;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ box-shadow: inset 0 0 0 1.5px rgba(71, 85, 105, 0.45);
+}
+
+.algo-tile.selected .algo-tile-top::before {
+ box-shadow: inset 0 0 0 3.5px var(--primary-color);
+}
+
+.algo-tile.disabled-algo .algo-tile-top::before {
+ box-shadow: inset 0 0 0 1.5px rgba(71, 85, 105, 0.25);
+}
+
+.algo-why {
+ font-size: 0.74rem;
+ color: #78350f;
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.html
new file mode 100644
index 00000000..8332508b
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.html
@@ -0,0 +1,83 @@
+2 Method
+Algorithm catalog
+
+
+
+
+
+ @if (catalog.searchActive()) {
+
+ }
+
+
+
+
+
+
+
+@if (catalog.hasAnyVisibleAlgorithms()) {
+
+ @for (category of catalog.filteredAlgorithmCategories(); track category.name) {
+
+
+ @if (catalog.isCategoryOpen(category.name) || catalog.searchActive()) {
+
+ @for (algorithm of category.algorithms; track algorithm.name) {
+
+ }
+
+ }
+
+ }
+
+} @else if (catalog.searchActive()) {
+No methods match “{{ catalog.algoSearchQuery() }}”.
+} @else if (catalog.showOnlyActive()) {
+No runnable methods for this assignment. Switch to All to see why.
+} @else {
+No methods in the catalog.
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts
new file mode 100644
index 00000000..18aba2b2
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts
@@ -0,0 +1,73 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { ExperimentStudioService } from '../../../../../services/experiment-studio.service';
+import { AlgorithmConfig } from '../../../../../models/algorithm-definition.model';
+import { runnableCount } from '../../utils/algorithm-catalog.utils';
+import { tooltipPosition } from '../../utils/tooltip-position.utils';
+import { AlgorithmRunService } from '../../experiment-result/algorithm-run.service';
+import { AlgorithmStudioSubstepService } from '../../algorithm-studio-substep.service';
+import { AlgorithmCatalogService } from '../algorithm-catalog.service';
+import { AlgorithmConfigFormService } from '../algorithm-config-form.service';
+
+/** The method catalog: search, the Runnable/All switch, and the method groups to pick from. */
+@Component({
+ selector: 'aside[app-algorithm-catalog]',
+ templateUrl: './algorithm-catalog.component.html',
+ styleUrl: './algorithm-catalog.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AlgorithmCatalogComponent {
+ private readonly studio = inject(ExperimentStudioService);
+ private readonly configForm = inject(AlgorithmConfigFormService);
+ private readonly run = inject(AlgorithmRunService);
+ private readonly substep = inject(AlgorithmStudioSubstepService);
+ protected readonly catalog = inject(AlgorithmCatalogService);
+ protected readonly selectedAlgorithm = this.studio.selectedAlgorithm;
+ protected readonly categoryRunnableCount = runnableCount;
+
+ onSearchInput(event: Event): void {
+ this.catalog.algoSearchQuery.set((event.target as HTMLInputElement)?.value ?? '');
+ }
+
+ /** Selects the method; its details and parameters render beside the catalog, so nothing scrolls. */
+ onAlgorithmClick(algorithm: AlgorithmConfig): void {
+ this.configForm.resetForNewAlgorithm();
+ // Use service method so it enriches configSchema and persists to sessionStorage
+ this.studio.setAlgorithm(algorithm);
+ this.run.clearRunState();
+ this.substep.set('parameters');
+ this.catalog.hideTooltip();
+ }
+
+ /** Opens the availability tooltip beside a method. Nothing in the catalog calls it at the moment. */
+ showTooltip(algorithm: AlgorithmConfig, event: MouseEvent): void {
+ event.stopPropagation();
+ const item = (event.currentTarget as HTMLElement)?.closest('li') || (event.currentTarget as HTMLElement);
+ const rect = item.getBoundingClientRect();
+ const container = item.closest('.experiment-container') as HTMLElement | null;
+ const containerRect = container?.getBoundingClientRect() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight);
+
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+ const padding = 16;
+ const offset = 12;
+
+ this.catalog.tooltipVisible.set(true);
+ this.catalog.tooltipData.set(algorithm);
+
+ // Immediate initial placement
+ this.catalog.tooltipPosition.set(
+ tooltipPosition(rect, containerRect, { width: 320, height: 200, viewportWidth, viewportHeight, padding, offset })
+ );
+
+ // Refinement after render
+ setTimeout(() => {
+ const el = document.querySelector('.tooltip') as HTMLElement | null;
+ if (!el) return;
+ const w = el.offsetWidth || 320;
+ const h = el.offsetHeight || 200;
+ this.catalog.tooltipPosition.set(
+ tooltipPosition(rect, containerRect, { width: w, height: h, viewportWidth, viewportHeight, padding, offset })
+ );
+ }, 0);
+ }
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.css
new file mode 100644
index 00000000..59398faa
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.css
@@ -0,0 +1,124 @@
+@import '../../styles/form-controls.shared.css';
+
+/* Label + "Required" */
+:host(.config-field) > label {
+ font-weight: 700;
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ margin-bottom: 5px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.config-field-desc {
+ margin: 0 0 8px;
+ font-size: 0.8rem;
+ line-height: 1.45;
+ color: #64748b;
+}
+
+.config-field-hint {
+ margin: 0 0 8px;
+ font-size: 0.78rem;
+ line-height: 1.4;
+ color: var(--text-muted);
+}
+
+:host(.config-field) > label {
+ grid-column: 1;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
+ padding-top: 8px;
+ font-size: 0.84rem;
+ font-weight: 600;
+ color: var(--text-main);
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.config-field-desc,
+.config-field-hint {
+ grid-column: 1;
+ margin: 0;
+ font-size: 0.76rem;
+}
+
+:host(.config-field) > .config-input,
+:host(.config-field) > .config-select,
+:host(.config-field) > .checkbox-wrapper,
+:host(.config-field) > .chip-picker,
+:host(.config-field) > .validation-message {
+ grid-column: 2;
+ grid-row: 1 / span 3;
+}
+
+.optional-tag {
+ margin-left: 4px;
+ font-size: 0.66rem;
+ font-weight: 600;
+ color: var(--text-muted);
+}
+
+.chip-picker {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ min-width: 0;
+}
+
+.chip-picker-option {
+ min-height: 28px;
+ padding: 0 10px;
+ border: 1px solid rgba(148, 163, 184, 0.34);
+ border-radius: 999px;
+ background: #fff;
+ color: var(--text-main);
+ font: inherit;
+ font-size: 0.76rem;
+ cursor: pointer;
+}
+
+.chip-picker-option.active {
+ border-color: var(--primary-color);
+ background: color-mix(in srgb, var(--primary-color) 8%, white);
+ color: var(--primary-color);
+ font-weight: 600;
+}
+
+/* Muted validation: invalid keeps a danger edge; valid is neutral. Kept in this sheet rather
+ than in the form-controls partial: it must come after the rules above to win their ties. */
+.config-input.ng-valid.ng-touched,
+.config-select.ng-valid.ng-touched {
+ border: 1px solid rgba(148, 163, 184, 0.34);
+ background: #fff;
+ background-image: none;
+ box-shadow: none;
+ padding-right: 12px;
+}
+
+.config-input.ng-invalid.ng-touched,
+.config-select.ng-invalid.ng-touched {
+ border: 1px solid var(--danger);
+ background-color: #fff;
+ background-image: none;
+ box-shadow: none;
+ padding-right: 12px;
+}
+
+.config-input.ng-invalid.ng-touched:focus,
+.config-select.ng-invalid.ng-touched:focus {
+ border-color: var(--danger);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 16%, transparent);
+}
+
+@media (max-width: 900px) {
+ :host(.config-field) > * {
+ grid-column: 1 !important;
+ grid-row: auto !important;
+ }
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.html
new file mode 100644
index 00000000..807991a0
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.html
@@ -0,0 +1,93 @@
+@let field = this.field();
+@let control = form.configForm().get(field.key);
+
+
+@if (field.desc) {
+{{ field.desc }}
+}
+
+@if (field.enumType === 'input_var_names' && field.type === 'select' && (!field.options || !field.options.length)) {
+
+ @if (field.enumSource?.includes('x')) {
+ Select covariates first. This parameter must be one of the variables in x.
+ } @else if (field.enumSource?.includes('y')) {
+ Select a variable first. This parameter must be one of the variables in y.
+ } @else {
+ Select the required variables before choosing this parameter.
+ }
+
+}
+
+@if (field.key === 'positive_class' && !form.selectedEventVarCode()) {
+Select an event indicator variable first.
+}
+
+@if (field.key === 'positive_class' && form.selectedEventVarCode() && form.positiveClassOptions().length) {
+
+ Select the event level mapped to 1; all other levels are mapped to 0.
+
+}
+
+@if (field.key === 'positive_class' && form.selectedEventVarCode() && !form.positiveClassOptions().length) {
+
+ This event variable has no catalog categories. Enter the value treated as event=1 (for example 0 or 1).
+
+}
+
+@if (field.type === 'number') {
+
+
+} @else if (field.type === 'select') {
+
+} @else if (field.type === 'multi-select') {
+
+ @for (opt of field.options; track opt) {
+
+ }
+
+} @else if (field.type === 'checkbox') {
+
+} @else {
+
+}
+
+@if (control?.invalid && control?.touched) {
+
+ @if (control?.hasError('required')) {
+ This field is required.
+ } @if (control?.hasError('min')) {
+ Value too small.
+ } @if (control?.hasError('max')) {
+ Value too large.
+ } @if (control?.hasError('pattern')) {
+ Invalid value.
+ } @if (control?.hasError('invalidOption')) {
+ Select a value from the list.
+ }
+
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.ts
new file mode 100644
index 00000000..d46ce541
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.ts
@@ -0,0 +1,25 @@
+import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
+import { ControlContainer, FormGroupDirective, ReactiveFormsModule } from '@angular/forms';
+import { allowsDecimal, optionLabel } from '../../utils/algorithm-parameter-form.utils';
+import { EnrichedParameterField } from '../../algorithm-panel.models';
+import { AlgorithmConfigFormService } from '../algorithm-config-form.service';
+
+/**
+ * One parameter of the selected method: its label, hints, the control for its type, and its
+ * validation messages. Attached to the `.config-field` element, which carries the form group.
+ */
+@Component({
+ selector: 'div[app-algorithm-config-field]',
+ imports: [ReactiveFormsModule],
+ // The form group sits on the host element; this lets formControlName in the view find it.
+ viewProviders: [{ provide: ControlContainer, useExisting: FormGroupDirective }],
+ templateUrl: './algorithm-config-field.component.html',
+ styleUrl: './algorithm-config-field.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AlgorithmConfigFieldComponent {
+ readonly field = input.required();
+ protected readonly form = inject(AlgorithmConfigFormService);
+ protected readonly allowsDecimal = allowsDecimal;
+ protected readonly optionLabel = optionLabel;
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css
new file mode 100644
index 00000000..eae9f363
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css
@@ -0,0 +1,296 @@
+@import '../../styles/config-placeholder.shared.css';
+@import '../../styles/sr-only.shared.css';
+@import '../../styles/step-kicker.shared.css';
+
+.algorithm-params__body {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ min-width: 0;
+}
+
+.algorithm-params__empty {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ border-radius: var(--radius-md, 8px);
+}
+
+.algorithm-params__empty-title {
+ margin: 0;
+ font-size: 0.92rem;
+ font-weight: 700;
+ color: var(--text-main);
+ line-height: 1.35;
+}
+
+.algorithm-params__empty-text {
+ margin: 0;
+ max-width: 30rem;
+ font-size: 0.8rem;
+ line-height: 1.6;
+ color: var(--text-muted);
+}
+
+/* Run requirements */
+.config-form {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.config-field {
+ flex-direction: column;
+}
+
+/* Fields are divided by a hairline rather than framed: the parameters read as one
+ form, and the card already paints the only container. */
+.config-field + .config-field {
+ border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1));
+ padding-top: 10px;
+}
+
+/* Outlier report table */
+.outlier-report-editor {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 4px 0;
+}
+
+/* Shared fieldset host: the unified card paints the frame, not the fieldset. */
+.algorithm-readonly-fieldset {
+ border: 0;
+ padding: 0;
+ margin: 0;
+ min-width: 0;
+}
+
+/* Needs checklist */
+.algo-needs {
+ display: grid;
+ gap: 4px;
+ margin: 0 0 14px;
+ padding: 10px 12px;
+ list-style: none;
+ border-radius: var(--radius-md);
+ background: var(--bg-neutral);
+ font-size: 0.8rem;
+}
+
+.algo-needs li {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 4px 8px;
+}
+
+.algo-needs-mark {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ box-shadow: inset 0 0 0 1.5px #a06b10;
+ transform: translateY(2px);
+}
+
+.algo-needs li.is-met .algo-needs-mark {
+ background: var(--success);
+ box-shadow: none;
+}
+
+.algo-needs small {
+ flex-basis: 100%;
+ padding-left: 20px;
+ color: #78350f;
+}
+
+.algo-needs-all-met {
+ margin: 0 0 14px;
+ padding: 8px 12px;
+ border-radius: var(--radius-md);
+ background: color-mix(in srgb, var(--success) 10%, white);
+ color: #065f46;
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+/* Parameters */
+.algorithm-params__title {
+ margin: 0 0 2px;
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: var(--letter-spacing-config);
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+
+/* Parameter rows: label beside control, sentence case, optional tag. */
+.config-field {
+ display: grid;
+ grid-template-columns: 220px minmax(0, 360px);
+ gap: 2px 16px;
+ align-items: start;
+ padding: 10px 0;
+}
+
+/* Advanced fields stay behind a disclosure, closed by default. */
+.config-advanced {
+ border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1));
+ margin-top: 4px;
+}
+
+.config-advanced__summary {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 0;
+ list-style: none;
+ cursor: pointer;
+ font-size: 0.84rem;
+ font-weight: 600;
+ color: var(--text-main);
+}
+
+.config-advanced__summary::-webkit-details-marker {
+ display: none;
+}
+
+.config-advanced__chevron {
+ width: 0.4rem;
+ height: 0.4rem;
+ border-right: 1.5px solid currentColor;
+ border-bottom: 1.5px solid currentColor;
+ transform: rotate(-45deg);
+ transition: transform 0.2s ease;
+}
+
+.config-advanced[open] > .config-advanced__summary .config-advanced__chevron {
+ transform: rotate(45deg);
+}
+
+.config-advanced__body {
+ padding-bottom: 6px;
+}
+
+/* Option rows: cross-validation uses the studio switch. */
+.config-option-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 12px 0;
+ border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1));
+}
+
+/* Settings that belong to the switch above sit under it, inset to read as its children. */
+.config-option-detail {
+ margin: -4px 0 8px;
+ padding-left: 14px;
+ border-left: 2px solid var(--studio-card-border, rgba(43, 51, 233, 0.1));
+}
+
+.config-option-detail .config-field {
+ grid-template-columns: 206px minmax(0, 360px);
+ padding: 4px 0;
+}
+
+.config-option-row.is-locked {
+ opacity: 0.86;
+}
+
+.config-option-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.config-option-label {
+ font-size: 0.84rem;
+ font-weight: 600;
+ color: var(--text-main);
+}
+
+.config-option-hint {
+ font-size: 0.76rem;
+ line-height: 1.4;
+ color: var(--text-muted);
+}
+
+.toggle-switch {
+ position: relative;
+ display: inline-block;
+ flex: 0 0 auto;
+ width: 44px;
+ height: 24px;
+}
+
+.toggle-switch.is-locked {
+ cursor: not-allowed;
+}
+
+.toggle-switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.toggle-switch input:focus-visible + .toggle-slider {
+ outline: 2px solid var(--primary-color);
+ outline-offset: 2px;
+}
+
+.toggle-slider {
+ position: absolute;
+ cursor: pointer;
+ inset: 0;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--text-muted) 75%, transparent);
+ background: color-mix(in srgb, var(--text-muted) 22%, white);
+ transition: background 0.22s ease, border-color 0.22s ease, box-shadow 0.22s ease;
+}
+
+.toggle-slider::before {
+ content: '';
+ position: absolute;
+ height: 18px;
+ width: 18px;
+ left: 3px;
+ top: 2.5px;
+ border-radius: 50%;
+ background: var(--card-bg);
+ box-shadow: 0 2px 6px rgba(15, 23, 42, 0.18), inset 0 0 0 1px rgba(15, 23, 42, 0.12);
+ transition: transform 0.22s var(--ease-standard, ease), box-shadow 0.22s ease;
+}
+
+.toggle-switch input:checked + .toggle-slider {
+ background: var(--primary-color);
+ border-color: rgba(43, 51, 233, 0.24);
+ box-shadow: 0 5px 14px color-mix(in srgb, var(--primary-color) 22%, transparent);
+}
+
+.toggle-switch input:checked + .toggle-slider::before {
+ transform: translateX(20px);
+}
+
+.toggle-switch input:disabled + .toggle-slider {
+ cursor: not-allowed;
+}
+
+/* Empty state under the ③ kicker, without the dashed frame. */
+.algorithm-params__empty {
+ align-items: flex-start;
+ justify-content: flex-start;
+ gap: 6px;
+ margin: 0;
+ padding: 16px;
+ border: 0;
+ background: transparent;
+}
+
+@media (max-width: 900px) {
+ .config-field,
+ .config-option-detail .config-field {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html
new file mode 100644
index 00000000..be4b3dc0
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html
@@ -0,0 +1,107 @@
+@if (selectedAlgorithm()) {
+
+ @if (run.algorithmNeeds().length) {
+
+ - Complete before running
+ @for (need of run.algorithmNeeds(); track need.key) {
+ -
+
+ {{ need.title }}
+ @for (message of need.messages; track message) {
+ {{ message }}
+ }
+ @if (need.action) {
+
+ }
+
+ }
+
+ } @else if (selectedAlgorithm()) {
+
✓ All inputs match
+ }
+
+
+
+
+} @else {
+
+
3
+
No algorithm selected
+
+ Select a runnable algorithm from the catalog to configure parameters and run the
+ experiment.
+
+
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts
new file mode 100644
index 00000000..d2d25220
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts
@@ -0,0 +1,55 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { ReactiveFormsModule } from '@angular/forms';
+import { ExperimentStudioService } from '../../../../../services/experiment-studio.service';
+import { ExperimentStudioNavigationService } from '../../../../../services/experiment-studio-navigation.service';
+import { AlgorithmRunService } from '../../experiment-result/algorithm-run.service';
+import { AlgorithmStudioSubstepService } from '../../algorithm-studio-substep.service';
+import { AlgorithmRunRequirement } from '../../algorithm-panel.models';
+import { AlgorithmCatalogService } from '../algorithm-catalog.service';
+import { AlgorithmConfigFormService } from '../algorithm-config-form.service';
+import { AlgorithmConfigFieldComponent } from '../algorithm-config-field/algorithm-config-field.component';
+import { OutlierReportEditorComponent } from '../outlier-report-editor/outlier-report-editor.component';
+
+/**
+ * The selected method's parameters: what still blocks the run, the parameter form with its
+ * Advanced fields, cross-validation, and the outlier_report editor. Read-only while the method
+ * is unavailable.
+ */
+@Component({
+ selector: 'section[app-algorithm-parameters]',
+ imports: [ReactiveFormsModule, AlgorithmConfigFieldComponent, OutlierReportEditorComponent],
+ templateUrl: './algorithm-parameters.component.html',
+ styleUrl: './algorithm-parameters.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AlgorithmParametersComponent {
+ private readonly studioNavigation = inject(ExperimentStudioNavigationService);
+ private readonly substep = inject(AlgorithmStudioSubstepService);
+ protected readonly catalog = inject(AlgorithmCatalogService);
+ protected readonly form = inject(AlgorithmConfigFormService);
+ protected readonly run = inject(AlgorithmRunService);
+ protected readonly selectedAlgorithm = inject(ExperimentStudioService).selectedAlgorithm;
+
+ toggleCrossValidation(event: Event): void {
+ this.form.setCrossValidation(!!(event.target as HTMLInputElement | null)?.checked);
+ }
+
+ resolveRunRequirement(requirement: AlgorithmRunRequirement): void {
+ if (requirement.id === 'preprocessing') {
+ this.studioNavigation.goToPreprocessing();
+ return;
+ }
+ // Availability issues are fixed on the setup board, which stays on screen above.
+ this.substep.set('setup');
+ requestAnimationFrame(() =>
+ scrollStudioTargetIntoView(document.querySelector('[data-guide="guide-role-assignment"]'), true)
+ );
+ }
+}
+
+/** Scrolls a studio anchor; `scroll-margin-top` on the target clears sticky chrome. */
+function scrollStudioTargetIntoView(target: HTMLElement | null, focus = false): void {
+ if (!target) return;
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ if (focus) target.focus({ preventScroll: true });
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.css
new file mode 100644
index 00000000..d03da9d9
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.css
@@ -0,0 +1,331 @@
+@import '../styles/step-kicker.shared.css';
+
+.algorithm-roles-board__header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px 16px;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08));
+ background: var(--studio-card-header-bg, #f8fbff);
+}
+
+.algorithm-roles-board__title {
+ margin: 0 0 2px;
+ font-weight: 700;
+ line-height: 1.25;
+ letter-spacing: 0.01em;
+ color: var(--text-main);
+}
+
+/* Live runnable/total counter — tabular so filtering never reflows the header. */
+.algorithm-count-badge {
+ gap: 6px;
+ flex: 0 0 auto;
+}
+
+.algorithm-count-badge strong {
+ color: var(--primary-color);
+ font-size: 1rem;
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.01em;
+}
+
+.algorithm-count-badge span {
+ font-variant-numeric: tabular-nums;
+}
+
+/* BOTTOM ROW: Algorithm catalog (left) · Algorithm configuration (right). The row
+ grows with its content (24rem floor) instead of being pinned to a short fixed
+ height, so the parameter selection expands and every parameter field is readable
+ in the normal flow — the window is the scroller rather than a nested inner scroll.
+ The assignment rails sit above the row in flow, so switching methods never moves
+ them out from under the pointer; only space below the row changes. */
+.algorithm-workspace {
+ display: grid;
+ grid-template-columns: minmax(300px, 20%) minmax(0, 80%); /* 20% catalog · 80% configuration */
+ min-width: 0;
+ min-height: 24rem;
+ height: auto;
+ overflow: hidden;
+}
+
+/* TOP BAND: Experiment pool across the full card width — the variable list on the left,
+ the stacked outcome and predictor rails beside it on the right — so the band only has
+ to be tall enough for the scrolling list. It is one shrinking grid track rather than a
+ stretched flex cross-size: that is what lets the band stop at its max-height and hand
+ the overflow to the list's own scroll, instead of clipping the hint out of the card. */
+.algorithm-pool {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: minmax(0, 1fr);
+ flex: 0 0 auto;
+ min-width: 0;
+ min-height: 0;
+ border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08));
+}
+
+.algorithm-pool > app-algorithm-role-assignment {
+ min-width: 0;
+ min-height: 0;
+}
+
+.algorithm-catalog {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px 14px 14px;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ color: var(--text-main);
+ box-sizing: border-box;
+ border-right: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08));
+}
+
+/* RIGHT OF THE BOTTOM ROW: ALGORITHM CONFIGURATION — documentation and parameter
+ fields share one column that grows with its content, so a parameter-rich method
+ reveals its whole form instead of clipping it under a fixed-height inner scroll.
+ The board paints the frame: this column adds no border, tint, or shadow of its
+ own. */
+.algorithm-config-column {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ overflow: visible;
+}
+
+/* PARAMETERS — continue the configuration flow directly under the documentation;
+ the unified card paints the frame. */
+.algorithm-params {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ gap: 10px;
+ padding: 0 16px 16px;
+ min-width: 0;
+ box-sizing: border-box;
+ color: var(--text-main);
+}
+
+.inspector-tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 1px 8px;
+ border-radius: 999px;
+ border: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.14));
+ background: var(--bg-neutral);
+ color: var(--text-muted);
+ font-size: 0.64rem;
+ font-weight: 700;
+}
+
+/* TOP OF THE CONFIGURATION COLUMN — one continuous Algorithm configuration section:
+ header, description, and documentation. It no longer pins: the column expands to
+ its content and the window scrolls, so a sticky head would hang over the scrolling
+ page and hide the parameter fields below it instead of helping them. */
+.algorithm-details {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ position: static;
+ background: var(--studio-card-bg);
+ color: var(--text-main);
+}
+
+.algorithm-details__header {
+ flex-direction: column;
+}
+
+.algorithm-details__heading {
+ flex-wrap: wrap;
+}
+
+/* Documentation */
+.documentation-panel {
+ margin: 0;
+}
+
+/* The native disclosure triangle is hidden, so the summary states its own affordance:
+ a chevron that turns with the state, driven by [open] on the itself. */
+.documentation-summary {
+ gap: 8px;
+ cursor: pointer;
+ list-style: none;
+}
+
+.documentation-summary::-webkit-details-marker {
+ display: none;
+}
+
+/* Same chevron geometry as the catalog groups: right when collapsed, down when open. */
+.documentation-chevron {
+ flex: 0 0 auto;
+ width: 0.45rem;
+ height: 0.45rem;
+ border-right: 1.5px solid currentColor;
+ border-bottom: 1.5px solid currentColor;
+ transform: rotate(-45deg);
+ transition: transform 0.2s ease;
+}
+
+.documentation-panel[open] > .documentation-summary .documentation-chevron {
+ transform: rotate(45deg);
+}
+
+.documentation-content {
+ font-size: 0.82rem;
+ line-height: 1.45;
+ color: rgba(20, 22, 31, 0.72);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.config-description {
+ margin: 0;
+ font-size: 0.86rem;
+ line-height: 1.55;
+ color: var(--text-muted);
+}
+
+/* RESPONSIVE LAYOUT */
+@media (max-width: 960px) {
+ .algorithm-workspace {
+ grid-template-columns: minmax(0, 1fr);
+ height: auto;
+ min-height: 0;
+ }
+ /* Stacked: head, rails, list and hint sit in one column, so the band hugs that
+ taller content and the window scrolls rather than the band scrolling inside it. */
+ .algorithm-pool {
+ border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08));
+ }
+ .algorithm-catalog {
+ border-right: 0;
+ border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08));
+ max-height: 24rem;
+ overflow: auto;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .documentation-chevron {
+ transition: none;
+ }
+}
+
+.algorithm-roles-board__title {
+ font-size: 0.96rem;
+}
+
+.algorithm-count-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 10px;
+ border: 0;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--primary-color) 8%, white);
+ color: var(--primary-color);
+ font-size: 0.72rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.algorithm-pool {
+ height: auto;
+ max-height: none;
+ overflow: visible;
+}
+
+.algorithm-pool > app-algorithm-role-assignment {
+ overflow: visible;
+}
+
+/* Configuration head: number ring, real method label, category tag. */
+.algorithm-details {
+ padding: 14px 16px 8px;
+}
+
+.algorithm-details__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.algorithm-details__heading {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.algorithm-details__title {
+ margin: 0;
+ min-width: 0;
+ font-size: 1.1rem;
+ font-weight: 700;
+ line-height: 1.3;
+ color: var(--text-main);
+ overflow-wrap: anywhere;
+}
+
+.inspector-tag {
+ flex: 0 0 auto;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.config-description {
+ max-width: 60ch;
+ margin-top: 6px;
+}
+
+/* Documentation is a quiet text link by default. */
+.documentation-panel {
+ margin-top: 2px;
+}
+
+.documentation-summary {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 0;
+ margin: 0;
+ border-radius: 0;
+ background: none;
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--primary-color);
+}
+
+.documentation-summary:hover {
+ background: none;
+ text-decoration: underline;
+}
+
+.documentation-chevron {
+ display: none;
+}
+
+.documentation-panel[open] > .documentation-summary {
+ border-bottom: 0;
+ border-radius: 0;
+}
+
+.documentation-content {
+ margin-top: 6px;
+ padding-left: 0;
+ border-left: 0;
+}
+
+@media (max-width: 900px) {
+ .algorithm-details__header {
+ flex-wrap: wrap;
+ }
+ .algorithm-workspace {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.html
new file mode 100644
index 00000000..736870a4
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+ @if (selectedAlgorithm(); as algorithm) {
+
+
+ @if (algorithm.description) {
+ {{ algorithm.description }}
+ }
+ @if (selectedAlgorithmDocumentation()) {
+
+
+
+ How this method works
+
+ {{ selectedAlgorithmDocumentation() }}
+
+ }
+
+ }
+
+
+
+
+
+
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.ts
new file mode 100644
index 00000000..68006f42
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.ts
@@ -0,0 +1,25 @@
+import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
+import { ExperimentStudioService } from '../../../../services/experiment-studio.service';
+import { AlgorithmRoleAssignmentComponent } from '../algorithm-role-assignment/algorithm-role-assignment.component';
+import { AlgorithmCatalogComponent } from './algorithm-catalog/algorithm-catalog.component';
+import { AlgorithmParametersComponent } from './algorithm-parameters/algorithm-parameters.component';
+import { AlgorithmCatalogService } from './algorithm-catalog.service';
+
+/**
+ * The algorithm card: role assignment, the method catalog, and the selected method's details
+ * and parameters. Attached to the card element the panel renders, so the DOM and its anchors
+ * are unchanged; the panel projects the run bar at the bottom of the card.
+ */
+@Component({
+ selector: 'section[app-algorithm-selection]',
+ imports: [AlgorithmRoleAssignmentComponent, AlgorithmCatalogComponent, AlgorithmParametersComponent],
+ templateUrl: './algorithm-selection.component.html',
+ styleUrl: './algorithm-selection.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AlgorithmSelectionComponent {
+ protected readonly catalog = inject(AlgorithmCatalogService);
+ protected readonly selectedAlgorithm = inject(ExperimentStudioService).selectedAlgorithm;
+ protected readonly selectedAlgorithmDocumentation = computed(() => this.selectedAlgorithm()?.documentation?.trim() ?? '');
+ protected readonly selectedAlgorithmCategory = computed(() => this.selectedAlgorithm()?.category?.trim() ?? '');
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.css
new file mode 100644
index 00000000..80480011
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.css
@@ -0,0 +1,102 @@
+@import '../../styles/config-placeholder.shared.css';
+@import '../../styles/form-controls.shared.css';
+
+.outlier-editor-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.outlier-editor-header h6 {
+ margin: 0;
+ font-size: 0.9rem;
+ font-weight: 700;
+ color: var(--text-main);
+}
+
+.outlier-count {
+ font-size: 0.72rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.outlier-table-wrapper {
+ overflow-x: auto;
+}
+
+.outlier-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8rem;
+}
+
+.outlier-table th {
+ padding: 6px 8px;
+ text-align: left;
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ border-bottom: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1));
+}
+
+.outlier-table td {
+ padding: 6px 8px;
+ vertical-align: top;
+ border-bottom: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1));
+}
+
+.outlier-table tr:last-child td {
+ border-bottom: 0;
+}
+
+.outlier-variable-label {
+ display: block;
+ font-weight: 600;
+ color: var(--text-main);
+}
+
+.outlier-table small {
+ color: var(--text-muted);
+ font-size: 0.68rem;
+}
+
+.outlier-table .compact-select,
+.outlier-table .compact-input {
+ min-width: 6.5rem;
+ padding: 5px 8px;
+ font-size: 0.78rem;
+}
+
+.outlier-enable {
+ min-height: 24px;
+}
+
+/* Muted validation: invalid keeps a danger edge; valid is neutral. Kept in this sheet rather
+ than in the form-controls partial: it must come after the rules above to win their ties. */
+.config-input.ng-valid.ng-touched,
+.config-select.ng-valid.ng-touched {
+ border: 1px solid rgba(148, 163, 184, 0.34);
+ background: #fff;
+ background-image: none;
+ box-shadow: none;
+ padding-right: 12px;
+}
+
+.config-input.ng-invalid.ng-touched,
+.config-select.ng-invalid.ng-touched {
+ border: 1px solid var(--danger);
+ background-color: #fff;
+ background-image: none;
+ box-shadow: none;
+ padding-right: 12px;
+}
+
+.config-input.ng-invalid.ng-touched:focus,
+.config-select.ng-invalid.ng-touched:focus {
+ border-color: var(--danger);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 16%, transparent);
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.html
new file mode 100644
index 00000000..aafc4ff6
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.html
@@ -0,0 +1,70 @@
+
+
+@if (form.outlierReportVariables().length > 0) {
+
+} @else {
+
+
+
No numerical variables selected
+
+
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.ts
new file mode 100644
index 00000000..d975cf64
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.ts
@@ -0,0 +1,23 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { OUTLIER_STRATEGIES, OUTLIER_TAILS } from '../../../../../core/outlier-rules';
+import { OutlierReportVariable } from '../../algorithm-panel.models';
+import { AlgorithmConfigFormService } from '../algorithm-config-form.service';
+
+/** outlier_report's per-variable rules: enabled, strategy, tail and fold for each numerical variable. */
+@Component({
+ selector: 'section[app-outlier-report-editor]',
+ imports: [FormsModule],
+ templateUrl: './outlier-report-editor.component.html',
+ styleUrl: './outlier-report-editor.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OutlierReportEditorComponent {
+ protected readonly form = inject(AlgorithmConfigFormService);
+ protected readonly outlierStrategies = OUTLIER_STRATEGIES;
+ protected readonly outlierTails = OUTLIER_TAILS;
+
+ variableLabel(variable: OutlierReportVariable): string {
+ return variable?.label ?? variable?.name ?? variable?.code ?? '';
+ }
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/styles/config-placeholder.shared.css b/src/app/pages/experiment-studio/algorithm-panel/styles/config-placeholder.shared.css
new file mode 100644
index 00000000..ecf5c1ec
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/styles/config-placeholder.shared.css
@@ -0,0 +1,41 @@
+/* Validation states: valid stays neutral; only the invalid edge and message signal a problem. */
+/* Placeholders */
+.config-placeholder {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 14px 16px;
+ border-radius: var(--radius-md, 8px);
+ border: 1px solid rgba(226, 232, 240, 0.95);
+ background: rgba(248, 250, 252, 0.65);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85);
+}
+
+.config-placeholder--minimal {
+ width: auto;
+ max-width: 100%;
+ min-height: 0;
+}
+
+.config-placeholder-content {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ max-width: 560px;
+}
+
+.config-placeholder-content h5,
+.config-placeholder-content h6 {
+ margin: 0;
+ color: var(--text-main);
+ font-size: 0.92rem;
+ font-weight: 700;
+}
+
+.config-placeholder-content p {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 0.82rem;
+ line-height: 1.6;
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/styles/form-controls.shared.css b/src/app/pages/experiment-studio/algorithm-panel/styles/form-controls.shared.css
new file mode 100644
index 00000000..19ef365f
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/styles/form-controls.shared.css
@@ -0,0 +1,58 @@
+/* Inputs / selects */
+.config-input,
+.config-select {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+ padding: 9px 12px;
+ background: rgba(255, 255, 255, 0.98);
+ border-radius: var(--radius-md, 8px);
+ border: 1px solid rgba(148, 163, 184, 0.34);
+ font-size: 0.85rem;
+ line-height: 1.4;
+ transition: border-color 0.18s ease, box-shadow 0.18s ease,
+ background-color 0.18s ease;
+ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.85) inset;
+}
+
+.config-select {
+ padding-right: 30px;
+}
+
+.config-select[multiple] {
+ min-height: 7rem;
+ padding-right: 12px;
+}
+
+.config-input:focus,
+.config-select:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ background-color: var(--studio-card-bg);
+ box-shadow:
+ 0 0 0 3px rgba(127, 156, 232, 0.16),
+ 0 4px 12px rgba(43, 51, 233, 0.08);
+}
+
+/* Checkbox wrapper */
+.checkbox-wrapper {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.85rem;
+ color: var(--text-main);
+}
+
+.checkbox-wrapper input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ border-radius: 5px;
+}
+
+/* Validation states */
+.config-error {
+ color: #b42318;
+ font-size: 0.74rem;
+ margin-top: 5px;
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/styles/sr-only.shared.css b/src/app/pages/experiment-studio/algorithm-panel/styles/sr-only.shared.css
new file mode 100644
index 00000000..5c260cfe
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/styles/sr-only.shared.css
@@ -0,0 +1,7 @@
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/styles/step-kicker.shared.css b/src/app/pages/experiment-studio/algorithm-panel/styles/step-kicker.shared.css
new file mode 100644
index 00000000..17c8525a
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/styles/step-kicker.shared.css
@@ -0,0 +1,21 @@
+.step-kicker {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: var(--letter-spacing-config);
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+
+.step-kicker-n {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ box-shadow: inset 0 0 0 1.5px var(--studio-card-border);
+ font-size: 0.68rem;
+}
From 5e32ef226a5b36a3ccb865d7eb40db02bfd0ba78 Mon Sep 17 00:00:00 2001
From: "K.Filippopolitis" <56073635+KFilippopolitis@users.noreply.github.com>
Date: Thu, 29 Oct 2026 18:35:35 +0200
Subject: [PATCH 6/8] refactor(algorithm-panel): extract experiment-result
components
div[app-experiment-result] renders the readiness bar the panel projects into
the card (is-ready as a host binding); ul[app-algorithm-run-requirements]
renders the run needs and resolves their actions. Both read
AlgorithmRunService, which owns the run, its error and the exports.
---
.../algorithm-panel.component.css | 28 ----------
.../algorithm-panel.component.html | 13 +----
.../algorithm-panel.component.ts | 5 +-
.../algorithm-parameters.component.css | 26 ----------
.../algorithm-parameters.component.html | 18 +------
.../algorithm-parameters.component.ts | 27 +---------
.../algorithm-run-requirements.component.css | 27 ++++++++++
.../algorithm-run-requirements.component.html | 15 ++++++
.../algorithm-run-requirements.component.ts | 37 +++++++++++++
.../experiment-result.component.css | 27 ++++++++++
.../experiment-result.component.html | 10 ++++
.../experiment-result.component.spec.ts | 52 +++++++++++++++++++
.../experiment-result.component.ts | 18 +++++++
13 files changed, 193 insertions(+), 110 deletions(-)
create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.css
create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.html
create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts
create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.css
create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.html
create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.spec.ts
create mode 100644 src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.ts
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css
index f32eceeb..b1a9f394 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css
@@ -113,35 +113,7 @@
color: var(--text-muted);
}
-.algorithm-run-status {
- display: flex;
- align-items: center;
- gap: 8px;
- min-width: 0;
-}
-
-.algorithm-run-dot {
- flex: 0 0 auto;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: #b45309;
-}
-
-.algorithm-run-bar.is-ready .algorithm-run-dot {
- background: var(--success);
-}
-
-.algorithm-run-text {
- min-width: 0;
-}
-
.algorithm-run-bar.is-ready {
color: #065f46;
font-weight: 600;
}
-
-.algorithm-run-error {
- flex-basis: 100%;
- color: #8a1f1f;
-}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html
index 917887a6..bb036102 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html
@@ -2,18 +2,7 @@
@if (assignablePool().length > 0) {
-
-
-
- {{ run.readinessText() }}
-
- @if (!run.canRun()) {
- Finish the needs above to enable the run.
- }
- @if (run.errorMsg()) {
- Backend error: {{ run.errorMsg() }}
- }
-
+
@if (catalog.tooltipVisible()) {
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts
index 9d967245..26828a28 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts
@@ -4,6 +4,7 @@ import { ExperimentStudioService } from '../../../services/experiment-studio.ser
import { AlgorithmSelectionComponent } from './algorithm-selection/algorithm-selection.component';
import { AlgorithmAvailabilityTooltipComponent } from './algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component';
import { AlgorithmCatalogService } from './algorithm-selection/algorithm-catalog.service';
+import { ExperimentResultComponent } from './experiment-result/experiment-result.component';
import { AlgorithmConfigFormService } from './algorithm-selection/algorithm-config-form.service';
import { AlgorithmRunService } from './experiment-result/algorithm-run.service';
import { AlgorithmStudioSubstepService } from './algorithm-studio-substep.service';
@@ -11,12 +12,12 @@ import { AlgorithmStudioSubstep } from './algorithm-panel.models';
/**
* The studio's Algorithm step. A shell: it provides the feature services its sections share,
- * lays out the algorithm card and the availability tooltip, and keeps the API the studio calls
+ * lays out the algorithm card, its readiness bar and the availability tooltip, and keeps the API the studio calls
* (run state for the run button, the run itself, the exports and the sub-step).
*/
@Component({
selector: 'app-algorithm-panel',
- imports: [AlgorithmSelectionComponent, AlgorithmAvailabilityTooltipComponent],
+ imports: [AlgorithmSelectionComponent, ExperimentResultComponent, AlgorithmAvailabilityTooltipComponent],
providers: [
AlgorithmStudioSubstepService,
AlgorithmCatalogService,
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css
index eae9f363..6fa9e956 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css
@@ -78,32 +78,6 @@
font-size: 0.8rem;
}
-.algo-needs li {
- display: flex;
- flex-wrap: wrap;
- align-items: baseline;
- gap: 4px 8px;
-}
-
-.algo-needs-mark {
- width: 12px;
- height: 12px;
- border-radius: 50%;
- box-shadow: inset 0 0 0 1.5px #a06b10;
- transform: translateY(2px);
-}
-
-.algo-needs li.is-met .algo-needs-mark {
- background: var(--success);
- box-shadow: none;
-}
-
-.algo-needs small {
- flex-basis: 100%;
- padding-left: 20px;
- color: #78350f;
-}
-
.algo-needs-all-met {
margin: 0 0 14px;
padding: 8px 12px;
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html
index be4b3dc0..b9c3c2b1 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html
@@ -1,23 +1,7 @@
@if (selectedAlgorithm()) {
@if (run.algorithmNeeds().length) {
-
- - Complete before running
- @for (need of run.algorithmNeeds(); track need.key) {
- -
-
- {{ need.title }}
- @for (message of need.messages; track message) {
- {{ message }}
- }
- @if (need.action) {
-
- }
-
- }
-
+
} @else if (selectedAlgorithm()) {
✓ All inputs match
}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts
index d2d25220..f748b479 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts
@@ -1,10 +1,8 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { ExperimentStudioService } from '../../../../../services/experiment-studio.service';
-import { ExperimentStudioNavigationService } from '../../../../../services/experiment-studio-navigation.service';
import { AlgorithmRunService } from '../../experiment-result/algorithm-run.service';
-import { AlgorithmStudioSubstepService } from '../../algorithm-studio-substep.service';
-import { AlgorithmRunRequirement } from '../../algorithm-panel.models';
+import { AlgorithmRunRequirementsComponent } from '../../experiment-result/algorithm-run-requirements/algorithm-run-requirements.component';
import { AlgorithmCatalogService } from '../algorithm-catalog.service';
import { AlgorithmConfigFormService } from '../algorithm-config-form.service';
import { AlgorithmConfigFieldComponent } from '../algorithm-config-field/algorithm-config-field.component';
@@ -17,14 +15,12 @@ import { OutlierReportEditorComponent } from '../outlier-report-editor/outlier-r
*/
@Component({
selector: 'section[app-algorithm-parameters]',
- imports: [ReactiveFormsModule, AlgorithmConfigFieldComponent, OutlierReportEditorComponent],
+ imports: [ReactiveFormsModule, AlgorithmConfigFieldComponent, OutlierReportEditorComponent, AlgorithmRunRequirementsComponent],
templateUrl: './algorithm-parameters.component.html',
styleUrl: './algorithm-parameters.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AlgorithmParametersComponent {
- private readonly studioNavigation = inject(ExperimentStudioNavigationService);
- private readonly substep = inject(AlgorithmStudioSubstepService);
protected readonly catalog = inject(AlgorithmCatalogService);
protected readonly form = inject(AlgorithmConfigFormService);
protected readonly run = inject(AlgorithmRunService);
@@ -33,23 +29,4 @@ export class AlgorithmParametersComponent {
toggleCrossValidation(event: Event): void {
this.form.setCrossValidation(!!(event.target as HTMLInputElement | null)?.checked);
}
-
- resolveRunRequirement(requirement: AlgorithmRunRequirement): void {
- if (requirement.id === 'preprocessing') {
- this.studioNavigation.goToPreprocessing();
- return;
- }
- // Availability issues are fixed on the setup board, which stays on screen above.
- this.substep.set('setup');
- requestAnimationFrame(() =>
- scrollStudioTargetIntoView(document.querySelector
('[data-guide="guide-role-assignment"]'), true)
- );
- }
-}
-
-/** Scrolls a studio anchor; `scroll-margin-top` on the target clears sticky chrome. */
-function scrollStudioTargetIntoView(target: HTMLElement | null, focus = false): void {
- if (!target) return;
- target.scrollIntoView({ behavior: 'smooth', block: 'start' });
- if (focus) target.focus({ preventScroll: true });
}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.css b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.css
new file mode 100644
index 00000000..7d42dca0
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.css
@@ -0,0 +1,27 @@
+@import '../../styles/sr-only.shared.css';
+
+:host(.algo-needs) li {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 4px 8px;
+}
+
+.algo-needs-mark {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ box-shadow: inset 0 0 0 1.5px #a06b10;
+ transform: translateY(2px);
+}
+
+:host(.algo-needs) li.is-met .algo-needs-mark {
+ background: var(--success);
+ box-shadow: none;
+}
+
+:host(.algo-needs) small {
+ flex-basis: 100%;
+ padding-left: 20px;
+ color: #78350f;
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.html b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.html
new file mode 100644
index 00000000..d85d8227
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.html
@@ -0,0 +1,15 @@
+Complete before running
+@for (need of run.algorithmNeeds(); track need.key) {
+
+
+ {{ need.title }}
+ @for (message of need.messages; track message) {
+ {{ message }}
+ }
+ @if (need.action) {
+
+ }
+
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts
new file mode 100644
index 00000000..8d42c110
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts
@@ -0,0 +1,37 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { ExperimentStudioNavigationService } from '../../../../../services/experiment-studio-navigation.service';
+import { AlgorithmStudioSubstepService } from '../../algorithm-studio-substep.service';
+import { AlgorithmRunRequirement } from '../../algorithm-panel.models';
+import { AlgorithmRunService } from '../algorithm-run.service';
+
+/** What the selected method still needs before it can run, each with the action that fixes it. */
+@Component({
+ selector: 'ul[app-algorithm-run-requirements]',
+ templateUrl: './algorithm-run-requirements.component.html',
+ styleUrl: './algorithm-run-requirements.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AlgorithmRunRequirementsComponent {
+ private readonly studioNavigation = inject(ExperimentStudioNavigationService);
+ private readonly substep = inject(AlgorithmStudioSubstepService);
+ protected readonly run = inject(AlgorithmRunService);
+
+ resolveRunRequirement(requirement: AlgorithmRunRequirement): void {
+ if (requirement.id === 'preprocessing') {
+ this.studioNavigation.goToPreprocessing();
+ return;
+ }
+ // Availability issues are fixed on the setup board, which stays on screen above.
+ this.substep.set('setup');
+ requestAnimationFrame(() =>
+ scrollStudioTargetIntoView(document.querySelector('[data-guide="guide-role-assignment"]'), true)
+ );
+ }
+}
+
+/** Scrolls a studio anchor; `scroll-margin-top` on the target clears sticky chrome. */
+function scrollStudioTargetIntoView(target: HTMLElement | null, focus = false): void {
+ if (!target) return;
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ if (focus) target.focus({ preventScroll: true });
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.css b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.css
new file mode 100644
index 00000000..997d419b
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.css
@@ -0,0 +1,27 @@
+.algorithm-run-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.algorithm-run-dot {
+ flex: 0 0 auto;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #b45309;
+}
+
+:host(.algorithm-run-bar.is-ready) .algorithm-run-dot {
+ background: var(--success);
+}
+
+.algorithm-run-text {
+ min-width: 0;
+}
+
+.algorithm-run-error {
+ flex-basis: 100%;
+ color: #8a1f1f;
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.html b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.html
new file mode 100644
index 00000000..55a65a84
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.html
@@ -0,0 +1,10 @@
+
+
+ {{ run.readinessText() }}
+
+@if (!run.canRun()) {
+Finish the needs above to enable the run.
+}
+@if (run.errorMsg()) {
+Backend error: {{ run.errorMsg() }}
+}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.spec.ts
new file mode 100644
index 00000000..506698d3
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.spec.ts
@@ -0,0 +1,52 @@
+import { Component, provideZonelessChangeDetection, signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { AlgorithmRunService } from './algorithm-run.service';
+import { ExperimentResultComponent } from './experiment-result.component';
+
+@Component({
+ imports: [ExperimentResultComponent],
+ template: '',
+})
+class HostComponent {}
+
+describe('ExperimentResultComponent', () => {
+ const run = {
+ canRun: signal(false),
+ readinessText: signal('Select a method'),
+ errorMsg: signal(null),
+ };
+
+ beforeEach(() => {
+ run.canRun.set(false);
+ run.errorMsg.set(null);
+ TestBed.configureTestingModule({
+ imports: [HostComponent],
+ providers: [provideZonelessChangeDetection(), { provide: AlgorithmRunService, useValue: run }],
+ });
+ });
+
+ function render(): HTMLElement {
+ const fixture = TestBed.createComponent(HostComponent);
+ fixture.detectChanges();
+ return (fixture.nativeElement as HTMLElement).querySelector('.algorithm-run-bar') as HTMLElement;
+ }
+
+ it('renders into the run bar and asks to finish the needs while the run is blocked', () => {
+ const bar = render();
+
+ expect(bar.getAttribute('role')).toBe('status');
+ expect(bar.classList.contains('is-ready')).toBe(false);
+ expect(bar.querySelector('.algorithm-run-text')?.textContent).toContain('Select a method');
+ expect(bar.querySelector('.algorithm-run-hint')).toBeTruthy();
+ });
+
+ it('marks the bar ready and shows the last error', () => {
+ run.canRun.set(true);
+ run.errorMsg.set('Failed to export the result tables.');
+ const bar = render();
+
+ expect(bar.classList.contains('is-ready')).toBe(true);
+ expect(bar.querySelector('.algorithm-run-hint')).toBeNull();
+ expect(bar.querySelector('.algorithm-run-error')?.textContent).toContain('Backend error: Failed to export the result tables.');
+ });
+});
diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.ts
new file mode 100644
index 00000000..4a0ba1b9
--- /dev/null
+++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.ts
@@ -0,0 +1,18 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { AlgorithmRunService } from './algorithm-run.service';
+
+/**
+ * The readiness bar at the bottom of the algorithm card: whether the run can start and why
+ * the last attempt or export failed. The run button itself is the studio's; the result shows
+ * in the Execution step. Attached to the bar element the panel projects into the card.
+ */
+@Component({
+ selector: 'div[app-experiment-result]',
+ templateUrl: './experiment-result.component.html',
+ styleUrl: './experiment-result.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: { '[class.is-ready]': 'run.canRun()' },
+})
+export class ExperimentResultComponent {
+ protected readonly run = inject(AlgorithmRunService);
+}
From 8b9ea8bb8a4540d3bce8a2fc9f766bec8d0c58fa Mon Sep 17 00:00:00 2001
From: "K.Filippopolitis" <56073635+KFilippopolitis@users.noreply.github.com>
Date: Thu, 29 Oct 2026 18:36:08 +0200
Subject: [PATCH 7/8] docs: algorithm panel split - sections, feature services
and CSS partials
---
AGENTS.md | 2 +-
docs/context/conventions.md | 1 +
docs/context/decision-log.md | 14 ++++++++++++++
docs/context/module-index.md | 2 +-
docs/llm-wiki/03-feature-index.md | 4 +++-
5 files changed, 20 insertions(+), 3 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 3e969c33..a7955397 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,7 +10,7 @@ This repository contains `fl-platform`, the Angular 22 standalone frontend for t
- `src/app/services/`: auth/session, experiment orchestration, dashboard data access, algorithm rules, labeling, runtime env, theme, errors, and PDF/CSV exports.
- `src/app/models/`: frontend and backend DTOs/interfaces for users, algorithms, experiments, filters, and data models.
- `src/app/core/`: algorithm mapping, result enum mapping, constants, and result utility logic.
-- `src/app/pages/experiment-studio/`: step views switched via a sticky horizontal stepper — `experiment-studio.component` shows one step view at a time (Datasets & Variables · Data review & preprocessing · Algorithm); all views stay mounted (hidden via CSS) so state is preserved. On the Data review step the stepper expands a sub-step row (Filtering, Raw Summary, Preprocessing, Processed Summary, Transformation) that drives the `statistic-analysis-panel` sections (`goToSection`). In-panel section targets use `scroll-margin-top` so `scrollIntoView` clears sticky chrome. Also contains variable/filter selection, QueryBuilder filter UI, algorithm configuration with variable role assignment (outcome y / covariates x on the algorithm panel), run/edit flows, statistics, visualizations, and result rendering.
+- `src/app/pages/experiment-studio/`: step views switched via a sticky horizontal stepper — `experiment-studio.component` shows one step view at a time (Datasets & Variables · Data review & preprocessing · Algorithm); all views stay mounted (hidden via CSS) so state is preserved. On the Data review step the stepper expands a sub-step row (Filtering, Raw Summary, Preprocessing, Processed Summary, Transformation) that drives the `statistic-analysis-panel` sections (`goToSection`). In-panel section targets use `scroll-margin-top` so `scrollIntoView` clears sticky chrome. Also contains variable/filter selection, QueryBuilder filter UI, algorithm configuration with variable role assignment (outcome y / covariates x on the algorithm panel; `algorithm-panel` is a shell over `algorithm-selection/` and `experiment-result/` sections that share panel-provided services), run/edit flows, statistics, visualizations, and result rendering.
- `src/app/pages/experiments-dashboard/`: experiment list/search/pagination, detail view, compare mode, sharing, delete/edit/name updates, and result export.
- `src/app/pages/terms-page/`: NDA/TOS display and acceptance flow.
- `src/app/pages/account-page/`: account/profile view and logout entry.
diff --git a/docs/context/conventions.md b/docs/context/conventions.md
index ee621fc5..c5cf94eb 100644
--- a/docs/context/conventions.md
+++ b/docs/context/conventions.md
@@ -31,6 +31,7 @@
- Use component CSS for feature-specific styles.
- Use `src/styles.css` for app-wide styles and QueryBuilder theming.
- A component may list several stylesheets in `styleUrls`; Angular concatenates them **in array order**, so that order is the cascade. Splitting a file must preserve the original order and must not move a rule across another rule that shares its selector — `statistic-analysis-panel` is split this way (`.component.css` shell + pipeline canvas, then `.preprocessing.css`, then `.results.css`).
+- A component split into section components keeps its DOM by attaching each section to the element it renders into (attribute selectors such as `section[app-algorithm-parameters]`). Each rule moves to the stylesheet of the template its element is declared in; a rule that reaches into a section from its host element is rewritten as `:host(.class)` (same specificity as `.class` under emulated encapsulation). Rules several sections share go into `styles/*.shared.css` partials, `@import`ed at the top of each stylesheet, so a rule only moves into a partial when it does not cross an equal-specificity rule for the same element (`algorithm-panel/` keeps its `.ng-touched` validation rules inline for that reason).
- Consult `DESIGN.md` before visual changes.
- Existing branding assets live under `src/assets`.
diff --git a/docs/context/decision-log.md b/docs/context/decision-log.md
index 1e7e1f92..1d55f217 100644
--- a/docs/context/decision-log.md
+++ b/docs/context/decision-log.md
@@ -215,3 +215,17 @@ Consequences: Breaking: this UI needs a backend with PR #34 and does not work ag
Files affected: `src/app/services/experiment-studio.service.ts`, `src/app/services/experiment-studio.service.spec.ts`, `e2e/experiment-studio-api.ts`, `e2e/experiment-studio.flows.spec.ts`, `README.md`, `docs/context/*.md`, `docs/llm-wiki/indexes/backend-endpoints.md`.
Date: 2026-10-15.
+
+### Decision: Algorithm panel split into selection and result sections
+
+Status: Accepted
+
+Context: `algorithm-panel.component.ts` had grown to 1,703 lines with 28 `any`, loose mutable fields (`lastUsedAlgorithm`, the outlier_report rule maps, `formKey`, the built-form signature, the category-collapse bookkeeping) and one 22.1 kB stylesheet over the 20 kB component budget. Data Handling had been split the same way (PR 56).
+
+Decision: `AlgorithmPanelComponent` (`app-algorithm-panel`) stays the studio's entry point with the same API (`canRun`, `runDisabledReason`, `onClickRunExp`, `setStudioSubstep`, `studioSubstepChange`, `onExportResult`, `onExportResultZip`) and provides four component-scoped services: `AlgorithmCatalogService` (search, Runnable/All, group collapse as a `linkedSignal`, availability text, tooltip state), `AlgorithmConfigFormService` (enriched schema, reactive form, cross-validation, positive_class, outlier_report rules as signals), `AlgorithmRunService` (run needs, `canRun`, the run, its error, PDF/ZIP export) and `AlgorithmStudioSubstepService`. Its template is a shell over `section[app-algorithm-selection]` (role assignment host, `aside[app-algorithm-catalog]`, details, `section[app-algorithm-parameters]` with `div[app-algorithm-config-field]`, `section[app-outlier-report-editor]`), the projected `div[app-experiment-result]` readiness bar with `ul[app-algorithm-run-requirements]`, and `div[app-algorithm-availability-tooltip]`. Attribute selectors keep the DOM, the `data-guide` anchors, `#algo-search`, sessionStorage keys and request payloads unchanged; pure helpers live in `algorithm-panel/utils/` with specs and shared types in `algorithm-panel.models.ts`.
+
+Consequences: Every stylesheet is under budget (the panel's is 3.3 kB). The outlier_report editor is still separate from Data Handling's outlier step; sharing one editor is a follow-up once PR 56 lands. `algorithm-result/` is shared with the Execution step and the dashboard and is unchanged.
+
+Files affected: `src/app/pages/experiment-studio/algorithm-panel/**`, `docs/context/conventions.md`, `docs/context/module-index.md`, `docs/llm-wiki/03-feature-index.md`, `AGENTS.md`.
+
+Date: 2026-10-29.
diff --git a/docs/context/module-index.md b/docs/context/module-index.md
index bdc4f92d..f9c1509c 100644
--- a/docs/context/module-index.md
+++ b/docs/context/module-index.md
@@ -42,7 +42,7 @@ Notes: Stored historical experiment payloads may depend on legacy algorithm keys
## `src/app/pages/experiment-studio`
Purpose: Experiment composition workflow.
-Key files: `experiment-studio.component.*`, `variables-panel/*`, `statistic-analysis-panel/*` (including `pca-column-source` / `kmeans-cluster-source`), `algorithm-panel/*`, `execution-panel/*` (result view + docked setup summary), `stepper/*`, `shared/*`, `guide/*`.
+Key files: `experiment-studio.component.*`, `variables-panel/*`, `statistic-analysis-panel/*` (including `pca-column-source` / `kmeans-cluster-source`), `algorithm-panel/*` (thin shell; `algorithm-selection/` catalog, details, parameter form and outlier_report editor; `experiment-result/` run needs, readiness bar, run and exports; feature services `AlgorithmCatalogService`, `AlgorithmConfigFormService`, `AlgorithmRunService`, `AlgorithmStudioSubstepService` provided by the panel; `algorithm-result/` is shared and stays put), `execution-panel/*` (result view + docked setup summary), `stepper/*`, `shared/*`, `guide/*`.
Used by: `/experiment-studio` route and dashboard edit flows.
Rules: Keep page-specific UI here; use `ExperimentStudioService` for shared selection/run state. Transformation cards append to `appliedTransformations` in creation order; each creator step names outputs with `new_column_names`. Do not reintroduce `pca_with_transformation`.
Tests: Component specs live near components; e2e covers PCA then K-means then logistic regression payload order in `e2e/experiment-studio.flows.spec.ts`.
diff --git a/docs/llm-wiki/03-feature-index.md b/docs/llm-wiki/03-feature-index.md
index b3fdbab0..95c2da81 100644
--- a/docs/llm-wiki/03-feature-index.md
+++ b/docs/llm-wiki/03-feature-index.md
@@ -19,7 +19,9 @@ Start with:
- `ExperimentStudioComponent` (step shell) and `StudioStepperComponent` (step rail)
- `ExperimentStudioService` (selection, algorithm roles, run state)
- `execution-panel/` for run status/results and
- `algorithm-panel/algorithm-role-assignment/` for the y/x slots
+ `algorithm-panel/algorithm-role-assignment/` for the y/x slots; the rest of
+ `algorithm-panel/` is a shell over `algorithm-selection/` (catalog, parameters)
+ and `experiment-result/` (run needs, readiness bar, run and exports)
- `shared/station-card`, `shared/station-action-bar`, `shared/station-list-row` for
the cards and footers every Data review step reuses
- relevant model under `src/app/models/`
From 3cc56bfdff683640f7d04ac6a1e0243ce808ad92 Mon Sep 17 00:00:00 2001
From: KFilippopolitis
Date: Fri, 30 Oct 2026 12:06:37 +0200
Subject: [PATCH 8/8] refactor(algorithm-panel): remove the unreachable
availability tooltip and tidy the run flow
- showTooltip had no caller, so the tooltip component, its position helper,
catalog tooltip state, .tooltip CSS, availabilityRequirementText and
formatInputCountRange are removed with their specs
- runExperiment checks the algorithm before touching run state and no longer
clears the error three times
- the requirements action scrolls inline instead of through a one-use helper
---
docs/context/decision-log.md | 4 +-
src/app/core/algorithm-input-counts.spec.ts | 4 --
src/app/core/algorithm-input-counts.ts | 10 ----
.../algorithm-panel.component.css | 18 -------
.../algorithm-panel.component.html | 5 --
.../algorithm-panel.component.spec.ts | 13 +----
.../algorithm-panel.component.ts | 8 ++-
...gorithm-availability-tooltip.component.css | 54 -------------------
...orithm-availability-tooltip.component.html | 18 -------
...lgorithm-availability-tooltip.component.ts | 18 -------
.../algorithm-catalog.service.ts | 12 +----
.../algorithm-catalog.component.ts | 35 ------------
.../algorithm-run-requirements.component.ts | 16 +++---
.../algorithm-run.service.ts | 18 +++----
.../algorithm-availability-text.utils.spec.ts | 14 -----
.../algorithm-availability-text.utils.ts | 12 -----
.../utils/tooltip-position.utils.spec.ts | 14 -----
.../utils/tooltip-position.utils.ts | 39 --------------
18 files changed, 20 insertions(+), 292 deletions(-)
delete mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css
delete mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html
delete mode 100644 src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts
delete mode 100644 src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.spec.ts
delete mode 100644 src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.ts
diff --git a/docs/context/decision-log.md b/docs/context/decision-log.md
index 1d55f217..d0c1403c 100644
--- a/docs/context/decision-log.md
+++ b/docs/context/decision-log.md
@@ -222,9 +222,9 @@ Status: Accepted
Context: `algorithm-panel.component.ts` had grown to 1,703 lines with 28 `any`, loose mutable fields (`lastUsedAlgorithm`, the outlier_report rule maps, `formKey`, the built-form signature, the category-collapse bookkeeping) and one 22.1 kB stylesheet over the 20 kB component budget. Data Handling had been split the same way (PR 56).
-Decision: `AlgorithmPanelComponent` (`app-algorithm-panel`) stays the studio's entry point with the same API (`canRun`, `runDisabledReason`, `onClickRunExp`, `setStudioSubstep`, `studioSubstepChange`, `onExportResult`, `onExportResultZip`) and provides four component-scoped services: `AlgorithmCatalogService` (search, Runnable/All, group collapse as a `linkedSignal`, availability text, tooltip state), `AlgorithmConfigFormService` (enriched schema, reactive form, cross-validation, positive_class, outlier_report rules as signals), `AlgorithmRunService` (run needs, `canRun`, the run, its error, PDF/ZIP export) and `AlgorithmStudioSubstepService`. Its template is a shell over `section[app-algorithm-selection]` (role assignment host, `aside[app-algorithm-catalog]`, details, `section[app-algorithm-parameters]` with `div[app-algorithm-config-field]`, `section[app-outlier-report-editor]`), the projected `div[app-experiment-result]` readiness bar with `ul[app-algorithm-run-requirements]`, and `div[app-algorithm-availability-tooltip]`. Attribute selectors keep the DOM, the `data-guide` anchors, `#algo-search`, sessionStorage keys and request payloads unchanged; pure helpers live in `algorithm-panel/utils/` with specs and shared types in `algorithm-panel.models.ts`.
+Decision: `AlgorithmPanelComponent` (`app-algorithm-panel`) stays the studio's entry point with the same API (`canRun`, `runDisabledReason`, `onClickRunExp`, `setStudioSubstep`, `studioSubstepChange`, `onExportResult`, `onExportResultZip`) and provides four component-scoped services: `AlgorithmCatalogService` (search, Runnable/All, group collapse as a `linkedSignal`, availability text), `AlgorithmConfigFormService` (enriched schema, reactive form, cross-validation, positive_class, outlier_report rules as signals), `AlgorithmRunService` (run needs, `canRun`, the run, its error, PDF/ZIP export) and `AlgorithmStudioSubstepService`. Its template is a shell over `section[app-algorithm-selection]` (role assignment host, `aside[app-algorithm-catalog]`, details, `section[app-algorithm-parameters]` with `div[app-algorithm-config-field]`, `section[app-outlier-report-editor]`), the projected `div[app-experiment-result]` readiness bar with `ul[app-algorithm-run-requirements]`. Attribute selectors keep the DOM, the `data-guide` anchors, `#algo-search`, sessionStorage keys and request payloads unchanged; pure helpers live in `algorithm-panel/utils/` with specs and shared types in `algorithm-panel.models.ts`.
-Consequences: Every stylesheet is under budget (the panel's is 3.3 kB). The outlier_report editor is still separate from Data Handling's outlier step; sharing one editor is a follow-up once PR 56 lands. `algorithm-result/` is shared with the Execution step and the dashboard and is unchanged.
+Consequences: Every stylesheet is under budget (the panel's is 3.3 kB). The outlier_report editor is still separate from Data Handling's outlier step; sharing one editor is a follow-up once PR 56 lands. The catalog's availability tooltip (`showTooltip` had no caller) was removed with its position helper and `formatInputCountRange`. `algorithm-result/` is shared with the Execution step and the dashboard and is unchanged.
Files affected: `src/app/pages/experiment-studio/algorithm-panel/**`, `docs/context/conventions.md`, `docs/context/module-index.md`, `docs/llm-wiki/03-feature-index.md`, `AGENTS.md`.
diff --git a/src/app/core/algorithm-input-counts.spec.ts b/src/app/core/algorithm-input-counts.spec.ts
index dcf037c8..ba7088d2 100644
--- a/src/app/core/algorithm-input-counts.spec.ts
+++ b/src/app/core/algorithm-input-counts.spec.ts
@@ -1,6 +1,5 @@
import {
applyFallbackInputCounts,
- formatInputCountRange,
readInputCount,
resolveInputMaxCount,
resolveInputMinCount,
@@ -29,8 +28,5 @@ describe('algorithm input counts', () => {
expect(resolveInputMaxCount(y, 'y', 'glmm_binary')).toBe(1);
expect(resolveInputMinCount(x, 'x', 'glmm_binary')).toBe(2);
- expect(formatInputCountRange(1, 1)).toBe('exactly 1');
- expect(formatInputCountRange(2, null)).toBe('2+');
- expect(formatInputCountRange(2, 2)).toBe('exactly 2');
});
});
diff --git a/src/app/core/algorithm-input-counts.ts b/src/app/core/algorithm-input-counts.ts
index eb983634..009bb75e 100644
--- a/src/app/core/algorithm-input-counts.ts
+++ b/src/app/core/algorithm-input-counts.ts
@@ -65,16 +65,6 @@ export function resolveInputMaxCount(
}
-export function formatInputCountRange(minCount: number, maxCount: number | null): string {
- if (minCount === 1 && maxCount === 1) return 'exactly 1';
- if (minCount === 0 && maxCount === 1) return '0-1';
- if (minCount > 0 && maxCount !== null && minCount === maxCount) return `exactly ${minCount}`;
- if (minCount > 0 && maxCount !== null) return `${minCount}-${maxCount}`;
- if (minCount > 0) return `${minCount}+`;
- if (maxCount !== null) return `0-${maxCount}`;
- return 'optional';
-}
-
export function applyFallbackInputCounts(inputdata: unknown, algorithmName: string): Record {
if (!inputdata || typeof inputdata !== 'object') return {};
const next: Record = { ...(inputdata as Record) };
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css
index b1a9f394..73f2bf61 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css
@@ -69,24 +69,6 @@
color: #64748b;
}
-/* Tooltip (hover affordance for catalog cards) */
-.tooltip {
- position: absolute !important;
- background: var(--studio-card-bg);
- box-shadow: 0 12px 32px rgba(15, 23, 42, 0.22);
- padding: 16px;
- border-radius: var(--radius-lg, 12px);
- width: 320px;
- max-width: calc(100vw - 40px);
- max-height: 70vh;
- overflow-y: auto;
- z-index: 99999;
- pointer-events: none;
- border: 1px solid rgba(226, 232, 240, 0.8);
- opacity: 1;
- transition: opacity 0.15s ease-out;
-}
-
/* The role band owns its own height now: the expanded roster caps itself and
scrolls; the pool no longer forces the whole assignment band into 22rem. */
.algorithm-roles-board {
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html
index bb036102..134a95e9 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html
@@ -4,11 +4,6 @@
-
-@if (catalog.tooltipVisible()) {
-
-
-}
} @else {
Select variables first
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts
index 22328e2e..49bea61a 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts
@@ -256,7 +256,7 @@ describe('AlgorithmPanelComponent', () => {
expect(details?.querySelector('.documentation-content')?.textContent).toContain('Line one.');
});
- it('shows disabled algorithm availability reasons on the card and in the tooltip', async () => {
+ it('shows disabled algorithm availability reasons on the card', async () => {
const disabledAlgorithm: AlgorithmConfig = {
...algorithm,
name: 'needs_two_variables',
@@ -293,21 +293,10 @@ describe('AlgorithmPanelComponent', () => {
((fixture.nativeElement as HTMLElement).querySelector('.algorithm-match-cat') as HTMLButtonElement).click();
fixture.detectChanges();
- catalog().tooltipVisible.set(true);
- catalog().tooltipData.set(disabledAlgorithm);
- fixture.detectChanges();
-
const nativeElement = fixture.nativeElement as HTMLElement;
const algorithmItem = nativeElement.querySelector('.algo-tile') as HTMLElement;
expect(algorithmItem.textContent).toContain('Needs Two Variables');
expect(algorithmItem.querySelector('.algo-why')?.textContent).toContain('Outcome needs at least 2, selected 1.');
- expect(nativeElement.querySelector('.tooltip')?.textContent).toContain('Outcome needs at least 2, selected 1.');
- expect(nativeElement.textContent).toContain('Availability');
- expect(nativeElement.textContent).toContain('Outcome: 2-3, selected 1');
- expect(nativeElement.textContent).toContain('type: real');
- expect(nativeElement.querySelector('.tooltip')?.textContent).toContain('Outcome type must be one of real.');
- expect(nativeElement.textContent).not.toContain('type: nominal');
- expect(nativeElement.textContent).not.toContain('stattypes: nominal');
});
it('shows type availability reasons on algorithm cards', async () => {
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts
index 26828a28..2a3b4289 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts
@@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { outputFromObservable } from '@angular/core/rxjs-interop';
import { ExperimentStudioService } from '../../../services/experiment-studio.service';
import { AlgorithmSelectionComponent } from './algorithm-selection/algorithm-selection.component';
-import { AlgorithmAvailabilityTooltipComponent } from './algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component';
import { AlgorithmCatalogService } from './algorithm-selection/algorithm-catalog.service';
import { ExperimentResultComponent } from './experiment-result/experiment-result.component';
import { AlgorithmConfigFormService } from './algorithm-selection/algorithm-config-form.service';
@@ -12,12 +11,12 @@ import { AlgorithmStudioSubstep } from './algorithm-panel.models';
/**
* The studio's Algorithm step. A shell: it provides the feature services its sections share,
- * lays out the algorithm card, its readiness bar and the availability tooltip, and keeps the API the studio calls
+ * lays out the algorithm card and its readiness bar, and keeps the API the studio calls
* (run state for the run button, the run itself, the exports and the sub-step).
*/
@Component({
selector: 'app-algorithm-panel',
- imports: [AlgorithmSelectionComponent, ExperimentResultComponent, AlgorithmAvailabilityTooltipComponent],
+ imports: [AlgorithmSelectionComponent, ExperimentResultComponent],
providers: [
AlgorithmStudioSubstepService,
AlgorithmCatalogService,
@@ -30,8 +29,7 @@ import { AlgorithmStudioSubstep } from './algorithm-panel.models';
})
export class AlgorithmPanelComponent {
private readonly substep = inject(AlgorithmStudioSubstepService);
- protected readonly catalog = inject(AlgorithmCatalogService);
- protected readonly run = inject(AlgorithmRunService);
+ protected readonly run = inject(AlgorithmRunService);
/** The card shows once the studio has variables to assign. */
protected readonly assignablePool = inject(ExperimentStudioService).algorithmAssignableVariables;
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css
deleted file mode 100644
index abcd3188..00000000
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.css
+++ /dev/null
@@ -1,54 +0,0 @@
-.tooltip-title {
- margin: 0 0 8px;
- font-size: 1rem;
- font-weight: 700;
- color: var(--primary-color);
- line-height: 1.3;
-}
-
-.tooltip-description {
- font-size: 0.82rem;
- line-height: 1.5;
- color: var(--text-muted);
- margin-bottom: 0;
-}
-
-.tooltip-requirements {
- margin-top: 12px;
- padding-top: 12px;
- border-top: 1px solid rgba(226, 232, 240, 0.6);
-}
-
-.tooltip-requirements-header {
- font-size: 0.72rem;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: #94a3b8;
- margin-bottom: 8px;
-}
-
-.req-item {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- margin-bottom: 8px;
-}
-
-.req-copy {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.req-text {
- font-size: 0.78rem;
- color: var(--chart-label-color);
- line-height: 1.4;
-}
-
-.req-error {
- font-size: 0.72rem;
- color: #b45309;
- line-height: 1.35;
-}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html
deleted file mode 100644
index 6048a213..00000000
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.html
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-@if (details().length) {
-
-}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts
deleted file mode 100644
index 580479d6..00000000
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-availability-tooltip/algorithm-availability-tooltip.component.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
-import { AlgorithmConfig } from '../../../../../models/algorithm-definition.model';
-import { availabilityRequirementText } from '../../utils/algorithm-availability-text.utils';
-import { AlgorithmCatalogService } from '../algorithm-catalog.service';
-
-/** A method's description and the role checks behind its availability. */
-@Component({
- selector: 'div[app-algorithm-availability-tooltip]',
- templateUrl: './algorithm-availability-tooltip.component.html',
- styleUrl: './algorithm-availability-tooltip.component.css',
- changeDetection: ChangeDetectionStrategy.OnPush,
-})
-export class AlgorithmAvailabilityTooltipComponent {
- readonly algorithm = input(null);
- private readonly catalog = inject(AlgorithmCatalogService);
- protected readonly details = computed(() => this.catalog.availabilityDetails(this.algorithm()));
- protected readonly availabilityRequirementText = availabilityRequirementText;
-}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts
index d23ac8a8..a7c6ca71 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts
@@ -6,8 +6,8 @@ import { countAlgorithms, filterAlgorithmCategories, nextCollapsedCategories } f
import { AlgorithmCategory } from '../algorithm-panel.models';
/**
- * The method catalog: search text, the Runnable/All filter, which groups are open, the
- * availability tooltip, and why a method is unavailable. Provided by the algorithm panel.
+ * The method catalog: search text, the Runnable/All filter, which groups are open, and why
+ * a method is unavailable. Provided by the algorithm panel.
*/
@Injectable()
export class AlgorithmCatalogService {
@@ -54,10 +54,6 @@ export class AlgorithmCatalogService {
),
});
- readonly tooltipVisible = signal(false);
- readonly tooltipPosition = signal({ x: 0, y: 0 });
- readonly tooltipData = signal(null);
-
/** The selected method cannot run with the current roles and datasets. */
readonly selectedAlgorithmUnavailable = computed(() => {
const algorithm = this.studio.selectedAlgorithm();
@@ -101,8 +97,4 @@ export class AlgorithmCatalogService {
const first = details.find((detail) => detail.messages.length > 0);
return first ? formatAvailabilityMessage(first.messages[0]) : 'Unavailable';
}
-
- hideTooltip(): void {
- this.tooltipVisible.set(false);
- }
}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts
index 18aba2b2..0a171d8c 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts
+++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts
@@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ExperimentStudioService } from '../../../../../services/experiment-studio.service';
import { AlgorithmConfig } from '../../../../../models/algorithm-definition.model';
import { runnableCount } from '../../utils/algorithm-catalog.utils';
-import { tooltipPosition } from '../../utils/tooltip-position.utils';
import { AlgorithmRunService } from '../../experiment-result/algorithm-run.service';
import { AlgorithmStudioSubstepService } from '../../algorithm-studio-substep.service';
import { AlgorithmCatalogService } from '../algorithm-catalog.service';
@@ -35,39 +34,5 @@ export class AlgorithmCatalogComponent {
this.studio.setAlgorithm(algorithm);
this.run.clearRunState();
this.substep.set('parameters');
- this.catalog.hideTooltip();
- }
-
- /** Opens the availability tooltip beside a method. Nothing in the catalog calls it at the moment. */
- showTooltip(algorithm: AlgorithmConfig, event: MouseEvent): void {
- event.stopPropagation();
- const item = (event.currentTarget as HTMLElement)?.closest('li') || (event.currentTarget as HTMLElement);
- const rect = item.getBoundingClientRect();
- const container = item.closest('.experiment-container') as HTMLElement | null;
- const containerRect = container?.getBoundingClientRect() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight);
-
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
- const padding = 16;
- const offset = 12;
-
- this.catalog.tooltipVisible.set(true);
- this.catalog.tooltipData.set(algorithm);
-
- // Immediate initial placement
- this.catalog.tooltipPosition.set(
- tooltipPosition(rect, containerRect, { width: 320, height: 200, viewportWidth, viewportHeight, padding, offset })
- );
-
- // Refinement after render
- setTimeout(() => {
- const el = document.querySelector('.tooltip') as HTMLElement | null;
- if (!el) return;
- const w = el.offsetWidth || 320;
- const h = el.offsetHeight || 200;
- this.catalog.tooltipPosition.set(
- tooltipPosition(rect, containerRect, { width: w, height: h, viewportWidth, viewportHeight, padding, offset })
- );
- }, 0);
}
}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts
index 8d42c110..c3385af6 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts
+++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts
@@ -23,15 +23,11 @@ export class AlgorithmRunRequirementsComponent {
}
// Availability issues are fixed on the setup board, which stays on screen above.
this.substep.set('setup');
- requestAnimationFrame(() =>
- scrollStudioTargetIntoView(document.querySelector('[data-guide="guide-role-assignment"]'), true)
- );
+ requestAnimationFrame(() => {
+ // `scroll-margin-top` on the target clears the sticky chrome.
+ const target = document.querySelector('[data-guide="guide-role-assignment"]');
+ target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ target?.focus({ preventScroll: true });
+ });
}
}
-
-/** Scrolls a studio anchor; `scroll-margin-top` on the target clears sticky chrome. */
-function scrollStudioTargetIntoView(target: HTMLElement | null, focus = false): void {
- if (!target) return;
- target.scrollIntoView({ behavior: 'smooth', block: 'start' });
- if (focus) target.focus({ preventScroll: true });
-}
diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts
index 38a0eba1..4da9ec05 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts
+++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts
@@ -230,9 +230,12 @@ export class AlgorithmRunService {
return;
}
- const algo = this.studio.selectedAlgorithm();
- this.errorMsg.set(null);
- this.errorService.clearError();
+ const algo = this.selectedAlgorithm();
+ if (!algo) {
+ this.errorMsg.set('Please choose an algorithm before running.');
+ return;
+ }
+
this.studio.runStatusText.set('Processing experiment...');
this.studio.hasRunStarted.set(true);
this.studio.runError.set(null);
@@ -240,14 +243,6 @@ export class AlgorithmRunService {
this.studio.runId.set(null);
this.studio.setRunning(true);
- if (!algo) {
- console.error('No algorithm selected in service.');
- this.errorMsg.set('Please choose an algorithm before running.');
- this.studio.setRunning(false);
- this.studio.hasRunStarted.set(false);
- return;
- }
-
const outlierReportError = this.configForm.validateOutlierReportForRun();
if (outlierReportError) {
this.errorMsg.set(outlierReportError);
@@ -263,7 +258,6 @@ export class AlgorithmRunService {
} = resolveRunAlgorithmNames(algo.name, this.configForm.crossValidationEnabled(), this.studio);
this.studio.lastUsedAlgorithm.set(finalAlgorithmName);
- this.errorMsg.set(null);
const configValues = this.configForm.runParameterValues();
if (!shouldIncludeSplits && configValues['n_splits'] !== undefined) {
diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts
index 644daf13..6b957f21 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts
+++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts
@@ -1,6 +1,5 @@
import { AlgorithmAvailabilityDetail } from '../../../../models/algorithm-definition.model';
import {
- availabilityRequirementText,
formatAvailabilityMessage,
needsRequirementText,
} from './algorithm-availability-text.utils';
@@ -34,19 +33,6 @@ describe('algorithm availability text', () => {
expect(needsRequirementText(detail({ role: 'x', minCount: 0, maxCount: null }))).toBe('Predictors optional');
});
- it('lists count, selection and display types in tooltip requirements', () => {
- expect(availabilityRequirementText(detail({ minCount: 1, maxCount: 1, selectedCount: 0, types: ['text', 'integer', 'text'] })))
- .toBe('Outcome: exactly 1, selected 0 • type: nominal, int');
- expect(availabilityRequirementText(detail({ types: [] }))).toBe('Outcome: exactly 1, selected 0');
- });
-
- it('displays text availability types as nominal in requirement tips', () => {
- const text = availabilityRequirementText(detail({ role: 'x', minCount: 1, maxCount: null, selectedCount: 1, types: ['real', 'int', 'text'] }));
-
- expect(text).toContain('type: real, int, nominal');
- expect(text).not.toContain('text');
- });
-
it('rewrites backend type lists in availability messages', () => {
expect(formatAvailabilityMessage('Outcome type must be one of text, polynominal. Other text.'))
.toBe('Outcome type must be one of nominal. Other text.');
diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts
index bceebb5c..e811b42f 100644
--- a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts
+++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts
@@ -1,4 +1,3 @@
-import { formatInputCountRange } from '../../../../core/algorithm-input-counts';
import { VariableTypes } from '../../../../core/constants/algorithm.constants';
import { AlgorithmAvailabilityDetail } from '../../../../models/algorithm-definition.model';
@@ -75,17 +74,6 @@ function requirementDisplayTypes(types: unknown): string[] | null {
return unique.length ? unique : null;
}
-/** One requirement line of the availability tooltip, e.g. "Outcome: 1, selected 0 • type: real". */
-export function availabilityRequirementText(detail: AlgorithmAvailabilityDetail): string {
- const count = formatInputCountRange(detail.minCount, detail.maxCount);
- const parts = [detail.label + ': ' + count + ', selected ' + detail.selectedCount];
- const types = requirementDisplayTypes(detail.types);
- if (types) {
- parts.push('type: ' + types.join(', '));
- }
- return parts.join(' • ');
-}
-
/** Rewrites the type list of "Outcome/Predictor type must be one of …." with display type names. */
export function formatAvailabilityMessage(message: string): string {
return message.replace(
diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.spec.ts
deleted file mode 100644
index 90e02e4e..00000000
--- a/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.spec.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { tooltipPosition } from './tooltip-position.utils';
-
-describe('tooltipPosition', () => {
- const container = new DOMRect(100, 50, 1000, 800);
- const placement = { width: 320, height: 200, viewportWidth: 1280, viewportHeight: 800, padding: 16, offset: 12 };
-
- it('places the tooltip right of the item, centred, relative to the container', () => {
- expect(tooltipPosition(new DOMRect(200, 300, 100, 40), container, placement)).toEqual({ x: 212, y: 170 });
- });
-
- it('flips left when there is no room on the right and clamps to the viewport', () => {
- expect(tooltipPosition(new DOMRect(1000, 700, 100, 40), container, placement)).toEqual({ x: 568, y: 534 });
- });
-});
diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.ts
deleted file mode 100644
index 147c689f..00000000
--- a/src/app/pages/experiment-studio/algorithm-panel/utils/tooltip-position.utils.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-/** Tooltip box and spacing for {@link tooltipPosition}. */
-export interface TooltipPlacement {
- width: number;
- height: number;
- viewportWidth: number;
- viewportHeight: number;
- padding: number;
- offset: number;
-}
-
-/**
- * Where to put a tooltip for an item, in coordinates relative to `containerRect`: right of
- * the item, else left of it, else pinned to the right viewport edge; vertically centred on
- * the item and kept inside the viewport, never closer than `padding` to the container edge.
- */
-export function tooltipPosition(rect: DOMRect, containerRect: DOMRect, placement: TooltipPlacement): { x: number; y: number } {
- const { width, height, viewportWidth, viewportHeight, padding, offset } = placement;
- const rightAlignedX = rect.right + offset;
- const leftAlignedX = rect.left - width - offset;
- const centeredY = rect.top + (rect.height - height) / 2;
-
- let x = rightAlignedX;
- if (rightAlignedX + width > viewportWidth - padding) {
- x = leftAlignedX >= padding ? leftAlignedX : viewportWidth - width - padding;
- }
-
- let y = centeredY;
- if (y + height > viewportHeight - padding) {
- y = viewportHeight - height - padding;
- }
- if (y < padding) {
- y = padding;
- }
-
- return {
- x: Math.max(padding, x - containerRect.left),
- y: Math.max(padding, y - containerRect.top),
- };
-}