Skip to content

Put the DeltaSignal UI behind a per-deployment flag (and update the branch) - #198

Open
adamjohnwright wants to merge 138 commits into
feat/deltasignal-uifrom
feat/deltasignal-toggle
Open

Put the DeltaSignal UI behind a per-deployment flag (and update the branch)#198
adamjohnwright wants to merge 138 commits into
feat/deltasignal-uifrom
feat/deltasignal-toggle

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

For #155 — targeted at feat/deltasignal-ui so the merge stays yours, @Chryseisliu. The module itself is good work: signals throughout, explicit status/error state, HttpErrorResponse handling, a typed model, its own spec, and it uses stableId rather than dbIds without being told to.

Two things it needs, and the first one forced the order of work.

The branch was 137 commits behind main

It predates the SITE_PROFILES refactor, so its environments.ts is the old shape — a toggle written against that would have had to be written again. So main is merged in first.

Two conflicts, both in viewport, both the same shape: main removed the compare-form scaffold (#174) while this branch added the DeltaSignal panel beside it. Both decisions kept — compare gone, DeltaSignal staying — and the two dropdown states folded into one signal.

The flag

The Perturb button and the panel rendered unconditionally, so DeltaSignal would have appeared on production, beta and the curator site alike. It is now a per-deployment profile field:

SiteProfile.deltaSignal?: boolean  →  SHOW_DELTASIGNAL  →  viewport.showDeltaSignal  →  @if

Absent means off, so a new 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. Turning it on elsewhere would offer a button whose every call fails.

A feature toggle, not a variant: the standing note in environments.ts says new deployments should compare SITE_VARIANT rather than grow a second boolean, but that is about which UI a deployment presents, not which features it enables. The new field says so.

Verified in a browser, not by reading the flag

e2e/deltasignal-toggle.spec.ts proves both states from one build, using the window.__APP_ENV runtime override the config already reads: the button is absent under the default profile and present under development.

Worth admitting: the first version of that test asserted /^Perturb$/ and found nothing, which made a working flag look broken. The button renders a <mat-icon> first and the ligature is text, so its content is "conversion_path Perturb". There is a comment on the locator now.

Two unit tests too, and the profile one was shown to fail rather than assumed to work — flipping the flag on for beta reports beta.deltaSignal: expected true to be false.

210 unit tests, check:types, check:dead at baseline, lint 653, prettier clean.

Still open, and not addressed here

/api only works in dev. The service calls /api/pathways, /api/parse, /api/solve as relative paths with no environment indirection, and every other backend in this app goes through a constant (CONTENT_SERVICE, RENDER_SERVICE). Nothing configures /api for a deployed environment, so on a built site those three calls hit the origin and get index.html back. The flag makes that safe — the feature is simply not offered where it cannot work — but it is what stands between this and shipping anywhere but a dev machine.

Two smaller notes: /api is a broad prefix for one feature where the house convention is named ones (/reactome, /RenderService, /GSAServer); and DELTASIGNAL_BACKEND || 'http://localhost:8080' defaults to the Tomcat running ContentService, which has no DeltaSignal endpoints, so an unconfigured dev gets 404s from a real server rather than a clear "not configured".

🤖 Generated with Claude Code

beaversd and others added 30 commits August 13, 2026 13:51
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>
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>
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>
…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>
…xplicit

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>
…kage.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>
…n 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>
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>
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>
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>
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>
"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>
`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>
…bution

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

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>
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>
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>
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>
adamjohnwright and others added 30 commits September 4, 2026 17:02
The PPTX floor was 10KB, which was right when a package carried a picture
of the diagram and wrong now: a reaction's figure is 5.6KB of shapes, and
the check rejected it. Lowered to 4KB, which is the package boilerplate.

A byte count was never the interesting question anyway. Both PPTX tests now
read the slide: the diagram's has over a hundred shapes and no picture, and
the reaction's has shapes on a slide a few inches across rather than the
hundreds on the 56in slide a whole pathway needs -- which is the same claim
the SVG case makes about being the reaction's own figure.

Found by the full preflight; `--fast` skips these, so a real regression sat
in the tree while the quick gate said all clear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The smoke it skips is the only check that downloads a file and looks
inside it. A change to an exporter can sit in the tree with fast mode
reporting all clear, which is what happened here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An adversarial pass over yesterday's PowerPoint work, against the diagram
it claims to reproduce.

**Arrowheads carried no meaning.** The model had `arrow: boolean` and the
exporter turned every true into a filled triangle line end. The diagram
draws four heads and three of them say something a triangle does not:
catalysis is a hollow circle, positive regulation a hollow green triangle,
and negative regulation a bar across the line. So a slide drew inhibition
as activation -- a figure asserting the opposite of the pathway.

OOXML line ends cannot fix this: they are always filled, always the line's
own colour, and there is no bar among them. Arrowheads are now geometry the
page computes and the exporter merely spells, which is the same division
everything else here follows. R-HSA-109606 exports 129 of them: 84 filled
triangles, 17 filled circles, 11 hollow circles, 9 hollow triangles and 8
bars -- and the 8 bars and 20 hollow heads match, one for one, the 8 red
and 20 green marks the site's own SVG draws.

**Framed exports put shapes off the slide.** Keeping a glyph that crosses
the frame is right; keeping the parts of it that fall outside is not. An
SVG has a viewBox and never draws them; a slide has nothing of the sort, so
a connector spanning the diagram arrived as an object 31 inches off the
side of an 11 inch slide. Connectors are now clipped to the frame -- per
segment, so one crossing the frame twice comes back as two runs rather than
a line joining them through the middle -- glyph boxes are clipped with
them, and an arrowhead is drawn only where the end it marks is in view.
Both fixtures now assert the invariant that broke: every shape lies inside
the extent the page declares.

Also: a text run keeps its alpha, and a filled shape with no border no
longer asks for a zero-width outline, which PowerPoint draws as a hairline
-- on a 16px arrowhead that is most of the arrowhead.

RENDER_CACHE_KEY goes to v5. v4 was published this morning and the output
has changed again; the second time in one day that a correct build served
a stale file, so the note about it now says to treat the key as part of the
change.

Not defects, though they looked like ones on the way past: the analysis
overlay is carried in full -- the colours a run adds are all exported --
and the 69 greys the SVG "draws" and the slide does not carry every one
have fill-opacity="0". The instrument that found them ignored opacity.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ed (#170)

Curators: "H2O and H+ disappear with zooming out." Separately, #150: a
molecule's chemical structure drawn with no molecule underneath it. Same
fault from two sides -- the node was hidden while a different handler
carried on drawing its structure.

Flagging pins trivial molecules visible by marking them `.always-visible`,
a class rather than an inline style precisely so nothing can lose it. But
the zoom handler writes an *inline* opacity across every `.trivial`
element, and in cytoscape an inline style beats any stylesheet rule, so it
painted straight over the class. Detaching it from the zoom event -- what
the previous fix did -- was not enough: `triggerZoom()` calls it directly,
on every restyle and whenever an interactor opens.

The class is now the authority: the handler leaves anything wearing it
alone. Measured on beta, with a flag active, opacity is 1 at every zoom and
across a restyle; with no flag, molecules still fade out and back as
before.

Also, the reason this took two attempts to land: `build:libs` did not build
reactome-cytoscape-style. CI compensated with a step of its own, so a fix
to that library passed CI and never reached the dev server, which builds
the app without building the library. `build:libs` now covers every
workspace library and `build` depends on it, so any path that produces the
app produces what it links against -- and CI's two special-case steps are
gone, because it now runs the same command a developer does.

The regression test is end-to-end rather than a unit test: the library
cannot be constructed headlessly (its layers plugin refuses), and the bug
lives in what a real renderer computes. It reads opacity through the
registration cytoscape keeps on its own container, which beats sampling
canvas pixels -- those cannot say which element was faded. Verified to
fail without the fix: both flagging tests go red, the unflagged one stays
green.

Fixes #150
Fixes #143

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…171)

Curators, re-testing #154: "the components are shown, but an indication of
which component is hit in the analysis is missing."

It was not missing. Run against a real tissue analysis -- the HPA
experiment through /ExperimentDigester, posted exactly as the Tissue form
posts it -- every component row carries a marker: 8 popups, 15 rows, none
unmarked, and the found/not-found split matches the data. So the report is
about what the panel looks like, and on that it is right.

Three things made a present indication unreadable:

- **The marker was on its own line.** The row is a column flex, so a 7px dot
  sat above the name rather than beside it, unattached to anything.
- **A hollow dot was the entire signal** for a component your data missed.
  On a row with no values beside it there was nothing else to read.
- **The values were three bare numbers.** Which tissue each belonged to was
  in a tooltip, and nobody hovers a tooltip to discover a column exists.

Now: the dot sits against the name it belongs to, with a ring that separates
found from not-found at a glance; a missed row says "not in your data" in
words; and each value is a chip carrying its sample's name.

Also fixes the emphasis that was supposed to mark the sample the diagram is
coloured by, and never once did. `state.sample` holds the column's *name* --
`samples()[index]` is what sets it -- so reading it as a number gave NaN for
every real analysis. It only appeared to work when no sample was set, where
the fallback was the first column anyway.

Verified on beta across all four states: tissue expression (chips, current
sample emphasised), overrepresentation (markers, no chips), a component the
data missed, and no analysis at all (no marker, no text).

Refs #154

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
86 links in this site's own news archive open the pathway browser with
nothing in it. They are the old browser's fragment links, and they come in
two spellings because the old site produced both:

    #/R-HSA-1430728    770 links, work
    #R-HSA-202733       41 links, blank page
    #R-HSA-8853659.1    45 links, blank page

FRAGMENT_PATTERN required the leading slash. Without one nothing matched,
so no id was extracted and the browser opened on no pathway -- from a link
in a release announcement, which is where someone arrives from a citation.

The slash is now optional and the id is required, which is what keeps a
fragment that is not a pathway -- `#introduction`, naming a section to
scroll to -- falling through untouched instead of being read as a stale
route. A trailing stIdVersion is consumed and dropped: the content service
does not want it, and it used to arrive as a query parameter called ".1".

Verified on beta: all three previously blank forms now open their pathway,
and the two that already worked are unchanged, parameters included. The
test cases are taken from what is actually in the content rather than
invented, and fail against the old pattern -- bare id and version go red,
the four that already worked stay green.

Not covered: 37 links spelling `#TOOL=AT`, the old analysis tool fragment.
They fall through as before rather than opening anything, and where they
should land is a product decision rather than a regex.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#174)

`CompareFormComponent` is the Angular CLI's generated placeholder -- eleven
lines of TypeScript, an empty stylesheet, and a template reading
`<p>compare-form works!</p>`. It has been in the tree since the initial
commit, and it renders into every pathway browser page inside a panel that
can never open: the panel is gated on `dropdown() === 'compare'`, and
nothing anywhere sets that state.

It cost me twenty minutes today. Chasing whether the toolbar's Compare
button worked, I found a component named compare-form, a dropdown state
called 'compare', and no way to reach either -- which reads like a broken
feature rather than an unfinished one. Comparing against a disease variant
is the Compare button in the toolbar, and it works: it opens a menu of the
disease variants and navigates. That is the whole feature.

So the state is narrowed to 'analysis' | null, the panel is gone, and the
scaffold with it. Verified on beta: the Compare menu still lists "Defective
Intrinsic Pathway for Apoptosis", Analyze still opens its form, no
cr-compare-form left in the DOM, no console errors.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
I called this gap minor when I documented it. It was not: 65 of this
diagram's 203 glyphs came out as empty outlined rectangles -- every
complex, every set, both genes.

Those types set `background-opacity: 0` and carry their whole body in a
`background-image`, so reading `background-color` gave nothing to fill
with. The images are SVG markup rather than rasters, so the real path is
there: it is flattened with the browser's own geometry (`getPointAtLength`
walks the arcs in a set's braces exactly), simplified, and handed over as a
closed filled polygon -- which the exporter already knew how to write, from
the arrowhead work. Nothing about Reactome's glyph vocabulary reaches it.

A complex is an octagon with a white inner line; a set is a braced box with
its drug marker in the corner. Three things had to be right and were not at
first:

- **Each image keeps its own position and size.** A set carries three --
  its body twice and a 22x24 marker at a fixed offset -- and stretching
  every one over the node drew that marker across the whole glyph.
- **`defs`, `clipPath` and `mask` hold definitions, not content.** Reading
  every element in the document drew a mask's white and black rectangles
  onto the slide and pulled the bare `<use>` inside a clipPath out as a
  shape with no paint at all: 40 of those on one diagram.
- **A masked outline is clipped to what the mask reveals.** A set's braces
  are its whole outline stroked and masked down to the two ends; ignoring
  that drew a white line all the way round it.

Simplification matters here. Walked at two units a step an octagon comes
back as 161 points, and 229 glyph layers of that put 37,000 points and 2MB
of XML on one slide; Ramer-Douglas-Peucker at 0.4 units keeps the arcs and
gives back the eight corners -- 5,000 points, and the file went from 136KB
to 54KB.

RENDER_CACHE_KEY goes to v6, and the container is rebuilt: the output
changed, so the key is part of the change.

Verified: 973 shapes and no empty-outlined boxes in the payload, the
curator-facing download through Apache carries 974, and SVG, PNG and GIF
are untouched. The three failures above each have a test taken from the
captured payload.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
An adversarial pass over #176, which had just landed. Framing was applied
by each producer as it built its shapes, and that does not hold: every
shape type added afterwards bypassed it silently.

**Arrowheads protruded past the frame.** Two of five sampled reactions did
it -- a head is drawn back from its tip and spread across the line, so a
tip just inside the edge puts part of the head outside. The gate was on the
tip alone. This is the defect #169 fixed for connectors, reintroduced by
the next shape type to be added.

**A glyph the frame cut was squashed rather than clipped.** Its body was
scaled into the node's *already clipped* box, so `CASP3(1-277) dimer` --
248 by 74 in the diagram -- arrived as a whole octagon crammed into a 20px
sliver, every corner intact. `SEPT4 dimer` came out 5px wide instead of
176. Every partially visible complex on a framed slide was a distorted
miniature of itself.

Both have one cause, so there is now one clip. Geometry is built at its
true size and position, and `clipToExtent` cuts the finished list: a node
box is intersected, an open polyline becomes the runs that survive, and a
closed polygon is clipped as a polygon -- Sutherland-Hodgman against the
frame -- so a filled glyph keeps its fill and gains an edge along the frame
rather than falling open and hollow. A shape type added next cannot forget
to be clipped, because nothing asks it to remember.

That also removed the per-producer clipping from the node and edge loops,
which is why they read shorter than they did.

Verified across five framed reactions: 0 shapes outside the extent, from 2.
A cut glyph now has points on the frame edge, which is the difference
between clipped and squashed, and the tests say so -- one fixture is
captured from a selection that used to leak, because a single frame held
the invariant by luck rather than by construction.

RENDER_CACHE_KEY goes to v7; the unframed export is byte-identical, the
framed one is not.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…#178)

* feat(downloads): say the file is being made, and never save a failure

A diagram's PowerPoint or GIF is rendered on demand and takes seconds.
Handed to the browser as `<a href download>`, that wait is silent -- no
sign anything is happening, no way to tell a slow render from a dead link.

And a failure is saved. The render service answers an id it cannot resolve
with `404 {"error":"no such pathway: R-HSA-000000"}`, and the browser
writes those 41 bytes into `R-HSA-000000.pptx`, which PowerPoint then
offers to repair. The reader is told nothing and left holding a broken
file.

So the page asks for the bytes itself: a spinner and "Preparing…" while the
file is being made, a percentage once it is arriving, and the file saved
only when the whole of it is here and the server said it was good. A
failure shows the server's own sentence -- "Could not download: no such
pathway: R-HSA-000000. Click to retry." -- and writes nothing.

"Preparing…" is a state rather than 0% on purpose. Until the file exists
the server is not sending, so a fraction would be a number we invented;
only once bytes arrive is there an honest one, and it is shown only when
Content-Length said what to expect.

It stays a real anchor. A modified click -- ctrl, middle, save-link-as --
is the reader asking the browser to handle it, and those are left alone.

Worth keeping in mind for the API rewrite: this only works because our
render service builds the whole file before it responds, so a response that
starts is one that finishes and its length is known. The Java exporters
stream while they generate, which is why a mid-render failure there yields
a truncated file that looks complete, with no size known in advance.

Specs cover the phase machine, the failure messages and the filename
parsing as pure functions -- no TestBed, no HTTP stack -- and one of them
caught `filename=""` being read as a file literally called `""`. The
end-to-end spec routes a 404 itself, so the case that matters runs in CI
whether or not a render service is up.

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

* fix(downloads): let a cross-origin deployment fetch its own downloads

An adversarial pass over the download feedback, before it merged. It would
have broken PowerPoint and GIF downloads outright on the published
artefact.

That build is served from one origin and points at beta for its backend, so
its downloads are cross-origin. An `<a href download>` does not care. A
fetch does: /RenderService sent no Access-Control-Allow-Origin, and a
cross-origin fetch of a render is blocked by the browser -- proven from a
page on another origin, where /ContentService answered 200 and the render
service failed outright. Replacing the link with a fetch would have turned
a working download into a button that does nothing.

So the render service says who may read it, as /ContentService already
does, and exposes Content-Disposition and X-Render-Cache -- without which a
cross-origin page cannot see the name the server chose or whether the
figure was already made, both of which it uses. Verified from another
origin: 200, 54224 bytes, filename readable, cache hit readable.

And the page no longer depends on that being true. A failure with a real
status is the server's answer and is shown; status 0 is not an answer at
all -- CORS, offline, an extension -- and the file is handed to the browser
instead, which is how it behaved before any of this existed. A reader gets
the download without a progress bar rather than a dead button.

Four smaller things from the same pass:

- The blob URL was revoked in the same task as the click. Chromium copes;
  other browsers cancel the save. It is revoked a minute later instead.
- The button never stopped a download when it went away. A render takes
  seconds, and a reader who moves on should not have a file dropped on a
  page they have left.
- `cached` was carried through the phase machine and never displayed.
  Removed rather than left as state nobody reads.
- The live region only existed once it had something to say, which is
  exactly when a screen reader will not announce it. It is always present
  and empty until it is not.

The last of those broke two of my own end-to-end tests, which had been
matching `.button__state` unscoped -- there is now one on every button. The
tests were reading somebody else's live region and are scoped to the
working button.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The download tab got this; the detail page's bar did not, and that is where
the waits are longest. Measured on beta:

    reaction SBML    14 KB   0.1s   no Content-Length
    reaction PDF     78 KB   1.5s   no Content-Length
    pathway SBML    807 KB   8.6s   no Content-Length
    pathway PDF     2.1 MB   8.6s   no Content-Length

Eight and a half seconds of nothing, and no length -- the content service's
exporters stream while they generate, so the browser cannot know how big
the file is, and a failure part-way through leaves a truncated file that
looks complete. Fetching in the page cannot stop the streaming, but a
stream that breaks throws, and nothing broken gets saved.

A directive rather than a component: these are plain text links in a dense
bar, so the state shows through an attribute and the stylesheet instead of
rebuilding markup around seven anchors. A spinning ring while it works, how
much has arrived beside the label, and a wavy red underline with the reason
in the title if it failed. What is shown is a size rather than a
percentage, because with no Content-Length a percentage would be invented.

Verified on beta: SBML saves 807,164 bytes of real SBML with "Preparing…"
shown throughout, and a 403 carrying an HTML error page saves *nothing* and
reads "Could not download: the server answered 403" -- the page of HTML is
deliberately not put in front of the reader.

One of the new tests would have skipped for ever. It gated on fetching an
exporter URL from the API request context, which beta's bot protection
answers with 403 -- so the gate said "not reachable" while the browser the
test runs in gets 200. It gates on the content service instead.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…is (#180)

An adversarial pass over #178 and #179.

**A request that was never answered spun for ever.** Thirty seconds into a
route that never replies, the link still said "Preparing…", and there was
nothing the reader could do: clicking again was ignored while busy, and
there is no browser download UI to cancel from, because the page is doing
the fetching. An `<a href download>` at least gave them the browser's own
cancel.

So there is a three minute ceiling -- past a genome-wide render at six
seconds, a pathway PDF at nine, and the render service's own forty-five
second limit -- and, more usefully, clicking a working download stops it.
The title says so while it works: "Being made on the server… click to
stop".

**And there were two implementations of all of this.** The download tab's
button had its own copy of the waiting, saving and failing, written before
the directive existed. They had already diverged, and every edge case in
this pass had to be considered twice. The button now uses the directive and
holds only what it looks like -- a spinner in the icon's place, the state
beside the label.

Dropped the cancel-on-destroy that #178 added, which was the wrong
instinct: a reader who clicked download asked for the file, and some of
these links sit inside a menu that closes on the click. Cancelling then
would have thrown the download away exactly when it was slow enough to
matter. I checked that case before removing it -- a five second render from
inside the PNG menu still saves, because the menu keeps its content.

Verified on beta: the tab's PPTX still reads "Preparing…" then "30%" and
saves 54,224 bytes; a hung export can be stopped by clicking it again.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#181)

Curators, re-testing #137: "no jumping in the search and the analysis table
and none in the upper section of event hierarchy, but pathways near the
bottom of the hierarchy (e.g. subevents) still jump."

They were right, and the revealing was not the cause. Rebuilding the tree
starts by emptying it -- a workaround for an Angular Material bug where
nested children otherwise do not render -- and emptying it destroys every
row, so the container collapses and the browser resets its scroll to the
top. Clicking a sub-event that was already on screen measured 274px, then
0, then 6: a jump to the top and a scroll back.

Expansion state was already carried across that rebuild. The scroll
position now is too, restored again on the next frame because the height is
not final until the restored branches have rendered and the browser clamps
a scrollTop set against a container that is still short.

Clicking a row you can already see now moves the tree 0px, from 274. A row
you cannot see is still brought into view -- 274 to 130 for one above the
fold -- because that is the half worth keeping, and both are tested.

I tried removing the workaround instead, since the bug it cites is from
2018 and it carries a "check performance issue" note. It is still needed:
without it the tree renders 29 rows instead of 41 and no branch opens.
Left in place, with what it costs written down next to it.

Verified to fail without the fix: the already-visible case goes red, the
reveal case stays green.

Fixes #137

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…e id (#182)

278 links across the news open the pathway browser with nothing in it -- 21
of them in the current release's own announcement, which is the list of
what is new in v97.

They are written the way the old browser addressed a pathway: a bare dbId
in the fragment, `/PathwayBrowser/#1280218`. #172 taught the pattern to
read `#R-HSA-…`, and these are the other half of the same legacy. The
browser already resolves a dbId given in the path, so only the fragment
form was missing.

**But it does not leave the reader on one.** A dbId is not stable across
releases, so a URL carrying one is a URL not worth keeping or sharing, and
an old link should hand over a good one. The dbId is resolved to its stable
id and that is what is navigated to: `#1280218` now lands on
`/PathwayBrowser/R-HSA-1280218`. If the lookup fails it falls back to the
dbId, which loads -- a page on a worse URL beats a link that goes nowhere,
and refusing to navigate would turn a working legacy link into a dead one.

Four digits at least, so an ordinary page anchor cannot be taken for a
dbId; the shortest one in the content is five, and there are no shorter
numeric fragments in it.

This also closes the last **gap** row in RELEASE-TESTING.md, which asked a
person to check that "a newly added pathway, reaction and complex render"
every release. The announcement names them, so
`e2e/release/new-in-this-release.spec.ts` reads the newest release note in
the repo and opens each link exactly as written -- 21 for v97, all of them
now -- and asserts each lands on a stable id. That leaves 48 auto, 8 human,
2 missing and no gaps.

Still written as dbIds in the content itself, which is a separate question:
13 curated files, and the announcements are authored through the CMS.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
An adversarial pass over #172 and #182.

**Following a legacy link trapped the reader.** From the news archive,
opening `/PathwayBrowser/#1280218` and pressing Back stayed on the pathway
-- six times over. The rewrite is the cause: the app writes state into the
URL while the fragment is still there, so `/PathwayBrowser#1280218` and
`?tab=details#1280218` both became history entries carrying it, and going
back to such an entry rewrites it forward again. Both PRs shipped this; the
`#R-HSA-…` links have had it since #172.

Two changes. A fragment being consumed is no longer carried forward by the
navigations that write state into the URL, so no entry retains it. And a
dbId is navigated on immediately rather than after its lookup -- going
first means the fragment is consumed in the same turn as a stable-id one,
before anything else can record it -- with the URL then corrected to the
stable id by a replace, which adds no entry.

Measured from /about/news: a dbId link now takes 3 Backs to leave, a
stable-id link 4, and a direct load 3. It was never.

**I had also written a resolver that already existed.** `dbIdToStId` has
been on this service all along, unused, and it is the better one: it asks
`/data/query/<dbId>/stId` and gets 13 bytes, where mine fetched the whole
object for one field -- 12,682. Mine is gone and the existing one has its
first caller.

Not fixed, and not mine: a pathway load adds two history entries of its own
before any of this, which is why leaving still takes three Backs rather
than one. Every navigation in this service pushes, including the ones that
are only normalising the URL. Worth a look, but making them replace would
also stop Back working between pathways, so it needs deciding rather than
just changing.

The escape is now a test, because it broke twice without anything noticing.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Opening a pathway cost three presses of Back to get out of, and the first
two did something nobody asked for. The app writes its own defaults into the
URL, so `?tab=info` and then `?tab=details` each became a history entry:
pressing Back stepped through tab changes the reader never made before it
would leave the page.

The URL still has to carry the tab -- a shared link has to open on the same
thing -- so the fix is not to stop writing it. Being *given* a tab now
replaces the entry; *choosing* one still adds to it. `details.component`
already knew the difference, in `tabCameFromUrl`; there was just no way to
tell the URL writer, so `settle()` is that way.

From the news archive: one press of Back leaves, where it was three. Choosing
the Molecule tab and pressing Back still returns to Details.

Found while reviewing #183, where I called this pre-existing and left it. It
was pre-existing, and it turned out to be one flag rather than the rework of
navigation semantics I assumed -- the reason I gave for not doing it was
wrong.

Both halves are tested, because keeping the second is what stops the first
turning into "Back does nothing in this app".

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…185)

* fix(routing): decide "replace or add" from the URL, not from a flag

#184 put a `settling` boolean on the service: settle() raised it, the
writer effect read it and lowered it. That only works if the writer
always runs, once, right after. It does not:

  - the writer returns early on the content and search routes, before it
    ever lowers the flag;
  - a default that already matches the current value changes no signal,
    so the writer never runs at all.

Either way the flag outlives its turn, and the reader's next write -- a
real choice -- silently replaces their history instead of adding to it.

So the writer now decides from the values. settle() records the params
the URL should say once its change has been applied; the writer replaces
only if it is about to write exactly those, and clears the record either
way. Both sides go through one currentQueryParams(), so they agree by
construction rather than by a comment.

The record is taken untracked. settle() is called from inside the
details defaulting effect, so reading every param made that effect
depend on every param: choosing the Molecule tab re-ran the default,
which set the tab straight back to details. The URL never changed and
the click did nothing -- caught by e2e/back-button.spec.ts, which now
passes both ways again.

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

* docs(hierarchy): write down why the scroll restore takes one frame

#181 restores the reader's scroll position on frame one; RevealDirective
brings a selected row into view on frame two. That order is what makes
`block: 'nearest'` compare against where the reader actually was, so a
row already on screen is left alone. Reverse it and clicking a visible
row scrolls twice again -- the bug #181 fixed.

Neither side said so, and both comments read as if their frame count
were a local choice. Noted in both, because either one being
"simplified" breaks the other.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ole URL (#186)

Both effects that write pathway browser state into the URL stood down
with `router.url.includes('content') || router.url.includes('query')`.
The query string answers that as readily as the path does.

`sample` holds a column name taken verbatim from the reader's own
expression file, and analysis.service.ts sets it automatically to the
first column. So an expression file whose first column is called
"GC content" puts `?sample=GC__content` in the URL -- and from that
moment nothing the reader did was written to the URL again. Not
selecting a node, not flagging, not changing tab. None of it survived a
reload, and none of it was in any link they shared.

Now one exported predicate, testing whole segments of the path only.
"query" is gone rather than fixed: the search page is `content/query`,
already covered by the first test, and of the two words it is the
likelier to turn up in somebody's data.

Verified by putting the substring test back: the new e2e case fails on
it and passes on this, so it is a regression test rather than a
description. The three content pages keep their own addresses either
way, which is what the guard is actually for and had no coverage at all.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…188)

The allowlist was `md|json|yml`. Every one of the 253 authored content
files is `.mdx`, because the Tina collections are format "mdx" -- so the
regex matched none of them, and the guard rejected every content edit it
exists to permit. No cms/* branch has ever merged, which is consistent
with that: the first content PR to try it failed on all 17 files.

Adds mdx. md and yml stay for content that may be authored that way
later, json is what the generated index files are.

content-dist/ is deliberately still excluded, and now says so: it is the
compiled JSON the browser reads, and stage:content rebuilds it in both
tests.yml and deploy.yml, so a content PR neither needs nor should carry
it.

Checked both directions against the 17 files of the content PR that
caught this -- all allowed -- and against code, a workflow file,
package.json, a generated content-dist JSON and a `.svg.ts` pretending
to be an upload: all still rejected. An upload image still passes.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The spec's header described the news archive as carrying 278 dbId
links, and named one case "as the release announcements write it". Once
those links are rewritten to stable ids, both statements are false and
the test looks like coverage of something that no longer exists.

It is not. Those links are a decade of citations, bookmarks and other
people's pages, and none of that can be edited -- which is the whole
reason the dbId path stays in the code. Said so, so nobody deletes the
case for being obsolete.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Every pathway link in the news archive and the research spotlights was
written the way the old browser addressed one: a bare dbId in the
fragment, `/PathwayBrowser/#1280218`. #182 made those open the right
pathway, so nothing is broken today -- but a dbId is not stable across
releases, and these announcements are a decade of citations and
bookmarks. A dbId that moves takes an old announcement's link with it,
silently.

316 links across 17 files, 229 distinct ids. Every one was resolved
against the ContentService first and every one answered; all 229 are
pathways (205 Pathway, 14 CellLineagePath, 10 TopLevelPathway), so there
is no id here that the fragment handler would open as something it is
not. Nothing that failed to resolve would have been rewritten -- nothing
did.

Only the fragment of a PathwayBrowser link is touched. A number anywhere
else in the prose is left as the curator wrote it, and each link keeps
the spelling it had, with the slash or without. 38 changed line pairs,
and none of them differs anywhere but the fragment.

The dbId path stays in the code and stays tested: links in the wild
cannot be edited, only ours.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Two findings from reviewing #187-#189.

**The CMS guard could be walked around by a rename.** `git diff
--name-only` prints only a rename's destination, so on a cms/* branch
`git mv package.json projects/website-angular/content/sneaky.mdx` showed
one allowed path and the deletion of package.json was invisible.
--no-renames prints both, and the source is judged on its own; proved
both ways on a scratch repo. Pre-existing, not something #188
introduced -- #188 only changed the extension list.

**Numbers in two doc comments were wrong.** They said 770 links spelled
`#/R-HSA-…` and 86 spelled `#R-HSA-…`. Measured against the content as
it stood before the rewrite: 638 and 70. Repo-wide it is 1276 and 140,
unique links 431 and 66; 770 and 86 reproduce at no scope. The dbId
figure was right but mislabelled -- 278 is the no-slash spelling alone,
and there were 285 in total.

Corrected per spelling, and #189's comment now says what the old numbers
were, so nobody re-derives them from the same bad source. The `24 in the
current release's announcement` claim checks out exactly.

Also notes in FRAGMENT_PATTERN that our own content no longer sends
dbIds here, so the traffic that still arrives is external and the
pattern has to stay.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The CLI was already installed; the repo had never been initialised, so
none of the workflow was available. `specify init --here --integration
claude` adds the nine speckit skills and the templates, scripts and
workflow under .specify.

The constitution is filled in rather than left as placeholders, because
a placeholder constitution steers every later spec into nothing. Its
five principles are the ones this project has actually paid for:

  I.   verify the instrument -- a "186 broken links" crawl that was our
       own rate limiting, a readiness probe that would have skipped its
       test forever, a scroll measured on the wrong container
  II.  measure in the running app -- a legacy fragment mapping that
       passed a typecheck and 19 unit tests and failed in a browser
       twice, for two different reasons
  III. prove a test fails first -- otherwise it describes the fix rather
       than guarding it
  IV.  never leave a reader on a dbId
  V.   comments carry the failure, and any number in one has to be
       measurable -- two of ours claimed counts that reproduce at no
       scope

Plus the gates, the two ratcheted baselines, and the workflow facts that
have each cost a retraction this week: verify a commit by its merge
commit rather than its branch, `gh pr update-branch` because protection
is strict, and never run concurrent git operations in this tree.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* chore: adopt Spec Kit, with a constitution that says what it cost

The CLI was already installed; the repo had never been initialised, so
none of the workflow was available. `specify init --here --integration
claude` adds the nine speckit skills and the templates, scripts and
workflow under .specify.

The constitution is filled in rather than left as placeholders, because
a placeholder constitution steers every later spec into nothing. Its
five principles are the ones this project has actually paid for:

  I.   verify the instrument -- a "186 broken links" crawl that was our
       own rate limiting, a readiness probe that would have skipped its
       test forever, a scroll measured on the wrong container
  II.  measure in the running app -- a legacy fragment mapping that
       passed a typecheck and 19 unit tests and failed in a browser
       twice, for two different reasons
  III. prove a test fails first -- otherwise it describes the fix rather
       than guarding it
  IV.  never leave a reader on a dbId
  V.   comments carry the failure, and any number in one has to be
       measurable -- two of ours claimed counts that reproduce at no
       scope

Plus the gates, the two ratcheted baselines, and the workflow facts that
have each cost a retraction this week: verify a commit by its merge
commit rather than its branch, `gh pr update-branch` because protection
is strict, and never run concurrent git operations in this tree.

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

* spec(interactors): confidence filtering and download

The first spec written with Spec Kit, for the two rows of the curator
release checklist that cannot be signed off because the behaviour does
not exist here: RELEASE-TESTING.md:117 (raising the confidence threshold
reduces the interactors shown) and :118 (interactor download).

Three prioritised stories: filter the interactors on the diagram (P1,
the blocked row), keep and share the filtered view (P2), download the
filtered set (P3). Each is independently testable and independently
useful.

Two numbers in it are measured rather than estimated, and both are
re-measurable:

  - scores run 0-1, sampled at 0.482-0.98 across the 33 interactions of
    Q13158, from /interactors/static/molecule/{acc}/details
  - the old browser's default threshold is 0.45, and thresholds are held
    *per resource* -- DEFAULT_SCORE and Map<String, Double>
    interactorsThreshold in reactome/pwp-diagram,
    data/InteractorsContent.java

The second of those was the spec's only open question. Reading the GWT
source answered it and also corrected an assumption the first draft had
made: it assumed one global threshold, reasoning from there being a
single control beneath the diagram. It is per resource, so switching
resource applies that resource's own threshold -- a score from IntAct
does not mean what a score from another resource means. FR-004a and the
resource entity came out of that.

The default applies without being written into the address: a curator's
address should name the threshold they chose, not one nobody chose. That
is the same rule #185 established for the details tab.

Format of the download is left to /speckit-plan. It is constrained by
how the site's other tabular exports behave, and is not something a
curator would state as a requirement.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
47 links in this site's own content opened an empty pathway browser, and
always had. The fragment pattern required a pathway id, so a fragment
carrying only settings matched nothing and was read as a page anchor:

  - `#TOOL=AT` (45 of them) is the "Analysis Tools" link in every
    release announcement we have ever published, the current one
    included. It now becomes `?analysisTab=qualitative`, which is the
    tool's first tab and its fallback -- the gene-list upload the old
    link led to.
  - `#DIAGRAM=<dbId>&PATH=<dbId>` (2, in the v64 announcement) names the
    pathway to open, so DIAGRAM is the route rather than a setting. It
    is only ever a dbId, which the existing resolution then swaps for
    the stable id, so the reader is not left holding one.

The id is now optional, but only when what follows is one of the old
browser's own keys, so `#introduction` still falls through untouched.
Both halves being optional means the pattern would otherwise match an
empty string -- and an absent fragment reads as '', which would have
made carriesLegacyPathway() answer true for every URL in the app. A
lookahead stops that, and a test holds it.

TOOL is rewritten into the URL rather than carried as a legacy token or
set on the signal, because neither survives: the writer replaces the
whole query string when any state settles, dropping `?TOOL=AT` before
the reader saw it, and the reader resets every param the URL does not
mention, so a signal write is undone in the same turn.

Also corrects `otherTransform`'s type. It was `(value: T) => T` and
receives a raw string off the query string; that was true of no caller
and went unnoticed only because `tab` is a `string | null`, so the lie
typechecked. A param with a narrower type could not have used it.

The spec asserted the old behaviour for TOOL=AT, reasoning that matching
it would make "a route to nowhere and a junk query parameter". True
while nothing mapped TOOL; analysisTab does now, so that case moved
rather than being deleted.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`says it is working during the wait` polled the DOM every 150ms against
the real content service and asserted it had caught a transient label.
It failed that way on main on 2026-09-09 -- run 34397628182, three
attempts, each reporting `states seen:` with nothing after it -- and it
has now blocked #191, which does not touch downloads.

Note what that message means: the sampler collected nothing at all. The
file still arrived and still contained `<sbml`, so the export worked. I
cannot say from the log whether the export outran the sampling window or
the busy state never appeared, and this change does not settle that
question -- it removes the test's dependence on timing so the question
stops mattering.

The export is now served by the test and held for 2.5s, so the busy
state is certain to exist while it is asserted. The assertion is on
`data-download-state`, the label the reader actually sees, rather than a
class that merely implies it, and it retries instead of sampling. It
also now checks the label goes away once the file has arrived, which the
sampling version could not express.

Ran three times locally: 6 passed each time, and the case is now 7-8s
rather than however long the real export happens to take.

The content-service gate stays, because the detail page still needs it
even though the export no longer does.

`e2e/flagging-trivial.spec.ts:98` and `:102` failed in that same main
run and are not touched here.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`fade with zoom when nothing is flagged` zoomed once, waited 400ms and
read once. It failed on main on 2026-09-09 -- run 34397628182 -- with
`visible close up` receiving [0] where it expected [1].

The cause was reproduced rather than inferred. Immediately after
zoomTo(1.5) the diagram reads zoom 1.5 and opacity [1]; forcing a fit()
straight afterwards drops it to zoom 0.113 and opacity [0], which is
exactly what CI reported. On a slow runner the diagram is still settling
when the test zooms, its own fit() lands after, and the opacity read
belongs to a zoom level nobody asked for.

An opacity only means something alongside the zoom it was measured at,
so the zoom is now checked in the same evaluate that reads the opacity,
and a reading taken at the wrong zoom is discarded rather than asserted
on. The assertion retries, so it converges as soon as the diagram stops
re-fitting.

Proved both directions by injecting a fit() 600ms into the test, which
is what the slow runner does by accident: the old read returns [0] and
fails, the new one passes.

Only this test needed it. The two flagged cases expect [1] at every
zoom, so a stray re-fit cannot falsify them -- they read directly, and
now say why.

Ran the spec three times: 3 passed each time.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#195)

247 files under projects/website-angular/content-dist were committed,
despite .gitignore:76 ignoring that directory -- the rule never applied
because they were already tracked. tests.yml calls content-dist "a
gitignored asset input", so the intent was always for it to be
generated; the tracked copies were the accident.

They stopped being harmless when #187 rewrote the content's links to
stable ids without touching content-dist. From then on the repo held two
disagreeing copies of every news announcement, and every `npm run build`
dirtied 16 files. That is not theoretical: `git add -A` swept them into
the commit for #194, lint-staged failed on the result, and the commit
was lost.

Untracking alone would have broken a cold local `npx playwright test`,
because playwright.config.ts boots `npm run start:simple` and that was
bare `ng serve` -- no content to serve. So start:simple now runs
generate:indices and stage:content first, which is also what README
already claims it does ("same as npm start, skipping TinaCMS"): it
skipped the content generation too, so the documentation was describing
a script that did not exist. start:simple:local had the same defect and
gets the same fix.

CI needed no change -- the e2e job already runs both steps, and so does
the deploy workflow.

Verified by deleting content-dist entirely and regenerating it: 253
pages to 256 JSON files, now carrying the stable-id links that the
tracked copies still had as dbIds.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* chore: point the docs at the scripts, and fix the last serve that skipped content

Adversarial review of the untracking commit, which found three things it
had missed.

**README told people to run a command that will now break.** It called
`ng serve -c curator` "the quickest way to check a clone works at all".
That is a bare ng serve: it compiles no content, so on a fresh clone it
serves nothing. `npm run start:curator` already does it properly, and
the table now says so.

**`start:local` compiled indices but not content.** It ran
generate:indices and stopped, so it was already half-broken and
untracking would have finished the job. Every start:* and serve:* script
now compiles content; audited rather than eyeballed.

**A bare `ng build` does not fail on the missing directory -- it
succeeds.** The asset entry is a glob over content-dist, so an absent
directory copies nothing and the build completes. My first draft of this
note claimed it "fails on a missing asset". It does not, and the truth
is worse: you get a site whose every content page is empty with nothing
in the log to say why. Checked by moving the directory aside and running
a production build, which finished normally -- and which is how I found
it, having done exactly that to my own checkout.

That last point is also worth stating plainly for anyone switching
branches: git deletes these files when you move to a branch that no
longer tracks them, because they were tracked in the branch you came
from. Any start:* or build puts them back.

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

* test(flagging): do not let an unmeasured diagram look like a faded one

Review of the previous commit. The `faint far out` poll returned null
when the zoom had not held, and asserted toBeLessThan(1) on it. In
JavaScript `null < 1` is true, so a reading that could never be taken
would have described an unmeasured diagram as a faded one -- the poll
would stop and the test would pass having measured nothing.

It does not, as it happens: Playwright's toBeLessThan throws on a null
rather than comparing it, so the poll retries. Checked, not assumed.

Returning Infinity anyway. The test should not depend on how a matcher
treats a value it was never meant to receive, and the intent belongs at
the point the value is produced.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The branch was 137 commits behind main and predates the SITE_PROFILES
refactor, so its environments.ts was the old shape. A toggle written
against that would have had to be written again, which is why the update
comes first.

Two conflicts, both in viewport, both the same shape: main removed the
compare-form scaffold (#174) while this branch added the DeltaSignal
panel beside it. Kept both decisions -- compare gone, DeltaSignal
staying -- and folded the two dropdown states into one signal.

**The flag.** The Perturb button and the panel rendered unconditionally,
so DeltaSignal would have appeared on production, beta and the curator
site alike. It is now a per-deployment profile field:

  SiteProfile.deltaSignal?: boolean   -> SHOW_DELTASIGNAL
                                      -> viewport.showDeltaSignal
                                      -> @if around the button and panel

Absent means off, so a new 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. Turning it on anywhere else would offer a button whose every
call fails.

A feature toggle, not a variant. The standing note in environments.ts
says new deployments should compare SITE_VARIANT rather than grow a
second boolean; that is about which UI a deployment presents, which is a
different question from which features it enables, and the field says so.

Verified in a browser rather than by reading the flag, using the
__APP_ENV runtime override the config already reads, so one build proves
both states: the button is absent under the default profile and present
under `development`. e2e/deltasignal-toggle.spec.ts holds it.

The first version of that test asserted /^Perturb$/ and found nothing,
which made a working flag look broken -- the button renders a mat-icon
first and the ligature is text, so its content is "conversion_path
Perturb". The comment says so.

Two unit tests as well, and the profile one was shown to fail: turning
the flag on for beta reports `beta.deltaSignal: expected true to be
false`.

210 unit tests, check:types, check:dead at baseline, lint 653, prettier
clean.

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