Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions app/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ import { getNavigation } from '../../../config/navigation';

const FEEDBACK_HREF = buildMailto({ subject: 'ProtSpace feedback' });

// Ghost-style Feedback CTA: transparent, on-brand blue text/icon with a faint blue
// hover — de-emphasized vs. the app-shell primary (#3c83f6). The hue is variant-aware
// so the label clears WCAG AA on both header backgrounds: the canonical ProtSpace blue
// (#00a3e0, ~5.6:1) on the dark header, and a darker shade (#006d96, ~5.3:1) on the
// light Explore header, where #00a3e0 would only reach ~2.6:1.
// Both literals are spelled out so Tailwind's source scan can generate the arbitrary
// color utilities (it cannot see classes built from interpolated strings).
const FEEDBACK_BUTTON_CLASS_DEFAULT =
'text-[#00a3e0] hover:bg-[#00a3e0]/10 hover:text-[#00a3e0] focus-visible:ring-[#00a3e0]';
const FEEDBACK_BUTTON_CLASS_LIGHT =
'text-[#006d96] hover:bg-[#006d96]/10 hover:text-[#006d96] focus-visible:ring-[#006d96]';

const mode = import.meta.env.MODE === 'production' ? 'production' : 'development';
const navItems = getNavigation(mode);

Expand All @@ -31,6 +43,8 @@ const Header = ({ variant = 'default', className }: HeaderProps) => {
const textClass = variant === 'light' ? 'text-slate-900' : 'text-foreground';
const hoverTextClass = variant === 'light' ? 'hover:text-slate-700' : 'hover:text-primary';
const mutedTextClass = variant === 'light' ? 'text-slate-700' : 'text-foreground/80';
const feedbackButtonClass =
variant === 'light' ? FEEDBACK_BUTTON_CLASS_LIGHT : FEEDBACK_BUTTON_CLASS_DEFAULT;

const headerClasses = cn(
'fixed top-0 left-0 right-0 z-50 border-b backdrop-blur-lg',
Expand Down Expand Up @@ -127,7 +141,7 @@ const Header = ({ variant = 'default', className }: HeaderProps) => {
})}

{/* Feedback CTA */}
<Button asChild size="sm">
<Button asChild variant="ghost" size="sm" className={feedbackButtonClass}>
<a href={FEEDBACK_HREF}>
<MessageSquareText />
Feedback
Expand Down Expand Up @@ -232,7 +246,12 @@ const Header = ({ variant = 'default', className }: HeaderProps) => {
})}

{/* Feedback CTA */}
<Button asChild size="sm" className="w-full mt-2">
<Button
asChild
variant="ghost"
size="sm"
className={cn(feedbackButtonClass, 'w-full mt-2')}
>
<a href={FEEDBACK_HREF} onClick={() => setIsMenuOpen(false)}>
<MessageSquareText />
Feedback
Expand Down
13 changes: 2 additions & 11 deletions app/src/explore/control-bar-events.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import type {
ProtspaceControlBar,
ProtspaceScatterplot,
SelectionDisabledNotificationDetail,
} from '@protspace/core';
import type { SelectionDisabledNotificationDetail } from '@protspace/core';
import { notify } from '../lib/notify';
import { getSelectionDisabledNotification } from './notifications';
import type { DatasetController } from './dataset-controller';
Expand All @@ -15,26 +11,21 @@ interface ControlBarEventsOptions {
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void;
controlBar: ProtspaceControlBar;
datasetController: DatasetController;
handleExport(event: Event): Promise<void>;
interactionController: InteractionController;
plotElement: ProtspaceScatterplot;
viewController: ViewController;
}

export function bindControlBarEvents({
addControlBarListener,
controlBar,
datasetController,
handleExport,
interactionController,
plotElement,
viewController,
}: ControlBarEventsOptions): void {
addControlBarListener('annotation-change', () => {
const nextAnnotation = controlBar.selectedAnnotation || plotElement.selectedAnnotation || '';
interactionController.handleAnnotationChange(nextAnnotation);
interactionController.handleAnnotationChange();
viewController.handleUserAnnotationChange();
});

Expand Down
6 changes: 1 addition & 5 deletions app/src/explore/dataset-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@
loadSequence = loadMeta.sequence;

if (runningLoadMeta && loadMeta.sequence !== runningLoadMeta.sequence) {
console.log('Ignoring stale data load result:', {

Check warning on line 111 in app/src/explore/dataset-controller.ts

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
source,
fileName: file?.name ?? null,
loadKind: loadMeta.kind,
Expand Down Expand Up @@ -138,11 +138,7 @@
legendElement.clearForNewDataset(datasetHash, shouldClearPersistedState);
controlBar.clearForNewDataset(datasetHash, shouldClearPersistedState);

const initialViewFallback = Object.keys(data.annotations)[0] ?? '';
interactionController.setLastKnownAnnotation(initialViewFallback);

const initialView = await loadData(data);
interactionController.setLastKnownAnnotation(initialView?.annotation ?? initialViewFallback);
await loadData(data);

const shouldApplyEmbeddedFileSettings = settings && loadMeta.kind !== 'opfs';
if (shouldApplyEmbeddedFileSettings) {
Expand Down Expand Up @@ -254,7 +250,7 @@
const loadSequence = runningLoadMeta?.sequence ?? null;

if (customEvent.detail.originalError?.name === 'AbortError') {
console.log('Data load cancelled by user');

Check warning on line 253 in app/src/explore/dataset-controller.ts

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
if (loadSequence !== null) {
loadQueue.resolvePendingLoadFinalization(loadSequence);
}
Expand Down Expand Up @@ -299,7 +295,7 @@
loadPersistedOrDefaultDataset: persistedDatasetController.loadPersistedOrDefaultDataset,
tryLoadPersistedAgain: persistedDatasetController.tryLoadPersistedAgain,
handleLoadingStart() {
console.log('Data loading started');

Check warning on line 298 in app/src/explore/dataset-controller.ts

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
overlayController.update(true, 5, 'Analyzing file structure...', 'Starting upload...');
},
handleLoadingProgress(event: Event) {
Expand Down
40 changes: 10 additions & 30 deletions app/src/explore/interaction-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
StructureErrorEventDetail,
StructureLoadEvent,
} from '@protspace/core';
import type { VisualizationData } from '@protspace/utils';
import { getProteinAnnotationIndices } from '@protspace/utils';
import { notify } from '../lib/notify';
import { getLegendErrorNotification } from './notifications';
Expand All @@ -16,34 +15,28 @@
plotElement: ProtspaceScatterplot;
selectedProteinElement: HTMLElement | null;
structureViewer: ProtspaceStructureViewer;
clearPersistedLegendHiddenValues: (annotation: string, dataOverride?: VisualizationData) => void;
}

export interface InteractionController {
updateLegend(): void;
updateSelectedProteinDisplay(proteinId: string | null): void;
getSelectedProteins(): string[];
setLastKnownAnnotation(annotation: string): void;
handleSelectionChange(event: Event): void;
handlePlotDataChange(): void;
handleLegendItemClick(event: Event): void;
handleLegendError(event: Event): void;
handleStructureLoad(event: Event): void;
handleStructureError(event: Event): void;
handleStructureClose(event: Event): void;
handleAnnotationChange(nextAnnotation: string): void;
handleAnnotationChange(): void;
}

export function createInteractionController({
legendElement,
plotElement,
selectedProteinElement,
structureViewer,
clearPersistedLegendHiddenValues,
}: InteractionControllerOptions): InteractionController {
let hiddenValues: string[] = [];
let selectedProteins: string[] = [];
let lastKnownAnnotation = '';

const updateSelectedProteinDisplay = (proteinId: string | null) => {
if (!selectedProteinElement) {
Expand Down Expand Up @@ -96,9 +89,6 @@
getSelectedProteins() {
return selectedProteins;
},
setLastKnownAnnotation(annotation) {
lastKnownAnnotation = annotation;
},
handleSelectionChange(event) {
const customEvent = event as CustomEvent<{ proteinIds?: string[] }>;
selectedProteins = Array.isArray(customEvent.detail.proteinIds)
Expand All @@ -118,17 +108,6 @@
selectedProteins = plotElement.selectedProteinIds || [];
updateLegend();
},
handleLegendItemClick(event) {
const customEvent = event as CustomEvent<{ value: string | null }>;
const valueKey = customEvent.detail.value === null ? 'null' : customEvent.detail.value;

if (hiddenValues.includes(valueKey)) {
hiddenValues = hiddenValues.filter((value) => value !== valueKey);
return;
}

hiddenValues = [...hiddenValues, valueKey];
},
handleLegendError(event) {
const customEvent = event as CustomEvent<LegendErrorEventDetail>;
console.error('Legend error:', customEvent.detail);
Expand All @@ -137,7 +116,7 @@
handleStructureLoad(event) {
const customEvent = event as StructureLoadEvent;
if (customEvent.detail.status === 'loaded') {
console.log(`✅ Structure loaded: ${customEvent.detail.proteinId}`);

Check warning on line 119 in app/src/explore/interaction-controller.ts

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
}
},
handleStructureError(event) {
Expand All @@ -152,15 +131,16 @@
console.log('Structure viewer should now be hidden');
updateSelectedProteinDisplay(null);
},
handleAnnotationChange(nextAnnotation) {
if (lastKnownAnnotation && lastKnownAnnotation !== nextAnnotation) {
clearPersistedLegendHiddenValues(lastKnownAnnotation);
}

hiddenValues = [];
plotElement.hiddenAnnotationValues = hiddenValues;
handleAnnotationChange() {
// Synchronously clear the plot's hidden set for the newly selected
// annotation. The core legend owns per-annotation visibility: it persists
// hidden categories (saveSettings/loadSettings, keyed by datasetHash +
// annotation) and restores them on its own (async, Lit-driven) update
// cycle, which runs after this synchronous handler — so this reset is
// intentionally overwritten and switching away and back restores the
// previously hidden categories.
plotElement.hiddenAnnotationValues = [];
updateLegend();
lastKnownAnnotation = nextAnnotation;
},
};
}
66 changes: 0 additions & 66 deletions app/src/explore/persisted-legend.ts

This file was deleted.

14 changes: 0 additions & 14 deletions app/src/explore/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
} from './fasta-prep-limits';
import { createLoadQueue } from './load-queue';
import { createLoadingOverlayController } from './loading-overlay';
import { createPersistedLegendController } from './persisted-legend';
import { startInitialExploreLoad } from './startup';
import { NOOP_CONTROLLER, type ExploreController } from './types';
import { createViewController } from './view-controller';
Expand Down Expand Up @@ -217,16 +216,11 @@ export async function initializeExploreRuntime(): Promise<ExploreController> {
});
lifecycle.addCleanup(() => viewController.dispose());

const persistedLegendController = createPersistedLegendController({
plotElement,
});

const interactionController = createInteractionController({
legendElement,
plotElement,
selectedProteinElement,
structureViewer,
clearPersistedLegendHiddenValues: persistedLegendController.clearPersistedLegendHiddenValues,
});

const datasetController = createDatasetController({
Expand Down Expand Up @@ -275,12 +269,6 @@ export async function initializeExploreRuntime(): Promise<ExploreController> {
'data-isolation-reset',
interactionController.updateLegend,
);
addTrackedEventListener(
lifecycle,
legendElement,
'legend-item-click',
interactionController.handleLegendItemClick,
);
addTrackedEventListener(
lifecycle,
legendElement,
Expand Down Expand Up @@ -321,11 +309,9 @@ export async function initializeExploreRuntime(): Promise<ExploreController> {
addControlBarListener(type, listener, options) {
addTrackedEventListener(lifecycle, controlBar, type, listener, options);
},
controlBar,
datasetController,
handleExport,
interactionController,
plotElement,
viewController,
});

Expand Down
35 changes: 33 additions & 2 deletions app/tests/url-view-state.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -813,7 +813,7 @@ test.describe('URL-backed explore view state', () => {
await expect(page).toHaveURL(/foo=1/);
});

test('resets hidden legend state when back navigation changes annotation via URL', async ({
test('restores hidden legend state when back navigation returns to a previous annotation via URL', async ({
page,
}) => {
await page.goto(
Expand All @@ -838,6 +838,37 @@ test.describe('URL-backed explore view state', () => {

await page.goBack();
await waitForView(page, { annotation: targetAnnotation, projection: targetProjection });
await expect.poll(() => isLegendItemHidden(page, firstLegendValue)).toBe(false);
// Per-annotation legend visibility is persisted (datasetHash + annotation), so
// returning to the original annotation restores the previously hidden category.
await expect.poll(() => isLegendItemHidden(page, firstLegendValue)).toBe(true);
});

test('keeps hidden legend categories when switching annotation away and back via the control bar', async ({
page,
}) => {
await page.goto(
`/explore?annotation=${encodeURIComponent(targetAnnotation)}&projection=${encodeURIComponent(targetProjection)}`,
);
await dismissTourIfPresent(page);
await waitForExploreDataLoad(page);
await waitForView(page, { annotation: targetAnnotation, projection: targetProjection });

const currentView = await getCurrentView(page);
const nextAnnotation = currentView.annotations.find(
(annotation) => annotation !== targetAnnotation,
);
expect(nextAnnotation).toBeTruthy();

const firstLegendValue = await getFirstLegendItemValue(page);
await clickLegendItem(page, firstLegendValue);
await expect.poll(() => isLegendItemHidden(page, firstLegendValue)).toBe(true);

// Switch to another annotation and back, both via the control bar (no URL navigation).
await selectAnnotation(page, nextAnnotation!);
await waitForView(page, { annotation: nextAnnotation! });
await selectAnnotation(page, targetAnnotation);
await waitForView(page, { annotation: targetAnnotation });

await expect.poll(() => isLegendItemHidden(page, firstLegendValue)).toBe(true);
});
});
Loading
Loading