diff --git a/docs/explore/images/duplicate-badges.gif b/docs/explore/images/duplicate-badges.gif index 9fe576485..deed9ac04 100644 Binary files a/docs/explore/images/duplicate-badges.gif and b/docs/explore/images/duplicate-badges.gif differ diff --git a/docs/explore/images/structure-viewer.png b/docs/explore/images/structure-viewer.png index 92c0f5437..8f08ddc5b 100644 Binary files a/docs/explore/images/structure-viewer.png and b/docs/explore/images/structure-viewer.png differ diff --git a/docs/explore/index.md b/docs/explore/index.md index 9646b395c..b7586cc27 100644 --- a/docs/explore/index.md +++ b/docs/explore/index.md @@ -57,7 +57,7 @@ The legend shows the current coloring scheme: ## Structure Viewer -When you select a protein, the 3D viewer appears and fetches its structure from AlphaFold (if available). The viewer includes direct links to the [AlphaFold Database](https://alphafold.ebi.ac.uk/), [UniProt](https://www.uniprot.org/), and [InterPro](https://www.interpro.org/) entries for the selected protein. +When you select a protein, the 3D viewer appears and fetches its structure from AlphaFold (if available). The viewer includes direct links to the [AlphaFold Database](https://alphafold.ebi.ac.uk/), [UniProt](https://www.uniprot.org/), [InterPro](https://www.interpro.org/), and [TED](https://ted.cathdb.info/) entries for the selected protein. Structure Viewer - showing 3D protein structure diff --git a/docs/explore/structures.md b/docs/explore/structures.md index 853a51972..1b4fb7a18 100644 --- a/docs/explore/structures.md +++ b/docs/explore/structures.md @@ -9,7 +9,7 @@ ProtSpace integrates with AlphaFold to display 3D protein structures alongside y When you select a protein with a UniProt accession: 1. The structure viewer appears in the sidebar below the legend -2. Links to [AlphaFold Database](https://alphafold.ebi.ac.uk/), [UniProt](https://www.uniprot.org/), and [InterPro](https://www.interpro.org/) appear at the top - click them anytime +2. Links to [AlphaFold Database](https://alphafold.ebi.ac.uk/), [UniProt](https://www.uniprot.org/), [InterPro](https://www.interpro.org/), and [TED](https://ted.cathdb.info/) appear at the top - click them anytime 3. The AlphaFold structure file is fetched directly from the [AlphaFold Database API](https://alphafold.ebi.ac.uk/api-docs); the [3D-Beacons API](https://www.ebi.ac.uk/pdbe/pdbe-kb/3dbeacons/) is used only to look up the model page link ::: tip Supported Structures diff --git a/openspec/changes/add-ted-link/.openspec.yaml b/openspec/changes/add-ted-link/.openspec.yaml new file mode 100644 index 000000000..5849c2dbf --- /dev/null +++ b/openspec/changes/add-ted-link/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-01 diff --git a/openspec/changes/add-ted-link/README.md b/openspec/changes/add-ted-link/README.md new file mode 100644 index 000000000..33edfdb02 --- /dev/null +++ b/openspec/changes/add-ted-link/README.md @@ -0,0 +1,3 @@ +# add-ted-link + +Add a TED external-resource link to the protein structure viewer for issue #344. diff --git a/openspec/changes/add-ted-link/design.md b/openspec/changes/add-ted-link/design.md new file mode 100644 index 000000000..35acfd6eb --- /dev/null +++ b/openspec/changes/add-ted-link/design.md @@ -0,0 +1,46 @@ +## Context + +The structure viewer header currently renders AlphaFold, UniProt, and InterPro destinations. Each destination URL is assembled in `header-links.ts`; UniProt and InterPro share `getBaseAccession()` so versioned protein IDs target the canonical accession. Issue #344 requests a TED link using `https://ted.cathdb.info/uniprot/`. + +## Goals / Non-Goals + +**Goals:** + +- Render TED beside the existing UniProt and InterPro links for every selected protein. +- Reuse the existing base-accession normalization and URL encoding contract. +- Preserve the existing safe new-tab attributes and visual treatment. +- Protect the URL builder and rendered behavior with focused tests. + +**Non-Goals:** + +- Redesign the structure viewer header or link styles. +- Add TED annotations, data fetching, availability checks, or navigation tracking. +- Refactor all resource links into a new abstraction. + +## Decisions + +### Extend the existing helper-and-template pattern + +Add a pure `buildTedUrl()` helper beside the existing destination builders, then render TED as a sibling header link separated by the existing middle-dot element. This keeps normalization in one tested boundary and follows the current component structure. + +Alternatives considered: + +- **Inline the TED URL in the Lit template.** Smaller in line count, but it duplicates normalization/encoding behavior and makes the URL contract harder to test independently. +- **Replace all links with a resource-descriptor array.** This could reduce repeated markup, but it expands issue scope and refactors working links for no user benefit. + +### Test the observable link and the URL boundary + +Add a jsdom component regression that renders a versioned protein ID and asserts that the real TED anchor has the expected href, new-tab target, and rel attributes. Add focused pure-helper cases for the exact TED pattern, version stripping, and encoding. The component test prevents an unused builder from appearing to fix the issue. + +## Risks / Trade-offs + +- **TED may not have a page for every UniProt accession** → Match the existing UniProt/InterPro behavior: expose the deterministic destination and let the external service report availability. +- **The extra label could tighten header space** → Reuse the existing wrapping flex container and compact link styles; verify the rendered desktop flow without introducing new layout rules. + +## Migration Plan + +No migration is required. The change is additive and can be rolled back by reverting the helper, anchor, tests, and spec artifacts. + +## Open Questions + +None. The issue supplies the canonical URL format and the existing header establishes placement and interaction behavior. diff --git a/openspec/changes/add-ted-link/proposal.md b/openspec/changes/add-ted-link/proposal.md new file mode 100644 index 000000000..d811205da --- /dev/null +++ b/openspec/changes/add-ted-link/proposal.md @@ -0,0 +1,29 @@ +## Why + +The protein structure viewer links selected proteins to UniProt and InterPro, but it omits TED even though TED provides a directly addressable UniProt-based protein page. Adding the missing link lets users move from a selected ProtSpace protein to its TED domain predictions without manually reconstructing the URL. + +## What Changes + +- Add TED as an external resource in the structure viewer header beside UniProt and InterPro. +- Build TED URLs from the normalized base UniProt accession used by the existing resource links. +- Add regression coverage for the URL contract and rendered header link. +- Update the Explore documentation and generated structure-viewer screenshot to show TED, + including stale image-pipeline readiness checks that blocked regeneration. + +## Capabilities + +### New Capabilities + +- `protein-resource-links`: External protein-resource links exposed by the structure viewer, including accession normalization and safe new-tab behavior. + +### Modified Capabilities + +None. + +## Impact + +- Affects the structure viewer header and its pure URL-building helpers in `packages/core`. +- Adds focused Vitest coverage in the same package. +- Updates the Explore resource-link descriptions, their shared generated screenshot, and the + image-pipeline readiness checks. +- Adds no dependencies, API changes, data migrations, or styling changes. diff --git a/openspec/changes/add-ted-link/specs/protein-resource-links/spec.md b/openspec/changes/add-ted-link/specs/protein-resource-links/spec.md new file mode 100644 index 000000000..b8edf09c1 --- /dev/null +++ b/openspec/changes/add-ted-link/specs/protein-resource-links/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Structure viewer exposes protein resource links + +When a protein is selected and the structure viewer header is shown, the system SHALL expose UniProt, InterPro, and TED as external resource links for that protein. + +#### Scenario: TED link is shown with existing protein resources + +- **WHEN** the structure viewer renders a selected protein +- **THEN** its header shows a link named `TED` beside the UniProt and InterPro links +- **AND** the TED link opens in a new tab without granting the destination access to the opener + +### Requirement: TED link targets the canonical UniProt accession + +The system SHALL build the TED destination as `https://ted.cathdb.info/uniprot/`, where `` is the URL-encoded base accession before any version suffix. + +#### Scenario: Unversioned accession targets TED + +- **WHEN** the selected protein ID is `W6JQJ9` +- **THEN** the TED link target is `https://ted.cathdb.info/uniprot/W6JQJ9` + +#### Scenario: Versioned accession targets its base entry + +- **WHEN** the selected protein ID is `W6JQJ9.2` +- **THEN** the TED link target is `https://ted.cathdb.info/uniprot/W6JQJ9` + +#### Scenario: Accession is safely encoded + +- **WHEN** a protein ID contains characters that are not safe in a URL path segment +- **THEN** the base accession is URL-encoded in the TED link target diff --git a/openspec/changes/add-ted-link/tasks.md b/openspec/changes/add-ted-link/tasks.md new file mode 100644 index 000000000..0112ec6d9 --- /dev/null +++ b/openspec/changes/add-ted-link/tasks.md @@ -0,0 +1,17 @@ +## 1. Regression Coverage + +- [x] 1.1 Add focused TED URL-builder and rendered-header regression tests. +- [x] 1.2 Run the focused tests against the current implementation and record the expected RED failure. + +## 2. Minimal Implementation + +- [x] 2.1 Add the TED URL builder using the existing base-accession normalization. +- [x] 2.2 Render the TED anchor beside UniProt and InterPro with matching safe new-tab behavior. +- [x] 2.3 Run the focused tests and record GREEN. + +## 3. Verification + +- [x] 3.1 Repeat the original browser reproduction and verify the TED label and exact href. +- [x] 3.2 Run the affected package checks and the repository-mandated `pnpm precommit` gate. +- [x] 3.3 Repair the stale image-pipeline readiness checks, update the Explore descriptions, and + regenerate their shared structure-viewer screenshot. diff --git a/packages/core/src/components/structure-viewer/header-links.test.ts b/packages/core/src/components/structure-viewer/header-links.test.ts index e73a63362..ade2dbadf 100644 --- a/packages/core/src/components/structure-viewer/header-links.test.ts +++ b/packages/core/src/components/structure-viewer/header-links.test.ts @@ -4,6 +4,7 @@ import { buildAlphaFoldUrl, buildUniProtUrl, buildInterProUrl, + buildTedUrl, } from './header-links'; describe('header-links', () => { @@ -76,4 +77,14 @@ describe('header-links', () => { ); }); }); + + describe('buildTedUrl', () => { + it('builds a TED URL from the base accession', () => { + expect(buildTedUrl('W6JQJ9.2')).toBe('https://ted.cathdb.info/uniprot/W6JQJ9'); + }); + + it('encodes special characters in the accession', () => { + expect(buildTedUrl('A B')).toBe('https://ted.cathdb.info/uniprot/A%20B'); + }); + }); }); diff --git a/packages/core/src/components/structure-viewer/header-links.ts b/packages/core/src/components/structure-viewer/header-links.ts index 0e7b45c18..ae8d90c64 100644 --- a/packages/core/src/components/structure-viewer/header-links.ts +++ b/packages/core/src/components/structure-viewer/header-links.ts @@ -27,3 +27,10 @@ export function buildUniProtUrl(proteinId: string): string { export function buildInterProUrl(proteinId: string): string { return `https://www.ebi.ac.uk/interpro/protein/UniProt/${encodeURIComponent(getBaseAccession(proteinId))}/`; } + +/** + * Build the TED protein page URL for a protein. + */ +export function buildTedUrl(proteinId: string): string { + return `https://ted.cathdb.info/uniprot/${encodeURIComponent(getBaseAccession(proteinId))}`; +} diff --git a/packages/core/src/components/structure-viewer/structure-viewer.component.test.ts b/packages/core/src/components/structure-viewer/structure-viewer.component.test.ts new file mode 100644 index 000000000..ded3e4b5d --- /dev/null +++ b/packages/core/src/components/structure-viewer/structure-viewer.component.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import './structure-viewer'; + +type StructureViewerElement = HTMLElement & { + autoSync: boolean; + proteinId: string | null; + updateComplete: Promise; +}; + +describe('protspace-structure-viewer resource links', () => { + beforeEach(() => { + document.body.innerHTML = ''; + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 1), + ); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('renders TED beside the existing protein resources', async () => { + const viewer = document.createElement('protspace-structure-viewer') as StructureViewerElement; + viewer.autoSync = false; + viewer.proteinId = 'W6JQJ9.2'; + document.body.appendChild(viewer); + await viewer.updateComplete; + + const tedLink = Array.from( + viewer.shadowRoot!.querySelectorAll('.header-link'), + ).find((link) => link.textContent?.trim() === 'TED'); + + expect({ + href: tedLink?.getAttribute('href'), + rel: tedLink?.getAttribute('rel'), + target: tedLink?.getAttribute('target'), + }).toEqual({ + href: 'https://ted.cathdb.info/uniprot/W6JQJ9', + rel: 'noopener noreferrer', + target: '_blank', + }); + }); +}); diff --git a/packages/core/src/components/structure-viewer/structure-viewer.ts b/packages/core/src/components/structure-viewer/structure-viewer.ts index f405b76fe..ddeb94b6b 100644 --- a/packages/core/src/components/structure-viewer/structure-viewer.ts +++ b/packages/core/src/components/structure-viewer/structure-viewer.ts @@ -5,7 +5,7 @@ import { StructureService } from '@protspace/utils'; import type { StructureData } from '@protspace/utils'; import { structureViewerStyles } from './structure-viewer.styles'; import { createMolstarViewer, type MolstarViewer } from './molstar-loader'; -import { buildAlphaFoldUrl, buildUniProtUrl, buildInterProUrl } from './header-links'; +import { buildAlphaFoldUrl, buildInterProUrl, buildTedUrl, buildUniProtUrl } from './header-links'; import { createStructureErrorEventDetail, createStructureLoadDetail, @@ -340,6 +340,16 @@ export class ProtspaceStructureViewer extends LitElement { > InterPro + · + + TED +
diff --git a/playwright.config.ts b/playwright.config.ts index 4afa006ed..54cee6406 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -58,7 +58,7 @@ export default defineConfig({ // For better WebGL on CI, consider using xvfb with headless: false headless: !!process.env.CI, }, - testMatch: /capture-static\.spec\.ts/, + testMatch: /capture-(?:helpers|static)\.spec\.ts/, }, { name: 'animations', diff --git a/scripts/docs-screenshots/capture-animations.spec.ts b/scripts/docs-screenshots/capture-animations.spec.ts index 3e245ccd8..b63da9517 100644 --- a/scripts/docs-screenshots/capture-animations.spec.ts +++ b/scripts/docs-screenshots/capture-animations.spec.ts @@ -1,7 +1,32 @@ -import { test, type Page } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; import * as path from 'path'; import * as fs from 'fs'; +type DuplicateStackProbe = { + key: string; + x: number; + y: number; + points: unknown[]; +}; + +type DuplicatePlotProbe = HTMLElement & { + config?: Record; + data?: { projections?: Array<{ name: string }> }; + selectedProjectionIndex: number; + updateComplete: Promise; + _dupOverlay?: { + byKey?: Map; + expandedKey?: string | null; + }; + _scales?: { x: (v: number) => number; y: (v: number) => number }; + _transform?: { x: number; y: number; k: number }; +}; + +type DuplicateControlBarProbe = HTMLElement & { + applyProjectionSelection(projection: string): void; + selectedProjection?: string; +}; + /** * Collects screen-space coordinates of points whose currently-selected * annotation value contains `substringMatch`, then trims outliers via MAD. @@ -520,33 +545,55 @@ test.describe('Scatterplot Animation Captures', () => { // Pre-enable the duplicate-counts setting so badges render without // animating the cog → checkbox path (keeps the GIF focused on the badge). - await page.evaluate(() => { - const plot = document.querySelector('#myPlot') as - | (HTMLElement & { config?: Record }) - | null; - if (!plot) return; + await page.evaluate(async () => { + const plot = document.querySelector('#myPlot') as DuplicatePlotProbe | null; + const controlBar = document.querySelector('#myControlBar') as DuplicateControlBarProbe | null; + if (!plot || !controlBar) { + throw new Error('Duplicate-badge capture needs #myPlot and #myControlBar'); + } + + const pca = plot.data?.projections?.find((projection) => projection.name.includes('PCA')); + if (!pca) { + throw new Error('Duplicate-badge capture requires a PCA projection'); + } + + controlBar.applyProjectionSelection(pca.name); + await plot.updateComplete; plot.config = { ...(plot.config ?? {}), enableDuplicateStackUI: true }; + await plot.updateComplete; }); + await expect + .poll( + () => + page.evaluate(() => { + const plot = document.querySelector('#myPlot') as DuplicatePlotProbe | null; + const controlBar = document.querySelector( + '#myControlBar', + ) as DuplicateControlBarProbe | null; + const plotProjection = plot?.data?.projections?.[plot.selectedProjectionIndex]?.name; + return !!plotProjection && plotProjection === controlBar?.selectedProjection; + }), + { timeout: 1_000 }, + ) + .toBe(true); + // Wait for the scatter-plot to (re)compute its duplicate stacks. The // overlay update is debounced behind the config change + a quadtree // rebuild, so poll the private cache until at least one multi-point // stack appears. await page.waitForFunction( () => { - const plot = document.querySelector('#myPlot') as - | (HTMLElement & { - _duplicateStackByKey?: Map; - }) - | null; - const map = plot?._duplicateStackByKey; + const plot = document.querySelector('#myPlot') as DuplicatePlotProbe | null; + const map = plot?._dupOverlay?.byKey; if (!map || map.size === 0) return false; for (const stack of map.values()) { if (stack.points.length > 1) return true; } return false; }, - { timeout: 10_000 }, + undefined, + { timeout: 10_000, polling: 200 }, ); await page.waitForTimeout(INITIAL_PAUSE); @@ -561,15 +608,8 @@ test.describe('Scatterplot Animation Captures', () => { // Snapshot the duplicate-stack candidates so the heuristic can pick one. const collectStacks = async (): Promise => page.evaluate(() => { - const plot = document.querySelector('#myPlot') as - | (HTMLElement & { - _duplicateStackByKey?: Map< - string, - { key: string; x: number; y: number; points: unknown[] } - >; - }) - | null; - const map = plot?._duplicateStackByKey; + const plot = document.querySelector('#myPlot') as DuplicatePlotProbe | null; + const map = plot?._dupOverlay?.byKey; if (!map) return []; const out: StackInfo[] = []; for (const stack of map.values()) { @@ -622,14 +662,8 @@ test.describe('Scatterplot Animation Captures', () => { // current scales + zoom transform, exactly like select-single.gif does. const stackToScreen = async (stackKey: string) => page.evaluate((key: string) => { - const plot = document.querySelector('#myPlot') as - | (HTMLElement & { - _duplicateStackByKey?: Map; - _scales?: { x: (v: number) => number; y: (v: number) => number }; - _transform?: { x: number; y: number; k: number }; - }) - | null; - const stack = plot?._duplicateStackByKey?.get(key); + const plot = document.querySelector('#myPlot') as DuplicatePlotProbe | null; + const stack = plot?._dupOverlay?.byKey?.get(key); if (!stack || !plot?._scales) return null; const transform = plot._transform ?? { x: 0, y: 0, k: 1 }; const rect = plot.getBoundingClientRect(); @@ -652,17 +686,13 @@ test.describe('Scatterplot Animation Captures', () => { }; // Re-wait for the anchor stack to be materialized in the current viewport. - // After a zoom/pan, _duplicateStackByKey rebuilds — poll until our + // After a zoom/pan, the duplicate-stack controller rebuilds — poll until our // anchor.key reappears with its multi-point membership. const waitForAnchorVisible = async () => page.waitForFunction( (key: string) => { - const plot = document.querySelector('#myPlot') as - | (HTMLElement & { - _duplicateStackByKey?: Map; - }) - | null; - const stack = plot?._duplicateStackByKey?.get(key); + const plot = document.querySelector('#myPlot') as DuplicatePlotProbe | null; + const stack = plot?._dupOverlay?.byKey?.get(key); return !!stack && stack.points.length > 1; }, anchor.key, @@ -670,16 +700,14 @@ test.describe('Scatterplot Animation Captures', () => { ); // After clicking the underlying point, the spider should expand. Poll the - // private _expandedDuplicateStackKey to confirm the click landed and the + // controller's private expanded key to confirm the click landed and the // spider actually opened — without this, a missed click would silently // produce a blank GIF instead of failing the test. const waitForSpiderOpen = async () => page.waitForFunction( (key: string) => { - const plot = document.querySelector('#myPlot') as - | (HTMLElement & { _expandedDuplicateStackKey?: string | null }) - | null; - return plot?._expandedDuplicateStackKey === key; + const plot = document.querySelector('#myPlot') as DuplicatePlotProbe | null; + return plot?._dupOverlay?.expandedKey === key; }, anchor.key, { timeout: 2_000 }, diff --git a/scripts/docs-screenshots/capture-helpers.spec.ts b/scripts/docs-screenshots/capture-helpers.spec.ts new file mode 100644 index 000000000..09e19014a --- /dev/null +++ b/scripts/docs-screenshots/capture-helpers.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; +import { waitForDataLoad, waitForLegend } from './helpers'; + +test.describe('Documentation screenshot wait helpers', () => { + test('waitForDataLoad honors its readiness timeout', async ({ page }) => { + test.setTimeout(2_000); + await page.setContent(''); + + await expect(waitForDataLoad(page, 100)).rejects.toThrow(/Timeout 100ms exceeded/); + }); + + test('waitForDataLoad honors its loading-overlay timeout', async ({ page }) => { + test.setTimeout(2_000); + await page.setContent( + '
loading
', + ); + await page.evaluate(() => { + const plot = document.querySelector('#myPlot') as HTMLElement & { + data?: { protein_ids: string[] }; + _plotData?: { length: number }; + _scales?: object; + }; + plot.data = { protein_ids: ['P12345'] }; + plot._plotData = { length: 1 }; + plot._scales = {}; + }); + + await expect(waitForDataLoad(page, 100)).rejects.toThrow(/Timeout 100ms exceeded/); + }); + + test('waitForLegend honors its item timeout', async ({ page }) => { + test.setTimeout(2_000); + await page.setContent(''); + + await expect(waitForLegend(page, 100)).rejects.toThrow(/Timeout 100ms exceeded/); + }); +}); diff --git a/scripts/docs-screenshots/helpers.ts b/scripts/docs-screenshots/helpers.ts index dac5d2255..494c19ed6 100644 --- a/scripts/docs-screenshots/helpers.ts +++ b/scripts/docs-screenshots/helpers.ts @@ -52,16 +52,17 @@ export async function waitForDataLoad(page: Page, timeout = 30000): Promise !document.getElementById('progressive-loading'), { + await page.waitForFunction(() => !document.getElementById('progressive-loading'), undefined, { timeout, polling: 100, }); @@ -82,6 +83,7 @@ export async function waitForLegend(page: Page, timeout = 15000): Promise const items = legend.shadowRoot.querySelectorAll('.legend-item'); return items.length > 0; }, + undefined, { timeout, polling: 200 }, );