Skip to content

Add DeltaSignal pathway perturbation UI - #155

Draft
Chryseisliu wants to merge 4 commits into
mainfrom
feat/deltasignal-ui
Draft

Add DeltaSignal pathway perturbation UI#155
Chryseisliu wants to merge 4 commits into
mainfrom
feat/deltasignal-ui

Conversation

@Chryseisliu

Copy link
Copy Markdown
Collaborator

What changed

  • adds a Perturb workflow to the Pathway Browser
  • matches a selected Reactome entity to all corresponding DeltaSignal logic-network nodes
  • supports knockout, baseline, and activation inputs plus multiple perturbations
  • calls the DeltaSignal /api/pathways, /api/parse, and /api/solve endpoints
  • overlays predicted changes on the existing Cytoscape diagram with a diverging palette
  • reports convergence, predicted activity, change from baseline, and influence
  • aggregates the result table to one Reactome entity per row while preserving member-level values as diagram gradients
  • adds local proxy configuration and developer documentation

Why

This is a bounded, demonstrable vertical slice for pathway perturbation prediction in the Reactome UI. It keeps the DeltaSignal engine in its own repository and uses the documented HTTP API as the integration boundary.

The first supported demo is Cell Cycle Checkpoints: selecting activated ATM (R-HSA-5693527) maps to 29 logic-network nodes and can be run as a knockout to visualize the predicted downstream response.

User impact

Users can go from a molecule selected in a Reactome pathway to a solver-backed predicted pathway response without leaving the Pathway Browser. Loading, unsupported-network, convergence, and API-error states are shown explicitly.

Validation

  • npm test: 69 tests passed
  • npm run build: production build passed
  • focused ESLint and formatting checks passed
  • live local test against the real DeltaSignal server:
    • Cell Cycle Checkpoints network: 17,858 nodes
    • activated ATM matched to 29 logic nodes
    • ATM knockout solve converged
    • results table and pathway overlay rendered successfully

Deliberate follow-up scope

This draft does not yet include multi-pathway joining, ReacFoam-wide overlays, temporal ordering, backend deployment configuration, or the benchmark report. The largest local network currently has noticeable parse/solve request latency, so backend caching/deployment should be reviewed before production rollout.

Keep LNG UUIDs separate in an interactive top-response graph, leave the Reactome diagram accessible beside the controls, and document the complete demo workflow.
@Chryseisliu

Copy link
Copy Markdown
Collaborator Author

I added the follow-up visualization from our discussion. Results now have a raw LNG logic-nodes-and-edges view alongside the Reactome overlay. It keeps UUIDs separate, shows the 40 largest changes plus the perturbed inputs, retains edges only when both endpoints are visible, and exposes mapping and solver details when a node is selected. I also changed the panel to a side drawer so the pathway stays visible on desktop, with a full-screen layout on narrow displays.

I documented the complete demo and local setup in documentation/PathwayBrowser/deltasignal.md. The full test suite (70 tests), lint, dead-code guard, development build, desktop run, and 800 px responsive check pass. I left ReacFoam out for now because it still needs a defined cross-pathway aggregation rule; this view exposes the graph DeltaSignal already solves without inventing a new pathway score.

Commit: d20ccc2.

…ranch) (#198)

* update env variables.

* For Literature display, if authorName, use this, otherwise use author.

* wip

* update

* Updates to enviornement and manual editing of the publication component.

* feat(render): produce GIF and PowerPoint from the site's own renderer

The two formats the Java exporter still owned, and the two that most obviously
looked like the old site. Both now come from the render page, so a downloaded
figure is the diagram the site draws.

GIF is the expression animation: one frame per sample, 1s each, looping, and a
single frame when there is no analysis. Encoding happens inside the browser --
a frame is tens of megabytes of pixel data and there is one per sample, so
shipping them out to be assembled costs more than the finished file. The palette
is built from every frame rather than the first, because one frame's palette
shifts colours on samples whose values land elsewhere on the scale; that is why
frames are drawn twice and never accumulated. Verified per frame rather than by
file size: PMAIP1 goes dark purple at 0.2 to bright green at 5.2 across the four
samples of a posted dataset.

Capped at 2000px on the longest side. A diagram's coordinate space is around
6000px wide and a GIF pays for that once per frame -- uncapped, four samples came
to 3.1MB. Now 735KB.

PPTX carries the SVG with a PNG fallback. PowerPoint draws the SVG and its
Convert to Shape turns the diagram into editable shapes. The alternative is
emitting DrawingML per glyph, as the Java exporter does via Aspose: editable on
open, at the cost of a second renderer to keep in step with the first and a
commercial licence. One click is worth that trade.

Two bugs found in the illustration code this reuses:

- EHLD raster download scaled twice, once on the context and again through
  scaled destination dimensions, so a downloaded PNG or JPEG showed the
  top-left ninth of the illustration blown up to fill the file.
- showAnalysisInfo assumed the analysis-info group contains a text element.
  Every other lookup there is guarded; this one threw part-way through and left
  the region half-decorated.

Rasterising an illustration now lives in EhldService, shared by the download and
the render page, since both need the styles inlined first: an EHLD's styling
comes from the page's stylesheets and does not travel with the markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* update package.json with curator website script.

* feat(download): take GIF and PowerPoint from the render service

Clicking GIF or PPTX in the download panel now gets a figure drawn by the site's
own renderer instead of by the Java exporter's reimplementation of it. That was
the last place a curator could download something that looked like the old site.

The app builds its URLs from RENDER_SERVICE, which is the current origin plus
/RenderService, exactly as CONTENT_SERVICE works -- so it follows whatever host
the bundle is served from. proxy.conf.js maps that path to the service on
loopback, and serve-prod.js reads the same table, so beta and the dev server
both reach it with no Apache change.

The analysis token travels with the request, which is the point of a GIF: one
frame per sample rather than a still. So does the sub-pathway preference, so the
checkbox means the same thing for a server-rendered file as for one the browser
produces.

Encoding the token needed care. The analysis service returns it already
percent-encoded, and setSearchParam encoded it again -- "...%253D%253D", a
different token to everything downstream and a separate cache entry. It happened
to still render, because the render page passed the same mangled value back to
the same service.

Illustrations keep going to the content service for GIF and PPTX: it serves the
same illustration file either way, so there is nothing to gain.

Clamped every number that arrives in a query string, now that the path is
publicly reachable. scale=50 asked for a 320-megapixel canvas and got it, which
is worse than an error: one query string for a gigabyte of someone else's
memory. Scale is capped at the default, and nothing has needed more.

deploy/render-service/ carries the systemd unit and says plainly what is still
missing before this fronts reactome.org: rate limiting at Apache, Apache serving
the cache directly on a hit, and a cache key that changes per release.

Docs use --token "$ANALYSIS_TOKEN" rather than an angle-bracket placeholder.
GitGuardian read the placeholder as a CLI-option secret and raised an incident;
there was no credential in it, and a variable reference reads as one to a
scanner as well as to a person.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(render): draw the GIF at a size its labels can be read at

The animation was sharp and the words in it were gone. A pathway's coordinate
space is around 6000px wide and its font sizes are chosen for 1:1, so fitting it
into 2000px scaled 8pt type to under 3pt and softened every arrow. Zooming in
then magnified a downscale, which is not the same as detail.

It renders at the diagram's own size now, and that is affordable because frames
are differenced. 255 palette colours carry the picture and one index is kept back
to mean "unchanged": every pixel equal to the previous frame becomes that index,
written with disposal 1 so the previous frame shows through. Between two samples
of an expression analysis only the node fills differ -- compartments, edges and
every label are identical -- and a long run of one repeated index is what LZW
compresses best.

R-HSA-109606 over four samples: 3.1 MB undifferenced at full size, 966 KB
differenced, against 735 KB for the unreadable 2000px version. Three times the
resolution for 30% more bytes, and each extra sample now costs what it changes
rather than what it contains. --max-size and ?maxSize= still cap it for anyone
who wants a smaller file.

Deployment is a container rather than a systemd unit, so a wedged browser or an
out-of-memory kill costs one request instead of the feature. node:22 plus
Chromium rather than a Playwright image: those carry three browsers and land
around 3 GB, this needs one, and node:22 is already here as the app image's base.
tools/render/ has its own package.json for the same reason -- four packages
instead of the site's whole tree -- and render-deps.spec.ts fails if its pins
drift from the root's, which matters most for Playwright, whose browser download
is version-locked to the library.

The container publishes no port. It is reachable from the app container and
nowhere else, which keeps the property that matters: a render can only be
commissioned through whatever fronts the site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(render): make a renderer change reach a browser that already has a figure

The full-size GIF shipped and curators kept getting the 2000px one. Nothing was
wrong with the renderer: the old file was in Cloudflare and in their browsers,
and neither was ever going to ask again. Figures are served `public`, so
Cloudflare stores them and keeps serving what it stored with the max-age it
stored it under -- a day. Reloading the page does not touch it either, because a
download link's URL is not a page subresource and is never revalidated. And
Cloudflare's own Browser Cache TTL overrides what the service sends: 300s went
out as 4h.

So the address has to change, which is what RENDER_VERSION in the app does. The
service ignores the parameter, so both versions of a figure share one entry in
its disk cache, and everything downstream sees a new resource. Bump it with
RENDER_CACHE_KEY whenever the renderer's output changes; the table in
deploy/render-service/README.md says which invalidates what.

Also an ETag, from the same key -- everything that determines the bytes is in it
-- answered before any render happens, so a repeat download is a round trip
rather than a browser holding a stale figure or the box drawing one twice.

Verified by clicking the button rather than by reading the code: the download
comes out 5775x2366, and cf-cache-status is a MISS on the versioned URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ci): let knip see the render tooling, and make the export theme explicit

CI has been failing since the GIF work landed, and every failure -- including the
two dependabot pull requests -- was the same two items: gifenc and fflate looked
like unused dependencies. They are imported from tools/render, which knip was not
configured to look at. It scans tools/**/*.mjs now, with the CLI, the service and
the SVG harness as entry points, so the count is back to the baseline of 147 with
more of the repo covered than before rather than less. MAX_FRAMES and renderUrl
stopped being exported; nothing outside their own modules used them, and
exporting a constant only makes it look like API.

Also the theme, which was implicit and machine-dependent. DarkService defaults
from localStorage and then from the browser's prefers-color-scheme, and the
render page inherited whatever that decided -- so the same request could produce
a light figure here and a dark one elsewhere, with one cache entry for both.
?dark=true now asks for dark and nothing else gives it.

The dark palette is plumbed through and cached separately, but the download panel
does not offer it, deliberately: it is a screen theme. Standalone it reads as
muddy -- pale mauve compartments, and sub-pathway labels whose dark halos exist
to sit on a dark canvas.

Frame capture takes the diagram's own background colour rather than a hardcoded
white, which is what made the dark case wrong in a way that would have looked
like a renderer bug: a dark palette on a white ground is neither theme.

And a regression caught on the way: setting the GIF's MAX_SIZE to 0 for "the
diagram's own size" left PowerPoint's fallback raster computing scale from
MAX_SIZE / longest, so it asked for a PNG at scale 0. The zip would still have
cleared the size floor, because the SVG in it is the real picture -- a blank
fallback nobody would find until the one viewer that needs it opened the file.
It has its own constant now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(render): read the container's dependencies from the root package.json

A second manifest with its own pins was the obvious way to keep the image small
and the wrong one. Dependabot only watches the root directory, so a bump there
would have left the service's copy behind -- and the drift test I wrote to catch
that would then have failed every dependabot pull request until someone edited a
file dependabot cannot see. The express bump waiting in the queue would have hit
it immediately.

The Dockerfile now picks the four packages it needs out of the root manifest at
build time, so there is one set of pins and nothing to keep in step. That matters
most for Playwright, whose browser download is version-locked to the library: a
mismatch there fails in a way that reads as a rendering bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(render): illustrations export standalone, and drop the URL version stamp

Two illustration bugs, both hidden by the fact that a diagram worked:

A .png of any illustrated pathway was a 500 -- "this view cannot export PNG yet"
-- because the export only knew how to ask a cytoscape instance. Illustrations
are the top-level pathways, so it was the ones a report is most likely to want.
They go through the same rasteriser the animation frames use now.

A .pdf of one was a blank 674 bytes, caught only by the size floor. An EHLD
declares width and height of 100% with no viewBox, so outside the page it has no
intrinsic size at all, and its styling comes from the page's stylesheets rather
than the markup. EhldService.svgMarkup writes both down, and the SVG export uses
it too -- that one was shipping unstyled.

And the version stamp goes away. Figures are `private, no-cache` now, so nothing
reuses one without asking; the ETag makes a repeat download a 304 in ~1ms. It was
introduced to defeat Cloudflare storing a figure for a day and serving it with
the max-age it was stored under, which is real -- but it meant two constants that
had to be bumped in step, and I bumped one and not the other within the hour,
which produces the worst case: a new address answered from the old cache. One
knob now, RENDER_CACHE_KEY, and nothing new gets stored anywhere.

Bounded the cache while here. A figure averages a couple of megabytes and there
are thousands of diagrams times five formats, on a host that also runs Tomcat,
Neo4j and the site's builds with 4 GB free. Filling that disk takes the site down,
which is far worse than paying for a render again -- everything in the cache is
derived data. 2 GB by default, least-recently-used evicted first, and evictions
are logged: a cache that silently discards what it was asked to keep looks
exactly like one that is working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(export): make the animated SVG's timeline clickable (#141)

The timeline could only be held still by keeping the pointer on the button, which
is what the issue means by "instead of just over". All three controls it asks for
now work, and the animation is CSS keyframes, so each needed a different answer:

- Play/pause on click. The button carries both icons and a class on the root
  decides which shows, so it says what clicking will do rather than what the
  animation is doing. Pausing sets that class and also pauses each animation
  through the Web Animations API -- the class alone holds the picture still, so
  in a browser without the API play/pause still works and only seeking is lost.
- Clicking a segment seeks the whole picture there, by setting currentTime on
  every animation at once. Verified against the timing rather than by eye:
  segments land on 1s, 3s, 5s, 7s for four samples at 2s each.
- Hovering a segment names the sample, through a native <title> tooltip. That
  needs no script at all, which matters because a script in an SVG only runs when
  the file is opened as a document -- not inside an <img>. The hover-to-pause rule
  is kept for that case and scoped so the two never fight.

Two things found on the way:

A sample name containing "<" silently truncated its label. The markup is built as
a string and parsed by innerHTML, which turns "<baseline>" into an element SVG
does not know and therefore does not draw, taking the rest of the label with it.
"&" was already being normalised by the serialiser, so only "<" bit. Names are
escaped now, and a column called `Ctrl & <baseline>` round-trips exactly.

And console.table of every frame's transition times, on every export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(reacfoam): signify flagging with a border rather than a fill (#140)

Flagging used to repaint the flagged groups in the flag colour and, with no
analysis running, wash every other group out to the surface colour. So a curator
could see where a gene appears, or see their analysis result, but not both --
and the pathways where they most want the result are precisely the flagged ones.

The flag is an outline now, drawn in groupContentDecorator by replaying
polygonContext: that buffer holds the commands FoamTree used to trace the group's
own polygon, so the stroke follows the real Voronoi shape rather than an
approximating rectangle. The fill is left alone, so family colours and the
analysis overlay both survive.

Two strokes, a dark one under the flag colour, so it reads on a pale fill as well
as a saturated one, and thinner at depth so a flagged child inside a flagged
parent stays legible. The path is replayed once per stroke rather than stroked
twice: on canvas either works, but the SVG export draws through svgcanvas, which
records one path element per path and keeps only the last style set on it -- the
halo was silently missing from every exported figure while looking right on
screen. Checked in the exported SVG, not just on screen: both widths are there.

Triggering moves to onSurfaceDirty, because flagging changes without the layout
changing. The decorator returns immediately unless something is flagged, which is
what keeps that affordable on a hierarchy this size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: make revealing the selected thing a directive (#137)

The event hierarchy's auto-scroll was the good version of this behaviour, and it
lived as a document.querySelector from the component after the tree finished
building. Three other places had grown their own copy, each with slightly
different behaviour and each reaching into a template it did not own by an id
convention.

RevealDirective inverts it: the element that knows it is selected reveals itself.
Timing follows rendering rather than a guess about it, so a node that appears
later -- as its branch expands, or when the tree is rebuilt for an analysis -- is
revealed when it appears instead of racing whatever finished first.

Applied to the hierarchy's nodes, the analysis result table's rows, and the
pathway list under a selected search result. The hierarchy's fallback of scrolling
to the pathway when the URL selects something that is not an event in the tree is
kept, as `select() ?? pathwayId()`. `node.isSelected` was the obvious input and is
the wrong one: it is also set on every ancestor of the selection, to draw the
path.

Two frames rather than one. One frame is enough for the element to exist and not
enough for it to be where it ends up -- a table still expanding rows, or paging to
a different page, moves after the first -- and revealing then left the row 28px
past the edge of its container. Verified by measurement, not by eye: the hierarchy
scrolls 469px to bring the selected event inside its container, the result row
lands directly below the sticky header, and window.scrollY stays 0 in both, which
is the page-drag that block:'nearest' exists to prevent.

Also respects prefers-reduced-motion, which the copies did not.

Left alone deliberately: found-table re-scrolls its parent row when its own
content finishes loading, which is growth rather than selection and does not fit
the directive; the object tree the issue mentions has no selection to bind to yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(curators): record what to check from this round

Six new things for them to look at, one of which we cannot check ourselves: the
PowerPoint file is structurally valid, but "opens in PowerPoint and converts to
editable shapes" needs PowerPoint, and there is none on the build machine. That
one is also a decision they may want to push back on, so it says what we traded
and why rather than only asking them to click.

Also records what is deliberately not offered -- dark figures -- and what fails
together: GIF and PPTX come from a service running alongside the site, so if those
two buttons break it is almost certainly the service rather than the diagram, and
saying so saves a round trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(idg): port the IDG protein-to-pathways search (/idg)

"What does this protein have to do with Reactome?", ported from the Vue app on
idg.reactome.org, reachable from a tile on the homepage next to ReactomeFIViz.

The data did not have to move first, which is the reason this is a front-end
change and nothing else. idg.reactome.org/idgpairwise is public, answers with
Access-Control-Allow-Origin, and returns TANC1's 482 enriched pathways in 70ms.
IDG_SERVICE is the one line that changes when the data moves here.

The pathways it finds are ours, so the table links into our own Pathway Browser
rather than carrying the IDG portal's GWT diagram widgets across -- those widgets
are the thing this year's work replaced.

Two endpoint names are misspelled in the service ("realtionships", "Pathays").
They are spelled that way here too, because the service is what has to answer.

An empty result now says which kind of empty it is. checkTerm tells "we have never
heard of this symbol" apart from "this protein has no enriched pathway in the
datasets you picked", and those call for different next steps -- check the
spelling, or add datasets.

Handling the IDG server being down took two goes. Reading value() on a resource
that failed throws, and a throw inside a computed the template depends on takes
the render with it, so a 503 from IDG produced "nothing found for TANC1" -- the
page blaming the gene for the server being unreachable. Every resource read is
guarded by hasValue() now and failure is judged by status(), checked before the
loading and empty-term branches because it can happen before anyone searches.
Verified against a 503 and a refused connection, and that the healthy path still
returns 482 rows with no page errors.

Breadcrumbs title-cased the segment, so /idg read as "Idg". Acronym segments are
upper-cased now -- idg, api, doi, faq, orcid, toc -- which is right for all of
them and wrong for none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(serve): say "rebuilding" instead of throwing ENOENT mid-build

`ng build --watch` empties and rewrites the output directory, so for the ten to
twenty seconds a build takes there is no index.html to send. Requests landing in
that window surfaced Express's ENOENT stack trace, which reads like the site is
broken rather than busy, and gives whoever is looking at it no way to tell those
apart. Adam hit exactly that while I was rebuilding beta.

503 with Retry-After is the honest answer -- the server is fine, the build is not
there yet -- and the page refreshes itself so nobody sits reloading. Anything else
that cannot be read is still a 500, because that is a real fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(idg): filter by functional interaction score, and show the distribution

The table had 482 rows for TANC1 because I passed a p-value where the service
wanted a score. `prd` is the functional-interaction cutoff and defaults to 0.9;
0.01 meant no filtering at all. Two modes exist and the service picks by whether
datasets are named, which is worth knowing: with datasets chosen `prd` is ignored
entirely -- TANC1 returns the same 482 pathways at 0.01 and at 0.99 -- and only
the no-datasets path actually filters.

The threshold cannot have a fixed default. The service's own is 0.9, and TANC1's
best predicted interactor scores 0.891, so 0.9 returns nothing whatsoever for it.
It starts at the 90th percentile of the gene's own distribution instead, says how
many interactors that keeps out of how many there are, and says what the best
score is -- so a threshold that returns nothing is visibly the threshold's fault.

Under it is the distribution itself, 28 buckets, filled where they are kept and
faded where they are not. TANC1's scores sit between 0.24 and 0.89 with a median
of 0.52, which no fixed number would have suited.

The table defaults to FDR <= 0.05 and lowest-level only, both untickable. TANC1
goes from 482 rows to 71, and the top of it is now NMDA receptors and
postsynaptic transmission, which is what TANC1 is for.

And the overlay this was missing: the kept interactors can be run through
Reactome's own analysis, which returns a token, which colours Reacfoam and every
diagram through machinery that already exists. It is not IDG's enrichment
recomputed -- it is Reactome's overrepresentation of the interactor list -- and
the button says so rather than implying the numbers match the table.

The token came back percent-encoded and the router encoded it again, which is the
same double-encoding that bit the download links. Decoded at the service boundary
this time, so every caller can encode once.

Still missing IDG's own diagram: a pathway-similarity network, 412 nodes coloured
by weighted Target Development Level and 22,509 edges. The edge count needs a
filter of its own before it is worth drawing, so it is not in this pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(idg): show Target Development Level, and accept lower-case symbols

The table had p-value and FDR, which any enrichment gives you. What makes this
the *druggable* genome is Target Development Level, and the page was throwing it
away.

It comes from the network endpoint, which returns the same lowest-level pathways
the table does -- 267 nodes against 267 lowest-level rows for TANC1 -- with a
weighted mean TDL per pathway. The scale runs tDark to tClin and low is dark:
TANC1's enriched pathways run 1.69 to 3.50, with FCGR3A-mediated phagocytosis at
the dark end and acetylcholine binding at the drugged end. As a column, and as a
plot of significance against how well studied, where the interesting corner is
high and to the left: enriched for this protein's interactors, and full of
proteins nobody has drugged.

Lower-case symbols found nothing. The service's index holds upper-case human
symbols only -- checkTerm says TANC1 exists and tanc1, Tanc1 and Trp53 do not --
and the enrichment endpoint answers a lower-case term with zero pathways rather
than an error, so "tanc1" looked exactly like a gene with no enriched pathways.
Terms are upper-cased on the way in, which cannot collide with a mixed-case
symbol from another species because none is in there, and UniProt accessions are
upper-case already.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(idg): add the portal's three plots, and match its defaults (#idg)

I had built one plot of my own choosing and left out all three of IDG's, which
was the wrong way round. So I drove the real portal and recorded what its results
page asks for, rather than guessing again:

- **Interacting Pathway Plot** -- significance per pathway, coloured by top-level
  pathway. That is the "type of pathway" that was missing, and it is the thing a
  table of p-values cannot show: whether a gene's hits cluster in one part of
  biology or scatter across it. TANC1's cluster in Signal Transduction (18),
  Immune System (7) and Neuronal System (7). The mapping comes from
  getHierarchicalOrderedPathways -- 2,730 stId-to-top-level pairs, fetched once
  and shared.
- **Genes vs Functional Interaction Score** -- how many predicted interactors
  survive each threshold, as a curve. More useful than my histogram for choosing
  a threshold, because it says what a move costs.
- **Feature Summary** -- interactions per data source, coloured by kind of
  evidence. Its request wants gene *names* and dataset *id strings*, not the
  digital keys everything else takes; sending keys returns an empty list rather
  than an error, which is how I first concluded the endpoint was not the right one.

Three defaults now match the portal, and the numbers agree exactly: threshold
0.8 rather than a percentile, so TANC1 has 11 interacting genes and 54 pathways
where the portal shows 11 and 54; a Genes column, which the payload had as
numGenes all along; and ten rows a page with a working pager rather than a
hundred-row wall.

The TDL plot I added stays. It is not in the portal's results page -- TDL appears
there only inside the pathway overview -- but low TDL is what "druggable" means,
and having it next to significance is the reason to look.

Plots are positioned elements and one polyline rather than a charting library:
every point is a link with a tooltip, the shapes are scatter and a line, and the
repo has no charting dependency to justify adding one for that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(idg): drop an unused type import

Left over from wiring the feature summary. My removal patch silently missed
because prettier had already collapsed the import to one line, and I committed
over the top of a lint gate that was one warning above the baseline -- exactly the
failure I asked the asserts to catch, on the one edit where I did not check the
gate afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(idg): teach the e2e suite about the new shortcut and page

CI failed and it was right to: the homepage test asserts the exact set of shortcut
cards, and I added a sixth without telling it. That test exists because these
cards have been silently mis-wired before, so the answer is to declare the card,
not to loosen the count.

Also a smoke test for /idg, deliberately stopping short of the results table. That
data comes from idg.reactome.org, a different server from everything else this
suite touches, and a test that needs it to answer would fail here whenever it is
down while saying nothing about our build. What is guarded is the part we own: the
route renders, and searching a lower-case symbol puts the upper-case one in the
URL -- the bug Adam found by typing "tanc1".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(idg): expandable rows with the pathway's description and hierarchy

The last thing the portal's results page has that ours did not. Clicking a row
opens the pathway's description and its place in the hierarchy, both from our own
content service -- which is what the portal does too, so an expanded row costs
nothing from a machine we do not control. One row open at a time, as it has.

The ancestry comes back with the pathway first and the root last, which is the
opposite of how a breadcrumb reads, so it is reversed: Neuronal System →
Transmission across Chemical Synapses → Neurotransmitter receptors and
postsynaptic signal transmission → Activation of NMDA receptors, which is what the
portal shows for the same row.

Summations carry markup -- `<br>` between paragraphs, the odd `<i>` -- and my
first version rendered "...Traynelis 2018).<br>NMDA tetramers assemble..." with
the tag visible. Converted to text with real line breaks rather than passed
through innerHTML: nothing in a description needs to be clickable, and text
cannot inject anything.

Also settles where the network view belongs. IDG's "Open Pathway Overview" leaves
the results page entirely -- it opens their GWT browser at
#/FLG=TANC1&DSKEYS=0&SIGCUTOFF=0.8 -- so the pathway-similarity network is a
feature of that browser, not of this page. Our equivalent is the genome-wide
overlay button already here. Nothing on the results page is missing now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(idg): list the interacting genes, with a CSV, and link out to Pharos

The threshold note said "11 of 2541 predicted interactors score 0.80 or better"
without ever saying which eleven. They are behind a collapsed summary now --
supporting evidence for the table rather than the point of the page -- strongest
first, with their scores. For TANC1 they are SPTAN1, five tubulins, HOMER1, KRAS,
SHANK1, DLG4 and CAMK2A: postsynaptic scaffolds, which is what TANC1 is.

The CSV is built from what is already loaded rather than through the service's own
download endpoint. The numbers are on the page to draw the list, so asking a
second machine to derive the same file is a round trip and a failure mode for
nothing.

And a Pharos link per gene, which is where the Target Development Level in the
table comes from -- so it is the right place to send someone who wants the
evidence behind that number.

Deliberately not included: the annotated-pathways tree. It is the one thing on the
portal's results page we still do not have, and the one I would add next, but
Adam's call was these three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(idg): download the pathway list as a CSV

The file carries every row the filters kept, not the ten on screen -- a file of
ten because that happens to be the page size would be a surprise. 54 rows for
TANC1 at the default threshold, which is what the heading claims.

It also carries the weighted TDL column, which the portal's own download does not,
because building the file from what is already loaded means it can include
anything the page knows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(render): run the container's browser from a path its user can read

The container built, started, reported healthy, and failed every render:
"Executable doesn't exist at /home/node/.cache/ms-playwright/...". `playwright
install` had run as root and put Chromium in root's cache, while the service runs
as node and looks in its own. PLAYWRIGHT_BROWSERS_PATH puts it somewhere neither
owns, chowned to node. The headless shell is named explicitly too, since that is
the binary chromium.launch() actually wants.

Worth remembering what the health check did not catch: it proves the HTTP server
answers, not that a browser can start. Healthy and useless at the same time.

Compose also had to match this box, where the site runs on the host rather than in
compose: the container renders host.docker.internal:4200 and publishes 4310 on
loopback for serve-prod to proxy, and no longer depends_on app, which would have
started a second app container to fight for :4200. A deployment where both are
containers drops the published port and uses the service name.

Verified through the container: R-HSA-109606 GIF at 5976x3350 in 22s, PPTX 1.1MB,
an illustration GIF in 4.4s, and beta serving a cache-busted request end to end
with cf-cache-status BYPASS. The cache is on a volume and a repeat is a 6ms hit.

The restart policy is configured but not proven here, and the README says so: a
crash is hard to simulate honestly. docker kill is a manual stop, which docker
does not restart; kill -9 1 inside is ignored because the kernel shields a
namespace's init from its own signals; and signalling from the host needs root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: start turning the release checklist into a suite

Adam's aim is that a green suite stands in for days of curator testing. This is
the first slice of that, built from Appendix R3 rather than from what I happened
to touch.

RELEASE-TESTING.md is the new-UI successor to that document. Same ground -- front
page, navigation, Pathway Browser, in-diagram search, details panel, context
menus, the four analysis tools, downloads, post-release -- but every row says
whether a test proves it, a human must do it and why, or it is a gap and therefore
work. 53 rows, and honestly a minority are automated today. It should get shorter
every release.

Three specs, chosen because each covers something that has actually broken:

nav-links crawls all 73 links in the rendered navigation and fails naming any that
404 or render an empty shell. This is the longest paragraph in the old document,
and the curators' QA pass found Analyse Gene Expression 404ing and Species
Comparison on a blank panel. The list comes from the rendered nav rather than
nav-options.json, because the dropdowns are CMS content -- so a page an editor adds
is covered without anyone updating a test.

downloads checks what is *inside* each file. Every download bug this month passed
"did something download" and fails this: a .jpeg holding PNG bytes, a blank
674-byte illustration PDF, an illustration PNG showing its own top-left ninth. GIF
and PPTX come from the render service and skip with a reason when it is not
running, since its absence says nothing about the build.

in-diagram-search covers the USP10 case the document asks for by name, and was one
of the four blockers that stopped the curators' pass entirely.

Two traps are written down because I hit both: export URLs 403 without a Referer
(protect-exporters.sh sheds crawler load that way, and it looks exactly like a
broken feature under curl), and download buttons must be matched by their label's
own text, since the icon ligature makes the button read "imageSVG".

And the limit worth saying out loud: the suite runs against localhost:4200, so
Apache rules, Cloudflare and TLS are outside it. Green means the application is
right, not that beta is serving it correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(testing): correct three rows the last commit claimed were covered

The in-diagram search rows still said "gap" after I committed a message saying a
spec covers them -- a patch that missed because prettier had reflowed the table,
which is the third time that has caught me today. Matched line by line instead of
on exact text.

Also records the tally, since a release gate should say how much of itself is real:
23 rows automated, 7 that genuinely need a person, 23 still to write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: cover every top-level pathway and the interactor overlay

diagram-coverage walks all 29 top-level pathways from the content service and
fails naming any that draw nothing. This is the broadest guarantee in the suite: a
change that breaks one family of diagrams while leaving the single pathway other
tests happen to open intact passes everything else and fails here. It takes 1.1
minutes, against 29 clicks a person otherwise makes every release. The list is
fetched rather than written down, so a pathway promoted to top level is covered
the release it appears.

interactors covers the overlay by looking at the diagram before and after, not at
the button: a resource turning blue proves only that a button turned blue.

Two rows of the release document turn out to describe features this UI does not
have, rather than tests I had not written: there is no interactor confidence
threshold and no interactor download, and no threshold concept anywhere in the
interactor services or the URL state. The old browser has both. They are marked
**missing** in RELEASE-TESTING.md -- a distinct status from **gap**, because one is
work and the other is a decision -- and written into the curator report, since
curators will look for them.

25 automated, 7 needing a person, 19 to write, 2 not built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(diagram): let a reaction's input edges reach the reaction (R-HSA-6805479)

On a reaction page the lines coming into the reaction stopped short of it, while
the outgoing ones met it properly. The asymmetry is the clue.

A connector's own segments end short of the reaction by design -- 21 to 29 units
in a pathway diagram -- and the reaction's own backbone segments bridge the rest.
addEdgeInfo walks those segments, forward along extraLine (from -> to) and
backward along reverseExtraLine (to -> from). A pathway diagram records the input
side pointing *towards* the reaction, so the forward walk follows it. The reaction
exporter's layouts record it pointing *away*: for R-HSA-6805479 the backbone reads
267 -> 247 where a pathway diagram would read 2277 -> 2298. The forward walk finds
nothing, and the edge ends 26 units out. Outputs were fine only because their
orientation happens to suit the backward walk.

So one step along a segment recorded in the opposite orientation is now allowed,
guarded: the step is taken only if it lands strictly closer to the point the walk
is heading for. That guard is what makes this safe where the cross-map fallback
removed earlier was not -- a wrong-direction segment leads away from the stop and
is refused, so a U-shape cannot be pulled out of shape.

Verified against that exact risk rather than asserted. Rendering R-HSA-112307,
transmission across electrical synapses -- the U-shape diagram the removed
fallback used to corrupt, and whose K+ connectors have no segments of their own so
this routine builds their whole path -- gives a byte-identical PNG before and
after. R-HSA-109606 differs by 4 bytes of PNG compression and zero pixels. The
only thing that changes is the reaction page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(analysis): make tissue enrichment reachable by the analysis service

Every tissue analysis failed. The form posts a URL to the analysis service, which
then downloads the sample itself, and the URL was hardcoded as
https://127.0.0.1/ExperimentDigester/... -- an address the analysis service
cannot fetch. It answered 422 for all of them.

The URL is for the backend, not the browser, which is the whole subtlety: it has
to resolve from inside the service doing the fetching. Nothing terminates TLS for
127.0.0.1 there, so plain HTTP to the Tomcat both services already share is what
works -- verified directly against the service, which returns a token for the
localhost:8080 form of the same URL and 422 for the current one.

It is a constant alongside the other service URLs now, named DIGESTER_FOR_BACKEND
so the next person can see it is not addressed from the page. Sending it out
through the public hostname instead would also work, and would put an
analysis-sized request through the hairpin that took Apache down with 522s once
already.

Verified end to end in a browser: choosing Adrenal Gland and pressing Next now
produces a token in the URL, an overlay on the diagram, and no failed requests.

Not verified: species comparison and the quantitative tools. My attempt to drive
species comparison closed the form instead of running it, so I am not claiming
either way. They do not share this bug -- there is no other hardcoded backend URL
in the app, and those tools pass identifiers rather than a URL for the service to
fetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(detail): a reaction's downloads live with its figure, drawn by our renderer

The reaction page listed SBML and BioPAX in a bar above the summation, nowhere
near the diagram, and offered no figure formats at all -- the bar gated those on
isPathway, so a reaction had no SVG, PNG, PPTX or SBGN. The current site lists
all of them inside the panel that holds the figure: data exports at the left,
figure exports at the right.

So the bar becomes a two-group toolbar and the reaction diagram section takes a
projection slot for it. Both copies are always projected, because projected
content cannot sit inside an @if, and each renders only where it belongs -- one
toolbar on the page either way.

The figure formats now come from the render service rather than the old
server-side exporters, asked for the reaction's own layout via a new
?view=reaction: the render page draws the same reaction-scoped diagram the page
displays, so a downloaded picture is the picture on screen. `select` frames a
curated diagram on one event, which is a different figure and still available.
PNG keeps the three tiers as render scales.

Found on the way: the service passed neither `select` nor `view` to the page,
and the cache key was missing both, so every variant answered from one entry.

  1098x463 reaction SVG, 549/1098/2196px PNG tiers, PPTX and PDF verified
  through the container; 16/16 downloads.spec.ts, 65 unit, lint 660, knip 147

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(detail): the data exports read as a document, not as stray braces

data_object draws a pair of curly braces, which beside a list of format names
reads as punctuation someone left behind rather than as "these are the data
formats". A document sheet says the same thing without being a puzzle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(idg): a pathway link opens with the interactors overlaid

Clicking a pathway in the IDG results opened the diagram with nothing
highlighted, which loses the point of the click: the question being asked is
where this protein's interactors turn up, and the answer is the overlay. Only
the "overlay on the genome-wide view" button ever created an analysis, so every
other link arrived with no token.

The page now analyses the kept interactors once per gene list and every pathway
link carries the token -- the plots, both table links, and the hierarchy inside
an expanded row. The button reuses it rather than posting the same genes again
for a second token.

Also: the info tab's three headings were emoji, and the magnifier fell back to a
missing-glyph box beside "Explore Reactome Pathways". They are material symbols
now, like every other icon on the site.

  TANC1 -> Activation of NMDA receptors lands with 21/71 found and the results
  titled "Overrepresentation analysis results of TANC1 interactors";
  e2e idg 3/3, 65 unit, lint 660, knip 147

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(download): the catalogue points at files that exist, in the release we serve

Thirty-three of the 74 links on the download page were broken. The names were
wrong, not the plumbing: the Guide to Pharmacology set is published as GtoP*
and we asked for IUPHAR*; every PE mapping is .txt and we asked for .tsv; the
interaction files live under interactors/; the figure archives are
diagrams.svg.tgz rather than diagrams/homo_sapiens.svg.tgz; five other mappings
had invented names (models2pathways.tsv, Reactome2OMIM.txt,
ProteinRoleReaction.txt, disease_variant_ewas_mapping.tsv,
humanPathwaysWithDiagrams.txt). The functional-interaction networks and the
curator tool were built as <bucket>/current/<file>, a prefix the bucket has
never had -- both are tools published under reactome.org/download/tools, and the
curator tool ships as two installers rather than a jar. Complex_2_Pathway_human
and Ewas2Pathway_human are published and were simply missing.

Every name checked against release 97 rather than guessed.

The release itself was also wrong. It was read once in ngOnInit, before the
content service had answered, so the page fell through to a build-time constant
and offered the previous release. It follows the database now -- and there is no
constant to fall back to: a number baked into the bundle is stale from the first
release after a deploy and looks exactly as authoritative as the real answer. So
the homepage header and the download links say nothing about a release until the
database has said, and the links stay inert until then rather than pointing at a
URL nobody can resolve.

  74/74 links return 200; download-files.spec.ts HEADs all of them and checks
  the badge against /data/database/version; 65 unit, lint 660, knip 147

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(icons): icons come from the release bucket, not from the dev backend

Icons are released files, published at <version>/icons/svg. They were fetched
from a hardcoded ICON_HOST of dev.reactome.org, because /icon/ is not proxied on
beta or release -- so the site's own icons came cross-origin from a machine
nobody would call part of the site, and beta depended on dev being up to draw
them.

One rule, exported, because two IconServices build icon URLs and they must not
disagree: the bucket when the release is known, the old host until then and on
curator, whose database is not versioned. The icon library, search results and
icon detail page all go through it now.

Non-EHLD figures stay where they are: they are not in the bucket under any path
I could find, and they already serve from the site's own origin.

  28/28 icon requests on /community/icon-lib now go to download.reactome.org and
  render; download-files.spec.ts fails if any icon comes from elsewhere;
  3/3 e2e, 65 unit, lint 660, knip 147

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(analysis): what an analysis is for, not just that its wizard opens

The existing spec drives each wizard to its last step and stops, so everything
after "Analyse" was unverified: the counts beside the pathways, the table you
filter, the two overlays that have no wizard of their own, and the files you take
away. Four checklist rows that curators were clicking through by hand.

Each test creates its own analysis. A token is server-side state, and a test that
leans on another test's token fails alone and passes in a suite, which is the
worst way round.

Notes for the next person, from what cost time here: a material menu item
concatenates its icon ligature into its text, so the Diseases filter reads
"microbiology Diseases" and an exact match finds nothing; the Diseases toggle
changes a total the page never shows and the species facet is disabled outright
for a human gene list, so a filter test has to drive the FDR slider, which takes
the table from 80 rows to 42.

  5/5 analysis-results.spec.ts; RELEASE-TESTING now 34 auto / 7 human / 13 gaps
  65 unit, lint 660, knip 147

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(diagram): a node with no graph entry no longer blanks the whole diagram

Switching species to Mus musculus left an empty canvas with the rest of the page
updated around it -- title, breadcrumb, hierarchy, legend -- which reads as "the
species switch is broken". It was not the switch. The diagram builder logs
"Missing graph data for node" and then dereferences that same value with a
non-null assertion two lines later, so one unmatched node threw for the whole
build instead of for itself. Every inferred species diagram has such nodes, so
every non-human diagram drew nothing.

The dereference is optional now: the node renders without its ChEBI structure and
accession, which is what the log line was already telling us to expect.

Found by writing the test for the species switch, which is the point of writing
them. R-MMU-109606 and R-RNO-109606 both draw, and both are asserted so this
cannot come back quietly; the sub-pathway hover row moves to human, because the
tints are on a canvas and the sidebar's hover handler only scrolls long names.

  4/4 diagram-behaviour.spec.ts; RELEASE-TESTING 36 auto / 8 human / 10 gaps
  65 unit, lint 657 (new baseline), knip 147

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(detail): the contents of the panels, not just their presence

The checklist rows for the molecules tab, protein structures and the reaction
page were all "the panel exists" at best. These assert what is in them: the
molecule groups with their counts and stoichiometry multipliers, the structure
viewer naming its PDB entry, and a reaction's name, id, type, species, summation
prose, every section heading, and a reference that resolves to PubMed.

Two things worth writing down, both found while making these pass. Selecting an
entity only shades the molecule list while that tab is already open -- arriving
with ?select= set opens the entity's own details and the list is not offered at
all, so the order of the two actions is the test. And the structure viewer's
label and accession are separate elements, so its text reads "StructurePDB5JSN"
and an assertion with whitespace in it never matches.

  4/4 detail-contents.spec.ts; RELEASE-TESTING 39 auto / 8 human / 7 gaps
  65 unit, lint 657, knip 147

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(content): a page that names a release gets the one we are serving

The statistics page embedded release 95's figures while the site served 97,
because the release number was typed into the markdown once and nothing brings
it forward. Content asks for `{release}` now and the renderer substitutes what
the database reports -- and a hardcoded number in a bucket path is rewritten
too, since everything under the versioned bucket is republished per release, so
the current one is always the right answer.

Checked against production, which embeds the same two documents from the same
place. Ours now says "Version 97, Panther, June 2026" like it does.

Pages are only awaited on the release when their body mentions one, so the
overwhelming majority still render without waiting on the content service.

Also recorded, from writing these tests: the newest news announcement is V96
while the database serves 97, which is written content nobody can generate -- so
that check is a marked expected failure, green in CI and loud the moment someone
publishes the V97 item. And /tag/release 404s: it has not been ported.

  5/5 content-currency.spec.ts; RELEASE-TESTING 43 auto / 8 human / 2 gaps
  65 unit, lint 657, knip 147

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(news): import announcements from the current site, verbatim

The V97 announcement was missing, so the site advertised V96 while serving 97.
These are curated announcements -- release notes, contributor credits, statistics
-- and the words matter exactly as written. Nothing here writes prose: the
importer converts markup and then checks its own work, comparing the plain text
of what it produced against the plain text of the source and refusing to write
the file if a single character differs.

That check earned its place four times over while importing V96 and V97:

  - a link target like `</images/x.png>` is indistinguishable from a closing tag,
    so the step that strips leftover markup ate every link on the page;
  - one announcement links "BlueSky@reactome.org " with the space inside the
    anchor, and trimming the label joined the link to the next word;
  - Joomla rebuilds hidden addresses from entity-encoded fragments, so reading
    the assignment up to the first semicolon returned one letter of the address;
  - Cloudflare hides others behind a link whose fragment is the address, wrapping
    the Joomla cloak on some pages.

A dropped address cannot pass silently either: both sides of the comparison would
lose it together, so the count of hidden addresses is checked against the count
decoded. That is how V96's "Follow us on [email protected] get frequent updates"
-- a sentence nobody wrote -- was found and fixed, along with the address at the
end of the same file.

  V97 imported and V96 corrected, both text-verified; 6 unit tests cover the
  four traps; the news currency check is now an ordinary assertion
  71 unit, lint 657, knip 147, e2e content-currency 5/5

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* build: stage content as part of the build, and check the PNG tiers differ

Two loose ends.

`npm run build` indexed the content but did not compile it, so a build picked up
new announcements in the listing and served an empty page for them -- which is
exactly what happened while importing V97, and the only reason it was noticed is
that the page was checked afterwards. The deploy workflow and `npm start` both
staged first; a bare build did not, and remembering that is not a plan.

And the PNG menu's three tiers are now asserted to be three different pictures,
by reading the width out of each file's header. Low, Medium and High returning
the same bytes would be worse than offering a single PNG.

  RELEASE-TESTING: 45 automated, 7 human, 1 gap, 3 not built -- the tally line
  had drifted from the rows and is corrected here too
  71 unit, lint 657, knip 147, downloads 17/17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(figures): take figures from the release bucket, falling back to the origin

Figures are the last asset the new site took from somewhere other than the
release: they live in the legacy Joomla document root, which is why nothing under
the versioned bucket ever resolved for them. They are requested from
<release>/figures now, the same arrangement as icons and illustrations, with a
fallback to our own origin so this is safe to ship before the release process
publishes them -- verified on beta, which asks the bucket first, gets its 403 and
falls back with nothing to redeploy once the files appear.

Worth knowing while doing it: figures are broken on beta today. Our origin
answers /figures/* with the application's index.html, so every one of the 883
figures the database names is a 200 that is not an image -- which also means a
link check against our own origin proves nothing. Publishing them to the bucket
fixes that; serving the legacy root through Apache would too, as a stopgap.

Found a trap on the way: DetailFigureService declared `implements
Partial<FigureService>`, so adding a method to the real service compiled cleanly
and broke the detail page in the browser with "figure.src is not a function". A
substitute for a token has to answer everything asked of the token, so it
implements the contract now and the compiler will catch the next divergence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(figures): address figures outside the release, now that they are published

Figures are not release artefacts. Icons and illustrations are regenerated each
release, which is why the bucket versions those; a figure is a curated image the
database references by a stable, version-free path, and the same file serves
release 95 and 97. Keying the URL on the release would have copied 150MB into
every release directory four times a year, and meant no figure could be
addressed until /data/database/version had answered.

So they live at the bucket root, and 2163 of them are now published there. 654
of the 665 the database references return a real image through the CDN; the
remaining 11 exist on no host at all and are for curators to supply or to stop
referencing -- five are named after papers, which is what a figure pulled for
licensing looks like.

This is the first time figures have worked on the new site. Our own origin
answers /figures/* with the application's index.html, so every figure was a 200
that was not an image, and a link check against our origin would have called it
healthy. The test asserts both halves: the host, so a fallback to the origin
fails it, and naturalWidth, so HTML with a 200 on it fails it too.

  654/665 through the CDN, verified drawn on beta; 71 unit, lint 657, knip 147
  RELEASE-TESTING: 46 automated, 7 human, 1 gap, 3 not built

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: close the testing queue, and draw the pathway thumbnail with our renderer

Loose ends, finished rather than listed.

The pathway detail page's figure came from the content service's diagram
exporter, which reimplements the drawing server side -- so the picture on the
page was in the previous site's style while the download buttons beside it, which
go through the render service, were in the new one. Two pictures of the same
pathway on one page. It is the same renderer now, for cytoscape diagrams and for
illustrations, with the old exporter kept as a fallback for a cold render or a
service that is down.

The last three items in the queue are closed too, two by testing and one by
admitting it cannot be:

  - the participating-institute logos: each has an absolute destination that
    answers, and each logo really draws
  - the contents of the four pathway tabs that need no analysis (the other two
    are covered once an analysis exists)
  - the animated SVG timeline moves to human, like sub-pathway hover: it scrubs a
    canvas, and asserting pixels there fails on font rendering rather than on
    behaviour

Also adds FOR-CURATORS.md, which is the outbound half of RELEASE-TESTING.md:
what changed since the curators last looked and is worth re-testing, what is
waiting on a decision only they can make, and the limits worth stating plainly.
Both are updated in the same commit as the work they describe.

  RELEASE-TESTING: 49 automated, 8 human, none to write, 3 not built
  71 unit, lint 657, knip 147, detail-contents 7/7

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(serve-prod): cover the server that actually serves beta

It had no test. The e2e suite starts `ng serve`, so nothing exercised the express
server real users hit -- and its behaviours are all edge cases, which is exactly
why they need pinning: the catch-all that makes deep links survive a refresh, and
the 503 that replaced an ENOENT stack trace during a rebuild.

One of them is pinned as documentation rather than as a wish: a missing asset
comes back as the application's own HTML with a 200 on it. That is unavoidable in
a single-page app -- it cannot tell a bad asset path from a route -- but it is how
every broken figure on the site passed for healthy, and anything checking assets
has to read the content type rather than the status.

DIST_DIR was added so the test can serve a directory it controls; unset, which is
every real deployment, nothing changes.

Written now because the express upgrade in #134 needs something to prove itself
against.

  6 tests, 395ms, hermetic (temp directory, free port)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* build(deps): express 4.18.2 -> 4.22.2 (dependabot #134)

The only internet-facing package we own: serve-prod.js is what beta runs. It
carried a high XSS advisory in response.redirect and a body-parser denial of
service, both fixed here, and 4.22.2 is within the same major.

Taken first of the eight open dependabot PRs because it is the one with real
exposure -- the rest are build and authoring tooling. Proven by the serve-prod
test written in the previous commit: same six behaviours, same result.

  express, body-parser, path-to-regexp, send, serve-static and cookie advisories
  all clear; 77 unit, lint 657, knip 147; beta answering in 70ms

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* build(deps-dev): playwright 1.58.2 -> 1.62.1, vitest 3.2.4 -> 3.2.7 (dependabot #129)

Both patch-level, and both are what runs the tests -- so the proof is the suites
themselves rather than anything written for the occasion. Clears the critical
advisory on vitest's UI server.

  77 unit; 50 e2e across the specs that cover today's work, the downloads, the
  catalogue, the homepage and IDG; lint 657, knip 147

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* build(deps): Tina 3.12 and its CLI, which is where the criticals actually were

Dependabot offered tinacms 3.8.1 -> 3.11.0 (#133). Taking it would have changed
nothing: both versions pin mermaid ^11, and the vulnerable mermaid@9.3.0 ->
dompurify@2.4.1 came in under `@tinacms/cli@2.1.7`, which pins its own
tinacms@3.5.1. The fix is the CLI: 2.6.0 depends on tinacms ^3.12, so the whole
chain moves to mermaid@11.17.0 and dompurify@3.4.14.

Also removes angular-cli-ghpages, which nothing used -- no script, no workflow,
and the deploy is an aws s3 sync. It and its gh-pages dependency were the last
two criticals. Its vestigial `PathwayBrowser:deploy` target goes with it rather
than being left pointing at a builder that is no longer installed.

  71 advisories -> 40, high 35 -> 16, critical 5 -> none
  77 unit, 25 e2e across content pages and the editor, lint 657, knip 147

Covered first, in the previous commit: the editor loads for authors and is
refused on a public origin -- an Apache rule nothing in this repository enforces,
so an upgrade that quietly opened it would otherwise be invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(curators): where to look on beta, and what a correct result looks like

A list of pages with the observation that matters on each, so a re-test pass is
reading a table rather than reconstructing what changed. Includes the four things
that look like bugs and are not, so nobody spends time on them: the figures that
are missing everywhere, the deliberately refused editor, the page that was never
ported, and the first render of a GIF being slow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(detail): the frame fits its column, the charts fit their box, BioPAX is one link

Three things from a pass over beta.

The frame around the reaction diagram had its right border cut off. Not the
compartment and not the cytoscape fit, both of which were correct: the container
is width:100% with a 1px border and no border-box, so the box came out 1070px
inside a 1068px column and an ancestor with overflow-x:auto clipped the last two
pixels. Two pixels reads as "the border is missing on one side" rather than as a
layout bug, so it is pinned at two widths now.

The statistics charts scrolled inside their own frames. The first is responsive --
611px tall at 1070 wide, 457 at 800 -- and was in a 500px box; the second is a
fixed 611 and was in 595. The first is sized by aspect ratio now, with enough
slack for the ~18px of chrome that does not scale, and the second by the height it
actually needs. Checked at four widths.

BioPAX is a single link, Level 3. Level 2 has been superseded for years and the
menu stood in front of the one people want.

Also, from the same pass: the reaction diagram re-fits after fonts load and
whenever its container changes width, which is correct regardless of the border
bug; and the protein structure test accepts AlphaFold as well as PDB, because the
viewer shows whichever resolved first. Whether an experimental structure should
win when one exists is now a question in FOR-CURATORS rather than a guess here.

  77 unit, 9 e2e on the detail pages, lint 657, knip 147

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(homepage): don't make the suite depend on four university websites

The institute logos' destinations are checked locally but not in CI, where
reaching them would tie the suite to their uptime and the runner's egress. The
assertion that catches the failure we own -- an empty or mistyped nav entry --
still runs everywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: shard the e2e suite, which outgrew a single runner

It passed the job's 30 minute limit today and was killed mid-run. A cancelled job
reads like a hang rather than like "this suite got bigger", which is what
actually happened: today's work took it from about 90 tests to 120.

Four shards, split by playwright itself, so coverage is unchanged and the wall
clock comes back to roughly what it was. fail-fast is off so one shard failing
still tells us about the other three, and each uploads its report under its own
name -- same-named artifacts collide and the upload fails exactly when the report
is wanted.

Co-Authored-By: Claude Opus 5 <…
adamjohnwright added a commit that referenced this pull request Sep 14, 2026
…nt flag (#202)

* feat(deltasignal): the pathway perturbation UI, behind a per-deployment flag

Brings #155 onto main. The feature is Chryseisliu's; what is added here
is the flag and the update to current main.

Taken as content rather than as a merge. The branch was 139 commits
behind, and #198 -- which merged main into it and added the flag -- was
squashed into it, which flattened main out of its history. The content
was then correct and the history was not, so a further merge conflicted
in six files for no useful reason. These 23 files are the branch's own
diff against main, applied to main.

.github/workflows/tests.yml was deliberately left behind: the branch
still carries actions/setup-node@v4 and upload-artifact@v4, and taking
it would have reverted the two dependabot bumps that landed on main
since (#125, #127).

The flag: SiteProfile.deltaSignal -> SHOW_DELTASIGNAL ->
viewport.showDeltaSignal -> @if around the Perturb button and the panel.
Absent means off, so a deployment opts in rather than inheriting a
research feature. Only `development` has it on, because /api is routed
by proxy.conf.js -- the dev server's proxy, which does not exist in a
built artifact and which nothing in deploy.yml or the Apache config
provides.

Still true, and still the thing between this and a deployed DeltaSignal:
the service calls /api/pathways, /api/parse and /api/solve as bare
relative paths with no environment indirection, where every other
backend here goes through a constant. On a built site those three reach
the origin and get index.html back. The flag makes that safe rather than
fixed -- the feature is not offered where it cannot work.

210 unit tests, check:types, both ratchets unchanged, and
e2e/deltasignal-toggle.spec.ts passes in both states.

Co-authored-by: Chryseisliu <Chryseisliu@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(deltasignal): default the proxy at the port DeltaSignal is actually on

DELTASIGNAL_BACKEND defaulted to http://localhost:8080. On the Reactome
dev host 8080 is the Tomcat serving ContentService and AnalysisService,
so /api/pathways, /api/parse and /api/solve went there and came back
404 -- a real server denying a real request, which reads as a broken
feature rather than an unconfigured one.

DeltaSignal's own compose also binds 8080, so the two cannot coexist on
this box. It is published on 8090 here; the container still listens on
8080 inside.

Verified end to end rather than by reading the config: the backend is up
(GET /api/health returns deltasignal-api 0.1.0, Julia 1.10.10), the
Angular dev server proxies /api/pathways to it with a 200, and on the
development profile the Perturb button renders, the panel opens, and it
fetches 200 /api/pathways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Chryseisliu <Chryseisliu@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants