feat(structure-viewer): add TED, restyle the resource row, and gate the docs-screenshot scripts - #402
Conversation
Automated reviewDoes it solve #344? Yes — the PR adds Found 1 issue:
protspace/docs/explore/structures.md Lines 11 to 13 in ef9b3dc Deliberately not flagged: the TED anchor rendering unconditionally (it mirrors the pre-existing AlphaFold/UniProt/InterPro anchors, and 🤖 Generated with Claude Code |
|
Disposition: actionable and needs to be addressed. Verified against current head 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. |
|
Implemented the verified documentation feedback in |
- 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
Adversarial reviewReviewed 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 (
|
|
Addressed the adversarial review in comment 5203113387 on
Verification: focused helper tests |
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 ·. 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
`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
bc7f7e2 to
8811d42
Compare
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
buildTedUrljoins the existing builders and reusesgetBaseAccession, so a versioned ID targets the canonical accession.Adding a fourth link made three existing problems visible, so the row was rebuilt:
0.75remin--text-secondary— the same treatment as the accession beside them. They read as metadata, and nothing marked them as leaving the app..header-linkswas a non-wrapping flex row, so each new resource made the group monolithically wider.The row is defined once, as
RESOURCE_LINKSbeside 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:
· 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 labelledrole="list"now; verified in Chromium's accessibility tree..header-link-labelis underlined.text-decorationpropagates 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 indesign.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.
b1695b30. Repaired, andselectProjectionmoved intohelpers.tsbesideselectAnnotation, which already owned that shape. The inlineexpect.pollit replaced compared the plot's projection to the control bar's — butapplyProjectionSelectionsets both synchronously, and PCA isprojections[0], so that predicate held whether or not the switch propagated.capture-helpers.spec.tsmatched no Playwright project, soplaywright test --listnever collected it. Its assertions were thin anyway: all three gates inwaitForDataLoadreject with the sameTimeout 100ms exceeded, so the test claiming to pin the readiness contract passed whether or not its setup satisfied that gate. Deleted.waitForFunctioncalls passed options into theargslot. The signature is(pageFunction, arg, options), so every writtentimeoutandpollingvalue was silently ignored in favour of Playwright's defaults. Verified againstplaywright-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 viapnpm quality/pnpm lint. That immediately surfacedheadless: 'new'(Puppeteer's string, not Playwright's boolean) and apageargumentlogActionnever 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-syntaxrule instead: 1 error, 0 false positives across all 68waitForFunctioncalls in the repo. The rule belongs to browser automation, not toscripts/, soapps/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.jsoncglobbedscripts/**/*.tsasentry, 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'scsstagged template made the cooked stringundefinedand 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
cssTextnow 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 declaredstatic styles = cssinline moved into styles modules; and ESLint rejects acssliteral anywhere else inpackages/core, so no sheet sits out of the guard's reach.Also fixed:
markdown.image.lazyLoadinghad 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
tedreturned the four Biocentral predictions, because they're keyedpredic**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").renderAnnotationNameas 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.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/_transformin five places. One had rotted: select-single read_plotData[idx].xfrom before PlotData went columnar, gotundefined, and clicked the plot centre every run. The plot now exposesdataToClient(through the interaction SVG's screen matrix — the exact inverse of thed3.pointerhit-test; the host rect is 1px off across its border),getProteinClientPosition,getDuplicateStacksandgetExpandedDuplicateStackKey, and every probe uses them.Also: one accession rule (
getBaseAccession, now in@protspace/utils) for both the structure fetch and the links;apps/web/testslinted by the app's ownlint;perf/,tests/anddocs/linted for the first time (clean).Tests
pnpm test:ci— utils 384, core 1814, app 167pnpm format:check,pnpm lint,pnpm quality,docs:annotations:check,docs:buildnumeric-binning+eat-visualization— 43/43 against a live dev serverzoom,select-single,select-box,duplicate-badges— 4/4, select-single now clicking real pointsplaywright test --project=screenshots— 15/15 against a live dev serverDo 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