Skip to content

feat(structure-viewer): add TED, restyle the resource row, and gate the docs-screenshot scripts - #402

Merged
tsenoner merged 30 commits into
mainfrom
feat/344-ted-link
Sep 16, 2026
Merged

tsenoner merged 30 commits into
mainfrom
feat/344-ted-link

Conversation

@FlorinSenoner

@FlorinSenoner FlorinSenoner commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Closes #344. Closes #293.

Started as "add a TED link". Grew, deliberately, into the three things that link exposed.

1. The TED link, and the row it lives in

buildTedUrl joins the existing builders and reuses getBaseAccession, so a versioned ID targets the canonical accession.

Adding a fourth link made three existing problems visible, so the row was rebuilt:

  • The links rendered at 0.75rem in --text-secondary — the same treatment as the accession beside them. They read as metadata, and nothing marked them as leaving the app.
  • AlphaFold wasn't in the row at all. It was the header title, so the destination the viewer is named after was a link almost nobody would find.
  • .header-links was a non-wrapping flex row, so each new resource made the group monolithically wider.
AlphaFold2 Structure   A0A023VZF1                    ✕
AlphaFold↗ · UniProt↗ · InterPro↗ · TED↗

The row is defined once, as RESOURCE_LINKS beside the URL builders, so a destination is one entry rather than an eight-line anchor plus a hand-placed ·.

Two details only the browser caught:

  • The separator trails its item rather than leading the next, so a wrapped row starts on a label (a leading dot wrapped with the link it preceded — at a 220px sidebar the second line began · InterPro). It hangs off a list item, not the link: generated inside the <a> it became part of every link's accessible name ("AlphaFold ·"), click target and focus ring. The row is a labelled role="list" now; verified in Chromium's accessibility tree.
  • Only .header-link-label is underlined. text-decoration propagates into descendants and can't be cancelled there, so underlining the anchor drew the line under the arrow and the separator too.

Links are deliberately unconditional. No availability checks, no requests. Measured first: all three probe endpoints are CORS-open and fast (alphafold.ebi.ac.uk/api/domains ~96ms, rest.uniprot.org ~281ms, ebi.ac.uk/interpro/api ~217ms), so feasibility wasn't the objection. Links would flicker ~250ms after each selection; a network error is indistinguishable from "no entry", so a flaky connection would hide working links; and ProtSpace never validates that an ID is a UniProt accession at all. Recorded in design.md.

2. The docs-screenshot pipeline, which was silently rotting

The TED screenshot couldn't be regenerated, because the capture suite was broken — and nothing said so.

  • The duplicate-badge capture bound to scatter-plot internals renamed in b1695b30. Repaired, and selectProjection moved into helpers.ts beside selectAnnotation, which already owned that shape. The inline expect.poll it replaced compared the plot's projection to the control bar's — but applyProjectionSelection sets both synchronously, and PCA is projections[0], so that predicate held whether or not the switch propagated.
  • capture-helpers.spec.ts matched no Playwright project, so playwright test --list never collected it. Its assertions were thin anyway: all three gates in waitForDataLoad reject with the same Timeout 100ms exceeded, so the test claiming to pin the readiness contract passed whether or not its setup satisfied that gate. Deleted.
  • Eleven waitForFunction calls passed options into the arg slot. The signature is (pageFunction, arg, options), so every written timeout and polling value was silently ignored in favour of Playwright's defaults. Verified against playwright-core@1.61.0.

Root cause: scripts/ was in no tsconfig, matched no ESLint config, and no workflow ran it. Now type-checked and linted via pnpm quality / pnpm lint. That immediately surfaced headless: 'new' (Puppeteer's string, not Playwright's boolean) and a page argument logAction never read at 20 call sites.

Hand-fixing 11 call sites wasn't enough — a twelfth survived, in a file that same commit edited. So there's a no-restricted-syntax rule instead: 1 error, 0 false positives across all 68 waitForFunction calls in the repo. The rule belongs to browser automation, not to scripts/, so apps/web/tests — the suite that actually runs in CI, previously matched by no ESLint glob — is covered too.

knip was already in the quality gate and already blind: knip.jsonc globbed scripts/**/*.ts as entry, making every file its own entry root. Narrowed to real roots, it found 1 unused file and 13 unused exports — more rot than both new tools combined.

3. A guard for something that should worry you

content: '\00b7' inside Lit's css tagged template made the cooked string undefined and silently voided the entire component stylesheet. The shadow root went from 37 CSS rules to 0. tsc, knip, all 128 core tests and the docs build stayed green, because jsdom never evaluates CSS. It was found by opening the page.

One assertion on cssText now covers that class for every stylesheet — verified by reintroducing the exact escape: the guard fails while all 21 structure-viewer tests still pass. The test finds sheets by glob (31 of them) rather than a hand-kept table, which had already missed three; the three components that still declared static styles = css inline moved into styles modules; and ESLint rejects a css literal anywhere else in packages/core, so no sheet sits out of the guard's reach.

Also fixed: markdown.image.lazyLoading had lazy-loaded the LCP image on four Explore pages, including a 3.7 MB GIF. Those four are eager with explicit dimensions; below-the-fold images still defer.

4. Annotation search, and labels in the query builder (#293)

Typing ted returned the four Biocentral predictions, because they're keyed predic**ted**_* and the filter matched any substring of the raw column name — text never shown on screen. The query builder had the mirror problem: it printed column names (cath, ec) where the control bar shows labels ("CATH-Gene3D", "EC number").

  • Both pickers show labels. The query builder's picker and condition button draw an annotation through the same renderAnnotationName as the dropdown: the label, plus the ⚡ badge for a predicted annotation, which also tells apart the two "Subcellular location" columns. Conditions still store the column name.
  • Search matches only the displayed label, as a case-insensitive substring, through one shared function. An intermediate version also matched column-name words; it offered predicted → "Membrane", "Signal peptide", … — rows with no visible "predicted" — so it was dropped. An unregistered column is labelled by its prettified name and stays findable through it.

5. Automation reads the scatter plot through public accessors

The docs captures and an e2e helper re-derived the plot's projection from _plotData/_scales/_transform in five places. One had rotted: select-single read _plotData[idx].x from before PlotData went columnar, got undefined, and clicked the plot centre every run. The plot now exposes dataToClient (through the interaction SVG's screen matrix — the exact inverse of the d3.pointer hit-test; the host rect is 1px off across its border), getProteinClientPosition, getDuplicateStacks and getExpandedDuplicateStackKey, and every probe uses them.

Also: one accession rule (getBaseAccession, now in @protspace/utils) for both the structure fetch and the links; apps/web/tests linted by the app's own lint; perf/, tests/ and docs/ linted for the first time (clean).

Tests

  • pnpm test:ci — utils 384, core 1814, app 167
  • pnpm format:check, pnpm lint, pnpm quality, docs:annotations:check, docs:build
  • e2e numeric-binning + eat-visualization — 43/43 against a live dev server
  • docs animations zoom, select-single, select-box, duplicate-badges — 4/4, select-single now clicking real points
  • playwright test --project=screenshots — 15/15 against a live dev server
  • strict OpenSpec validation — 26 specs; four changes archived before merge per AGENTS.md
  • browser-verified: the rendered row at full and 220px widths, the exact TED href, the link row's accessibility tree and focus ring, and the query builder listing labels

Do not squash — this touches no apps/protspace/ path, but the repo rule stands.

🤖 Generated with Claude Code

https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
https://claude.ai/code/session_01M9am9XhSUJdEjRLJVc7xB5

@FlorinSenoner
FlorinSenoner marked this pull request as ready for review August 1, 2026 20:40
@tsenoner

tsenoner commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Automated review

Does it solve #344? Yes — the PR adds buildTedUrl() in packages/core/src/components/structure-viewer/header-links.ts, reusing the same getBaseAccession() + encodeURIComponent() normalization as the UniProt and InterPro builders, and renders a fourth .header-link anchor with matching target/rel attributes. It reaches real users: apps/web/src/pages/Explore.tsx sets show-header="true", so the header renders on every protein selection, and buildTedUrl('W6JQJ9.2') produces exactly the URL from the issue's example. Nothing the issue asked for is missing; the only gap is peripheral documentation.

Found 1 issue:

  1. Two user-facing docs pages enumerate the structure-viewer header links by name and still say AlphaFold, UniProt and InterPro, so they now contradict the shipped UI — docs/explore/structures.md:12 and docs/explore/index.md:60 are closed enumerations, not examples, so a reader sees a fourth link the docs say does not exist, with no explanation of what TED is (even though docs/guide/annotations.md already describes it). This is a one-line addition on each page; the same drift happened for the sibling feature, where the InterPro link commit (2bbff9a) left both sentences stale and a separate docs pass (f854a36) had to fix them four days later. The shared screenshot docs/explore/images/structure-viewer.png embedded on both pages also predates the change and can be regenerated with pnpm docs:images.

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
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

Deliberately not flagged: the TED anchor rendering unconditionally (it mirrors the pre-existing AlphaFold/UniProt/InterPro anchors, and design.md records the decision to match existing behaviour rather than add availability checks) and the repeated anchor markup (design.md explicitly rejects a resource-descriptor array as scope creep here).

🤖 Generated with Claude Code

Reviewed at ef9b3dc against issue #344.

@FlorinSenoner

Copy link
Copy Markdown
Collaborator Author

Disposition: actionable and needs to be addressed.

Verified against current head ef9b3dc2ca2dc6847557f53177f8df5c4d4c188d: structure-viewer.ts renders TED, while docs/explore/structures.md:12 and docs/explore/index.md:60 still enumerate only AlphaFold, UniProt, and InterPro. Both pages embed docs/explore/images/structure-viewer.png; the checked-in screenshot visibly omits TED, and the documented pnpm docs:images pipeline regenerates it.

Required direction: update both Explore descriptions to include/link TED and regenerate the shared structure-viewer screenshot. No implementation was performed as part of this triage.

@FlorinSenoner

Copy link
Copy Markdown
Collaborator Author

Implemented the verified documentation feedback in 569e67dd975c1018d43d1785a5d9ce4bcda9d708: both Explore pages now link TED, the shared structure-viewer screenshot was regenerated and visually verified with the TED link present, and stale screenshot-pipeline readiness probes were updated for the current scatterplot/controller internals. Verification passed with pnpm docs:images (13 static screenshots, 7 animations, 7 GIF conversions), openspec validate add-ted-link --strict, and pnpm precommit.

tsenoner and others added 2 commits August 6, 2026 11:42
- Insert the missing `undefined` arg in the three `page.waitForFunction`
  calls in scripts/docs-screenshots/helpers.ts (waitForDataLoad readiness
  predicate, the #progressive-loading overlay wait, and waitForLegend) so
  their `timeout` and `polling` land in the options position instead of
  being serialized as the unused page-function argument and discarded.
  Matches the form already used at helpers.ts waitForWebGLContext and in
  capture-animations.spec.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qoU16kDQxz6U3H2UWbbm2
Reverts 9617f85. Moving the options object into waitForFunction's third
argument is the correct fix -- the options were silently discarded in the
`arg` slot -- but it is not behavior-preserving, so it should be the
author's call rather than an automated cleanup.

Because playwright.config.ts leaves `actionTimeout` unset (default 0), the
three waits in scripts/docs-screenshots/helpers.ts were previously
unbounded, capped only by the 60s per-test timeout. Applying the fix caps
them at 30s/30s/15s and switches polling from `raf` to fixed 200/100/200ms
intervals, which can turn a slow-but-successful screenshot capture into a
hard failure.

Raised as a review comment instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qoU16kDQxz6U3H2UWbbm2
@tsenoner

tsenoner commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Adversarial review

Reviewed in an isolated worktree by three independent lenses (code quality, adversarial correctness, issue-resolution audit), with every finding then put through a refuter whose default position was that it is a false positive. 4 raised, 2 survived refutation.

Applied, then reverted (7f7ca7ee)

I initially applied the waitForFunction argument-position fix in scripts/docs-screenshots/helpers.ts,
then reverted it. An adversarial confirm pass proved it is not behavior-preserving, so it belongs to
you rather than to an automated cleanup pass. The branch is back to its original state; details are in
the findings below.

Issue resolution — resolves the issue

The triage is correct and the PR description is honest — unusually so. Nothing was narrowed,
redirected, or declined. I independently checked the two claims that could have been hand-waving:

  1. "There was no TED URL builder or TED anchor, so the resource could never appear" — confirmed
    against origin/main; header-links.ts had only AlphaFold/UniProt/InterPro builders.
  2. The "stale image-pipeline readiness checks that blocked regeneration" claim (openspec
    proposal.md:11) is real, not an excuse for scope creep. scripts/docs-screenshots/helpers.ts:55
    used Array.isArray(plot._plotData), but _plotData is typed PlotData
    (packages/core/src/components/scatter-plot/scatter-plot.ts:142), and PlotData is a struct-of-
    arrays interface with a readonly length field (packages/utils/src/types.ts:125-131) — never an
    array. So waitForDataLoad could never return true and every screenshot capture timed out. The one-
    line fix to plot._plotData?.length was genuinely required to regenerate structure-viewer.png
    (which comes from capture-static.spec.ts:207-241, a consumer of that shared helper).
    "Closes [FEATURE] Add TED link #344" is warranted. The issue is a single, fully-specified feature request with no comments
    expanding its scope (I fetched it: gh issue view 344 shows an empty comments array), the requested
    URL shape is implemented exactly, and the user-visible outcome is demonstrated in the committed
    screenshot. I would not downgrade this to "Related to".
    The one place the PR overreached without saying so in the PR body: the capture-animations.spec.ts
    rewrite (see gaps).
Gaps found by the issue audit (5)
  • Scope creep in scripts/docs-screenshots/capture-animations.spec.ts (+47/-40) that this PR cannot verify. Only the one-line helpers.ts:55 fix was needed to regenerate structure-viewer.png (produced by capture-static.spec.ts:207). The animations spec changes rewire the duplicate-stack probes from the removed _duplicateStackByKey / _expandedDuplicateStackKey to _dupOverlay.byKey / _dupOverlay.expandedKey, add a forced PCA-projection selection that throws if absent, and switch a waitForFunction to explicit polling: 200.
    • Why it matters: These probes drive the duplicate-badge GIF, which this PR does not regenerate — the only image changed is structure-viewer.png. The new field paths are runtime-reachable (TypeScript private is soft-private; I confirmed byKey at duplicate-stack-overlay-controller.ts:79 and expandedKey at :81, and _dupOverlay at scatter-plot.ts:241), so the fix is plausible — but it ships unexercised. If the projection-index or polling change is subtly wrong, the breakage surfaces on some future unrelated docs regeneration, and the blame will point at that PR rather than this one. It also makes this PR's diff larger than its stated purpose.
    • Suggested follow-up: Either split the capture-animations.spec.ts repair into its own PR that actually runs the animation captures and commits the regenerated GIF, or run the duplicate-badge capture once on this branch and note the result in the PR body so the change is not merged untested.
  • TED coverage is narrower than UniProt/InterPro, but the link is rendered unconditionally for every proteinId. getBaseAccession only strips a .-suffix, so an isoform ID like P12345-2 or a non-UniProt dataset ID (my_seq_001, sp|P12345|NAME) is passed straight through and produces a dead TED URL.
    • Why it matters: docs/guide/faq.md:50 already tells users "Structures load automatically from AlphaFold if your protein IDs are UniProt accessions", i.e. non-UniProt datasets are an expected case. TED is derived from the AlphaFold DB, so its per-accession coverage is strictly narrower than UniProt's and somewhat narrower than InterPro's — the dead-link rate for the new TED link will be visibly higher than for the two links it sits beside. The design doc (design.md:37) consciously accepts this ("let the external service report availability"), which is defensible and consistent with existing behavior, so this is a follow-up rather than a blocker.
    • Suggested follow-up: Open a follow-up to handle unresolvable resource links uniformly — e.g. suppress or visually de-emphasize the UniProt/InterPro/TED row when _loadStructure reports No 3D structure was found for <id> (structure-viewer.ts:182), since that error is already a strong signal the accession is not in the AlphaFold DB and therefore not in TED.
  • .header-links is display: flex with no flex-wrap (packages/core/src/components/structure-viewer/structure-viewer.styles.ts:73-77); only the outer .header-info sets flex-wrap: wrap (:61-66). The design doc (design.md:39) states the change "Reuse[s] the existing wrapping flex container", which is inaccurate about the container the three links actually live in.
    • Why it matters: The link row grew from two labels to three plus an extra separator and can now only wrap as a whole unit, not between links. At the app's default right-panel width it fits — the regenerated screenshot proves that — but a narrower panel or a longer accession would overflow the row horizontally rather than wrap. There is no regression test or screenshot covering a narrow/mobile layout, so this would only be caught by eye.
    • Suggested follow-up: Add flex-wrap: wrap to the .header-links rule (a one-line, zero-risk change), and correct the design.md sentence so the accepted trade-off is recorded against the container that actually applies.
  • docs/explore/control-bar.md:39 still describes the annotation dropdown sections as "(UniProt, InterPro, Taxonomy, Other)", while packages/core/src/components/control-bar/annotation-categories.ts:9 defines 'Biocentral' | 'InterPro' | 'TED' | 'Taxonomy' | 'UniProt' | 'Other'.
    • Why it matters: Pre-existing staleness, not introduced here and genuinely outside issue [FEATURE] Add TED link #344's scope (it concerns annotation grouping, not resource links). Flagging it only because it is a TED-specific doc gap that this PR walked directly past while updating the two other TED-mentioning Explore docs — a reader comparing the docs will see TED acknowledged in one place and missing in another.
    • Suggested follow-up: Fold the one-line list update into this PR (it is already touching docs/explore/) or file a small docs: issue to resync control-bar.md with annotation-categories.ts.
  • The OpenSpec change is not archived: the new protein-resource-links capability exists only under openspec/changes/add-ted-link/specs/, never merged into openspec/specs/ (which currently holds only bundle-format-contract, prep-failure-routing, prep-observability, support-contact).
    • Why it matters: AGENTS.md states openspec/specs/ is "the source of truth for current behavior", so until /opsx:archive runs, the shipped resource-link behavior is not represented in the source of truth. Low severity: five other changes in the repo (add-eat-visualization, unified-visibility-model, optimize-e2e-suite, …) are likewise unarchived, so the repo's actual practice is clearly to archive on a separate cadence — this is consistent, not a deviation.
    • Suggested follow-up: No action needed for this PR; run /opsx:archive add-ted-link in the next batch archive pass so protein-resource-links lands in openspec/specs/.

Findings needing a decision (2)

These were left for you rather than auto-applied: each changes behavior, needs a product call, or reaches outside this diff.

1. waitForDataLoad/waitForLegend pass their waitForFunction options in the arg position, so the timeout and polling they compute are silently discarded.

scripts/docs-screenshots/helpers.ts:55 · low · correctness · applied then reverted

Playwright's signature is waitForFunction(pageFunction, arg, options) (verified in playwright- core@1.61.0/types/types.d.ts:5880) — there is no arity sniffing. helpers.ts:50-60, :64-67 and
:78-86 pass { timeout, polling: 200 } as the second positional argument, so it is serialized as
the (unused) page-function argument and the real options object is undefined. This PR fixed
exactly this mistake in the sibling file (capture-animations.spec.ts:564-575 now passes
undefined, { timeout: 10_000, polling: 200 }) but left it in the call it edited. Concrete failure:
await waitForDataLoad(page, 5000) does not fail after 5s — actionTimeout is unset in
playwright.config.ts, so the wait runs unbounded until the 60s test timeout and reports an opaque
"Test timeout of 60000ms exceeded" instead of a waitForFunction failure naming the readiness
predicate. This is exactly how the now-fixed Array.isArray(plot._plotData) staleness manifested.
polling: 200 is likewise ignored (defaults to raf).

Why it was reverted rather than kept: playwright.config.ts leaves actionTimeout unset (default
0), so all three waits were previously unbounded, capped only by the 60s per-test timeout. Applying
the fix caps them at 30s/30s/15s and moves polling from raf to fixed 200/100/200ms intervals. A probe
spec confirmed the old form never times out while the new form rejects on schedule. That is almost
certainly what you intended when you wrote { timeout, polling } — but it can turn a slow-but-successful
screenshot capture into a hard failure, so it is your call, not an automated one. Blast radius is small:
docs:screenshots/docs:animations/docs:images are referenced by no workflow in .github/workflows/,
so this runs only on manual local invocations.

The fix, if you want it

In /Users/tsenoner/Documents/projects/protspace-suite/.protspace-wt-pr402/scripts/docs-
screenshots/helpers.ts, move the options object into the third argument position by inserting
undefined, before it at the three call sites named in the finding:

  1. line 50-60 (waitForDataLoad readiness predicate): change the trailing },\n { timeout, polling: 200 },\n ); to },\n undefined,\n { timeout, polling: 200 },\n );
  2. line 64-67: await page.waitForFunction(() => !document.getElementById('progressive-loading'), {\n timeout,\n polling: 100,\n }); becomes await page.waitForFunction(\n () => !document.getElementById('progressive-loading'),\n undefined,\n { timeout, polling: 100 },\n );
  3. line 78-86 (waitForLegend predicate): change },\n { timeout, polling: 200 },\n ); to },\n undefined,\n { timeout, polling: 200 },\n );
    This is exactly the form already used at capture-animations.spec.ts:564-575 and at helpers.ts:100
    (waitForWebGLContext), so it type-checks. Note for a follow-up (do not autofix in this pass):
    helpers.ts:141, :156 and :175 have the identical misplacement, and fixing :156/:175 is what restores
    their intended try/catch fallbacks; the same pattern also appears in capture-static.spec.ts.

2. Forcing plot.selectedProjectionIndex directly bypasses the control bar, so the recorded duplicate-badges.gif shows a projection dropdown that contradicts the plotted data.

scripts/docs-screenshots/capture-animations.spec.ts:554 · medium · correctness

Concrete failure: apps/web/public/data.parquetbundle (the dataset /explore loads) has
projections ['ProtT5 — UMAP 2', 'ProtT5 — PCA 2', 'ESM2-650M — UMAP 2', 'ESM2-650M — PCA 2'], and
data-renderer.ts:94 starts the plot at index 0. The new code computes pcaIndex = 1 and assigns
plot.selectedProjectionIndex = 1 on the scatterplot element. The control bar only mirrors that
value inside _syncWithScatterplot(), which control-bar.ts:1233-1237 schedules exactly once via
setTimeout(..., 50) at auto-sync setup; a selectedProjectionIndex change does not dispatch
data-change (scatter-plot.ts:764-782 only emits on data / filteredProteinIds /
filtersActive), so nothing re-syncs it. Result: the plot renders ProtT5 — PCA 2 while the
control-bar trigger (control-bar.ts:564, which renders this.selectedProjection) still reads
ProtT5 — UMAP 2. The animation project records the full 1536x864 viewport
(playwright.config.ts), so the control bar is in frame and the published
docs/explore/images/duplicate-badges.gif would ship that contradiction. The switch also appears
unnecessary: duplicate-badges.gif was successfully generated on the default projection in 68c3bd9
(2026-05-11), before the _duplicateStackByKey rename broke the probe, and duplicate grouping is
cross-projection (0c6e079). The GIF is not regenerated in this PR, so this path is unverified.

Suggested fix

In /Users/tsenoner/Documents/projects/protspace-suite/.protspace-wt-pr402/scripts/docs-
screenshots/capture-animations.spec.ts, replace the body of the first page.evaluate in the
duplicate-badges.gif test (lines 542-558) with a control-bar-driven selection so the label and the
plotted data stay in sync:

await page.evaluate(async () => {
  const plot = document.querySelector('#myPlot') as DuplicatePlotProbe | null;
  const controlBar = document.querySelector('#myControlBar') as
    | (HTMLElement & { applyProjectionSelection(projection: string): void })
    | 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;
});

applyProjectionSelection (control-bar.ts:139) sets selectedProjection, mirrors the index onto
the scatterplot while autoSync is on (data-renderer.ts:209 turns it on after load), and dispatches
projection-change so the app's view controller/URL stay consistent.
DuplicatePlotProbe.selectedProjectionIndex can then be dropped from the type at line 17 if unused.
Do not autofix: the capture must be re-run and duplicate-badges.gif regenerated to confirm PCA still
yields a multi-point stack before this is trusted.

2 further finding(s) were raised and refuted during verification.

@FlorinSenoner

Copy link
Copy Markdown
Collaborator Author

Addressed the adversarial review in comment 5203113387 on 52b8afc3f40313e6dd6377a4a510af5ef292b9ab.

  1. Animation-script scope: kept. pnpm docs:images is the documented canonical image pipeline and includes the animation project. A live probe of the current bundled dataset found 0 multi-point stacks on the default ProtT5 UMAP projection and 265 on ProtT5 PCA, so the PCA switch is required for this capture rather than incidental scope. The focused duplicate-badge capture now passes and the GIF was regenerated.
  2. TED availability for non-UniProt IDs: no code change. openspec/changes/add-ted-link/design.md explicitly makes availability checks a non-goal and records deterministic-link parity with UniProt/InterPro; changing that policy would be a separate product change.
  3. Header wrapping: no code change. .header-info is the existing wrapping container named by the design, while .header-links contains only fixed link labels; a longer accession is a separate .protein-id flex item and does not widen the link row. Styling changes are also an explicit non-goal for this change.
  4. Control-bar category docs: no change. The stale annotation-category list predates this PR and concerns annotation grouping, not the resource-link capability in [FEATURE] Add TED link #344.
  5. OpenSpec archival: no change. The change remains active while the PR is open; archive/merge into openspec/specs/ belongs to the post-merge archive pass.
  6. waitForFunction options: fixed at the three reviewed call sites by passing undefined as the page-function argument. Added real Playwright regressions for readiness, loading-overlay, and legend timeouts. RED: each 100 ms timeout ran until the 2,000 ms test timeout; GREEN: all 3 pass.
  7. Projection/control-bar mismatch: fixed by selecting PCA through controlBar.applyProjectionSelection() and asserting the plotted projection equals the visible control-bar selection before recording. RED: the assertion timed out with direct plot mutation; GREEN: the complete 21-action duplicate-badge capture passed, and the regenerated GIF visibly labels ProtT5 — PCA 2.

Verification: focused helper tests 3/3; focused duplicate-badge Playwright capture 1/1; GIF conversion 1/1; openspec validate add-ted-link --strict; staged pnpm precommit (ESLint/Prettier, typecheck, Knip, annotation-doc check, docs build). Exact-head checks: Code Quality passed, Build Documentation passed, Bundle format contract passed, E2E path-filter skipped.

tsenoner and others added 17 commits September 15, 2026 18:38
Every conflict was in the docs-screenshot pipeline, which main reworked
underneath this branch:

- playwright.config.ts / helpers.ts: took main's side. Main generalized
  `testMatch` to suffix self-registration, and turned `waitForDataLoad`'s
  second argument into an options object with `expectedProteinCount` — a
  strict superset of this branch's version.
- capture-animations.spec.ts: kept main's import shape, since `INITIAL_PAUSE`
  and `saveTestVideo` moved into helpers.
- structure-viewer.png: kept this branch's capture, the only one that shows
  the TED link.

That `waitForDataLoad(page, options)` signature change also broke a call
site on this branch that git did not report as a conflict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
Three review findings in the capture pipeline, all one mechanism:

`selectProjection()` now lives in helpers.ts beside `selectAnnotation()`,
which already owned this exact shape — drive the control bar, then poll the
plot on one property. It also gates on the projection that was *requested*.
The inline `expect.poll` it replaces compared the plot's selected projection
to the control bar's, but `applyProjectionSelection()` sets both
synchronously, so that predicate held whether or not the switch propagated,
and PCA is `projections[0]` — the default — so it held before the call too.
With a real gate, the two `await plot.updateComplete` calls are redundant.

`capture-helpers.spec.ts` is deleted. It matched neither project in
playwright.config.ts (`/static\.spec\.ts$/`, `/animations\.spec\.ts$/`), so
`playwright test --list` never collected it — the failure mode this config's
suffix self-registration exists to prevent. Its assertions were also thin:
all three gates inside `waitForDataLoad` reject with the same `Timeout 100ms
exceeded`, so the test that claimed to pin the readiness contract passed
whether or not its setup satisfied that gate. The readiness repair it was
written to guard came from main in 5fa2ac3, not from this branch.

Last: a comment still named `_expandedDuplicateStackKey` and
`_renderDuplicateBadgesCanvas`, neither of which resolves since b1695b3 —
the same rot the rest of this file's diff is repairing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
`_loadStructure`'s error path re-split the accession by hand
(`proteinId?.split('.')[0]`) while the file now imports the module that
owns that rule; call `getBaseAccession()` instead.

The TED component test found its anchor by label and asserted three
attributes on it, so it passed if TED rendered anywhere in the header — and
kept passing if UniProt and InterPro vanished, which is exactly what the
test name and the spec scenario ("beside the UniProt and InterPro links")
claim to cover. Assert the rendered row in order instead.

Also drop the `vi.fn()` wrapper around the rAF stub, which nothing asserts
on, and say why the stub is there: the component defers `_loadStructure()`
(a network fetch) into a rAF callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
Run as the last commit before the merge, per AGENTS.md.

Reconciled the artifacts against the final diff first, since archiving
freezes them:

- proposal.md claimed a repair of "stale image-pipeline readiness checks".
  Main landed that in 5fa2ac3, and the merge resolved helpers.ts to main's
  side, so this change contains none of it. What it does repair is the
  duplicate-badge capture spec, which bound to scatter-plot internals renamed
  in b1695b3 and could not run at all — and which regenerates
  duplicate-badges.gif as a side effect. That is now what proposal.md and
  tasks.md say.
- design.md described the component test as asserting the TED anchor alone,
  and described `.header-links` as a wrapping container. Both are now
  accurate: the test pins the whole row in order, and it is the parent
  `.header-info` that wraps.

The archiver leaves a new capability's Purpose as a TBD placeholder that
still validates clean, so protein-resource-links got a real one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
The links sat inline beside the accession in the same muted 0.75rem
treatment, so they read as metadata rather than as things you can press,
and nothing marked them as leaving the app. AlphaFold was worse off: it
was not in the row at all, it was the header title, so the destination the
viewer is named after was a link almost nobody would find.

The row now sits under the title, AlphaFold is a peer of its three
siblings, every entry carries a ↗, and the title is plain text.

Both the row and its separators come from one place. RESOURCE_LINKS lives
beside the URL builders it references, and the template maps over it, so a
destination is one entry rather than an eight-line anchor plus a manually
placed &middot;. add-ted-link deferred this as scope creep, correctly, for
a change that added one link to a row that was not otherwise moving.

Two details the browser caught and the tests could not:

- The separator trails its own link (`:not(:last-child)::after`) rather
  than leading the next one. A pseudo-element belongs to its element's
  inline box, so a leading dot wraps with the link it precedes — at a
  220px sidebar the second line began `· InterPro`.
- Only `.header-link-label` is underlined. A text-decoration on the anchor
  propagates into descendants and cannot be cancelled there, so underlining
  the anchor drew the line under the arrow and the separator too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
Folds the row into protein-resource-links: AlphaFold is now one of the
destinations rather than the header title, the row is defined once, and
every entry is marked as leaving the application.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
page.waitForFunction's signature is (pageFunction, arg, options). Eleven
call sites passed the options object as the second argument, so it landed
in the arg slot and was serialized into the page as the predicate's
parameter. Playwright then used its own defaults: the 30s default timeout
instead of the one written here, and raf polling instead of the stated
100-1000ms interval. Both silently.

Verified against playwright-core@1.61.0, which has no overload that reads
a second positional as options:

  async waitForFunction(pageFunction, arg, options = {}) {
    ...
    arg: serializeArgument(arg),
    timeout: this._timeout(options)
  }

This makes the written timeouts real, which tightens several waits (the
legend gate from 30s to 15s, the dropdown gates from 30s to 5s), so the
full screenshots project was run against a live dev server to confirm it
still passes: 15/15 captures, 24.8s.

capture-animations.spec.ts has a twelfth instance, left alone here because
feat/344-ted-link already fixes that same line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
docs/explore/scatterplot.md references four GIFs totalling ~22 MB and the
config set no markdown options, so VitePress emitted plain <img> tags and
every one of them was fetched on page load — including the three below the
fold. Verified in the build output: scatterplot.html now carries four
loading="lazy" attributes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
scripts/ was in no tsconfig (the root has files: [] and references only
apps/web and packages/*) and matched no ESLint files pattern, so eslint
reported "File ignored because no matching configuration was supplied".
Nothing checked this directory at all, which is why its specs could bind
to component internals renamed months earlier and rot in silence.

Adds scripts/tsconfig.json, a type-check:scripts step inside pnpm quality,
and a lint:scripts step inside pnpm lint — both of which CI already runs
via quality:ci.

The two errors it immediately surfaced:

- webgl-fixture.ts passed headless: 'new', which is Puppeteer's old
  headless-mode string. Playwright types it as boolean; 'new' is truthy so
  CI happened to behave correctly.
- logAction() took a `page` it never read, at twenty call sites.

Plus two lint errors: inline import() type annotations in capture-static,
and an unused catch binding in convert-to-gif.

no-explicit-any is a warning rather than an error here, and no-console is
off: these scripts drive a browser through component internals that carry
no public types, and they report progress on stdout. 23 warnings remain,
all no-explicit-any.

Verified the gate rejects what it used to miss (a bad annotation and an
unused parameter both fail now), and that the captures still run: 15/15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
Config, first — the gate I added was mostly a copy of config that already
existed:

- scripts/tsconfig.json restated 11 of 13 options the root tsconfig already
  sets. It now extends ../tsconfig.json, like packages/core and
  packages/utils do, and adds only `types: ["node"]`. 17 lines to 5, and
  re-verified it still rejects a bad annotation and an unused parameter.
- The eslint block restated the parser, the plugins and two rules from the
  block above it. `scripts/**/*.ts` now joins that block's `files`, and the
  override carries only what differs. Byte-identical output before and
  after: 23 problems, 0 errors. It also silently made no-unused-vars
  stricter in tooling than in shipped code; tsc owns that now.

Then the footgun itself. Eleven hand-inserted `undefined`s were a snapshot
of one person's attention, and the snapshot was already incomplete — a
twelfth call site survived in a file that commit edited. A
no-restricted-syntax selector makes it impossible to reintroduce:

  CallExpression[callee.property.name='waitForFunction'][arguments.length=2]
    > ObjectExpression.arguments:has(Property[key.name=/^(timeout|polling)$/])

One error, zero false positives across all 68 waitForFunction calls in the
repo. That error was the twelfth site, now fixed.

The rule belongs to browser automation, not to scripts/, and apps/web/tests
— the Playwright suite that actually runs in CI — matched no eslint files
glob at all (apps/web lints `src` only). It is now covered, which turned up
two dead imports.

knip was already in the quality gate and already blind: knip.jsonc globbed
`scripts/**/*.ts` as `entry`, making every file its own entry root, so
nothing there could ever be reported. Narrowed to the real roots, it found
one unused file and 13 unused exports. webgl-fixture.ts is deleted — zero
importers, its `test` export a documented no-op, and the `headless` line
this branch fixed already exists verbatim at playwright.config.ts:59 and
:78. Eleven dead helpers are gone; four exports that are used inside their
own module are now private.

Last, logAction is sync — it pushes to an array and logs, and 30 call sites
were awaiting it — and `lint:fix` now covers what `lint` checks, which it
did not.

Captures re-run against a live dev server after each step: 15/15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
Lit's `css` is a tagged template, so an illegal escape in it does not
raise: per the ES2018 template-literal revision the cooked string becomes
`undefined` and the whole stylesheet silently collapses.

`content: '\00b7'` did that to the structure viewer earlier on this branch.
The shadow root went from 37 CSS rules to 0 — the component rendered
completely unstyled — and every gate stayed green: tsc, knip, the full
component suite, the docs build. jsdom never evaluates CSS, so nothing
could see it. It was caught by opening the page in a browser.

One assertion on cssText covers the whole class, for every style module.
Verified by reintroducing the exact escape: the guard fails with
"expected 'undefined' to be 'string'" while all 21 existing
structure-viewer tests still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
Enabling markdown.image.lazyLoading stamped loading="lazy" on every
markdown image with no per-image opt-out, including the hero image at the
top of four Explore pages — which is the LCP element on each. Lazy-loading
the LCP image hides it from the preload scanner, so discovery is deferred
until after layout and runs at low priority.

Those four are now raw <img> with loading="eager" and fetchpriority="high",
plus explicit width/height, which also removes the layout shift the
lazy ones would otherwise cause (nothing reserved their space).

Verified in the built output — scatterplot 4 lazy / 0 eager, legend 2/1,
index 3/1, control-bar 5/1 — so everything below the fold still defers,
including the ~16 MB of GIFs on scatterplot.md that motivated the flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
Searching `ted` returned the four Biocentral predictions above the one
annotation the reader wanted, because every one of them is keyed
`predicted_*` and the filter matched any substring of the raw column name:

  MATCH predicted_subcellular_location  key:Y  label("Subcellular location"):n
  MATCH predicted_membrane              key:Y  label("Membrane"):n
  MATCH predicted_signal_peptide        key:Y  label("Signal peptide"):n
  MATCH predicted_transmembrane         key:Y  label("Transmembrane"):n
  MATCH ted_domains                     key:Y  label("TED domains"):Y

Every Biocentral hit was on the key and none on the label, so the matched
text was never on screen.

A query now matches a substring of the displayed label, or the start of a
word in the column name. The asymmetry is the point: a column name is a
machine identifier built by joining words, so a mid-word hit in it is
almost always an accident, while a label is prose the reader is looking at,
so `cellular` should still find Subcellular location.

The rule lives in one helper beside annotationLabel/annotationSource, and
both pickers call it. They disagreed before — query-condition-row matched
column names only and never labels at all, so searching for a label you
could see found nothing there.

Verified in the running app against the reported column set: ted →
ted_domains alone; predicted → all four; membrane → membrane and
transmembrane; cellular and loc → subcellular location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
Run as the last commit before the merge, per AGENTS.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
Review caught two things wrong with the first cut.

The rule split the column but not the query, then asked whether any word
started with the needle. No word can start with a needle that still holds a
separator, so every multi-word column stopped matching its own name — 16 of
38 in the registry. Typing the name you know went fine to `predicted`, went
empty at `predicted_`, and never recovered. That directly contradicted this
change's own goal that searching by raw column name keeps working.

Both sides are normalised on `_`/`-` now, and the query has to begin where a
word does. `ted` still misses every `predicted_*`; `predicted_membrane`,
`ted_domains` and `go_bp` find themselves again. Verified across the whole
registry: no column fails to match its own name.

The other miss was worse for being self-inflicted: this change claimed "one
rule, one implementation" while annotation-select.test.ts kept its own
exported copy on the OLD semantics, with eight tests asserting it. The suite
was green for both rules at once, and two of those tests encoded results the
shipped code no longer produced. The whole pipeline — group, filter, drop
empty sections — now lives in annotation-categories.ts, which already said
"Shared by annotation-select and query-condition-row" in its own doc comment.
Both pickers call it, the mirror is deleted, and its tests exercise the real
function.

That also restores the empty-query short circuit query-condition-row lost,
and removes the double normalisation at both call sites.

Recorded in design.md, because it is not fixed here: a match can still land
on text a picker does not draw. The dropdown renders only the label, so
`predicted` returns four rows reading "Membrane", "Signal peptide", … The
query builder renders only the column name, so `swiss` returns `reviewed`.
This removes the worst case — a hit on neither, which is what `ted` was —
but making every match accountable needs the pickers to show what matched.
query-value-picker already highlights matched substrings; that is the
pattern to copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J26u3dxSKYGb8fRxchpJVa
@tsenoner tsenoner changed the title feat(structure-viewer): add ted resource link feat(structure-viewer): add TED, restyle the resource row, and gate the docs-screenshot scripts Sep 15, 2026
tsenoner and others added 7 commits September 16, 2026 21:56
`getBaseAccession` lived in core's header-links while `StructureService`
kept a private copy of the same `split('.')[0]`. The viewer loads a
structure through one copy and links out through the other, so a change
to either (say, to UniProt isoforms) would load one entry and link to
another. It now lives in `@protspace/utils` and both call it.

The four link builders also shared the strip-and-encode step by copying
it; they now call one private helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9am9XhSUJdEjRLJVc7xB5
The `·` between resource links was generated inside each `<a>`, so it was
part of every link's accessible name ("AlphaFold ·"), its click target and
its focus ring. Each link now sits in its own list item under a labelled
`role="list"`, and the dot hangs off the item with empty alt text.

Also narrows `proteinId` once in `render()` instead of casting inside the
link map, and has the component test mount through one helper, compare the
row against `RESOURCE_LINKS` rather than a copy of its URLs, and pin that
no link contains the separator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9am9XhSUJdEjRLJVc7xB5
The stylesheet integrity test listed its modules by hand, and the list had
already missed three of the twelve `*.styles.ts` files. It now finds them
by glob — `*.styles.ts`, the files in a component's `styles/` folder, and
the shared sheets — so a new one is covered the moment it exists: 31
sheets instead of 9.

Three components still declared `static styles = css` inline, out of the
test's reach: `info-popover`, `category-score-strip`, and the query
builder's host rule. Each moves into a styles module.

The test now strips CSS comments before looking for "undefined": moving
the popover's sheet exposed a comment that says "a second, undefined
token", while a collapsed template chunk never lands inside a comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9am9XhSUJdEjRLJVc7xB5
- `apps/web/tests` is linted by the app's own `lint` script, so turbo runs
  and caches it with the rest of the app; `lint:tooling` keeps the folders
  that belong to no workspace package: `scripts/`, and now also `perf/`,
  `tests/` and `docs/`, which the config never matched before. All of them
  lint clean. VitePress's cache and Python venvs are ignored.
- `pnpm type-check` includes the scripts check, as `lint` includes
  `lint:tooling`.
- A `css` literal in a `packages/core` component file is an error: a sheet
  there is out of the integrity test's reach, and a bad escape in it
  unstyles the component without failing anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9am9XhSUJdEjRLJVc7xB5
The control bar's dropdown names annotations by label ("CATH-Gene3D",
"EC number") while the query builder's picker and condition button
printed the column name (`cath`, `ec`). Both now draw a name through one
`renderAnnotationName` helper: the label, plus the ⚡ badge for a
predicted annotation, which also tells apart two annotations sharing a
label. The dropdown's own trigger gets the badge too. Conditions still
store the column name.

Search now matches only that displayed label, as a case-insensitive
substring. The column-name word match is gone: with labels in both
pickers it offered rows the reader could not account for — `predicted`
listed "Membrane", "Signal peptide", "Subcellular location" and
"Transmembrane". An unregistered column is labelled by its prettified
name and stays findable through it.

`flattenGroupedAnnotations` moves beside the shared filter, replacing the
component's private copy and the test file's hand-kept one.

Fixes #293

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9am9XhSUJdEjRLJVc7xB5
The docs captures and an e2e helper located points by re-deriving the
scatter plot's projection from `_plotData`, `_scales` and `_transform`,
and probed duplicate stacks through `_dupOverlay`. Five copies of the
projection math drifted: the select-single capture still read
`_plotData[idx].x` from before PlotData became columnar, got `undefined`,
and silently clicked the plot centre every time.

The plot now answers those questions itself:

- `dataToClient(x, y)` maps through the interaction SVG's screen matrix,
  the exact inverse of the `d3.pointer` hit-test, so hovering the result
  lands on the point (the host rect is 1px off, across its border).
- `getProteinClientPosition(id)`, `getDuplicateStacks()` and
  `getExpandedDuplicateStackKey()` cover the rest.

The captures, `waitForDataLoad` and the EAT e2e helper use them, typed
against the public surface, so an internal refactor now fails the type
check instead of blanking a GIF.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9am9XhSUJdEjRLJVc7xB5
- `IMAGES_DIR` and `TEMP_VIDEOS_DIR` were defined in both the capture
  helpers and the GIF converter. The recorder writes where the converter
  reads, so they now come from one `paths.ts`; had they drifted, the
  converter would have found no videos.
- Two hand-written double-rAF waits call the existing `awaitTwoFrames`.
- `ActionLog.timestamp` was written and never read once `getActionLogs`
  went; it is dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9am9XhSUJdEjRLJVc7xB5
@tsenoner
tsenoner merged commit fa71ec7 into main Sep 16, 2026
4 checks passed
@tsenoner
tsenoner deleted the feat/344-ted-link branch September 16, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Add TED link [BUG] Annotation list inconsistency in control bar and in filter logic

2 participants