Skip to content

Latest commit

 

History

512 Commits

Folders and files

Repository files navigation

react-cheminfo

NPM version build status Test coverage npm download

Assembled React components shared by the *.cheminfo.org sites.

What it is

react-science gives us primitives — Toolbar, Accordion, SplitPane, Button. This package is the layer above: assemblies, a component you hand data to and it delivers a whole feature, its copy, its interactions and its logic included. A site normally depends on both.

Installation

npm i react-cheminfo

react and react-dom (19 or later), @blueprintjs/core and react-science are peer dependencies: a site already has them, and a second copy of react-science would mean two Blueprint style trees in one page.

Entry points

Subpaths are bundle boundaries, not taxonomy: a feature gets its own door only when it drags a heavy dependency behind it.

Import Holds Costs
react-cheminfo/core every framework-free helper — 140 value exports nothing
react-cheminfo/formula formulas: read, compared, drawn and copied mf-parser, react-mf; React on the components
react-cheminfo/ui every React component and hook — 60 value exports React
react-cheminfo/vite the prerender plugin and the OG card nothing; React on the card
react-cheminfo/pages the Cloudflare Pages analytics middleware nothing
react-cheminfo/server the prerendered build, served from Node fastify, @fastify/static; Node only
react-cheminfo/card the card an answer is shown as in a chat React; cheminfo-mcp-server, an optional peer
react-cheminfo/orbital the 3D atomic-orbital viewer React, molstar
react-cheminfo/molecule3d the 3D molecule viewer and its toolbar React, molstar
react-cheminfo/structure the structure editor and renderer React, react-ocl, OCL
react-cheminfo/conformers conformer generation, ranking and refinement OCL
react-cheminfo/slides the deck format and the slideshow player React, react-markdown
react-cheminfo/spectra the spectra processing settings editors React; types only from spectra-processor, ml-pca
react-cheminfo/ir the infrared chart, band table and editor React, ir-spectrum, convert-to-jcamp
react-cheminfo/raman the Raman editor: the infrared one, read for Raman the same as react-cheminfo/ir
react-cheminfo/uvvis the UV-vis and fluorescence editors, read off the infrared one the same, and uv-spectrum once a file is read
react-cheminfo/mass the mass spectrum chart, peak table and editor React, ms-spectrum, jcampconverter, xy-parser
react-cheminfo/xps the XPS chart, peak, fit and composition tables, editor React, xps-analysis, ml-gsd, xy-parser
react-cheminfo/xtb GFN2-xTB as a geometry relaxer nothing on import; xtb-wasm on the first call
react-cheminfo/host data handed over by a page that frames the tool nothing on import; ajv once the page is framed
react-cheminfo/demo a tool playing a steps slide's demo, with a cursor nothing; loaded only on a page with ?demo=
react-cheminfo/host-testing a stand-in host and SciPeaks' messages, for e2e tests nothing; Node only
react-cheminfo/playwright the browser-suite configuration every site shares nothing; types from @playwright/test
react-cheminfo/storage work kept in the browser, workspaces, documents React on the backup menu; idb, @zip.js/zip.js
react-cheminfo/drawing the drawing editors' plane, pages, captions, pickers mf-parser; React and Blueprint on the components
react-cheminfo/caption-editor the box a drawing's caption is typed in, and its toolbar prosemirror; React and Blueprint
react-cheminfo/styles/chrome.css the shared tokens and site-header stylesheet nothing
react-cheminfo/styles/slides.css the deck stylesheet, themed on the site's colours nothing
react-cheminfo/styles/card.css the card’s stylesheet, on the family’s tokens nothing
react-cheminfo/styles/demo.css the demo cursor, on the family's tokens nothing
react-cheminfo/styles/drawing.css the handles a drawing's curves are edited by nothing
react-cheminfo/styles/caption-editor.css the caption editor and its toolbar nothing

A backend serving an RIS endpoint, a prerender script writing a sitemap, and every unit test of that logic therefore load no React at all — and a worker sampling an orbital loads neither React nor molstar. Every peer is optional, so a site that only wants the Tools menu downloads none of them, and a project that only prerenders installs neither React nor Vite. The Markdown stack of the slides (react-markdown, rehype-raw, rehype-sanitize, about 9 MB installed) is a regular dependency instead: it is small enough to always install, and a site that never imports react-cheminfo/slides bundles none of it. The same holds for openchemlib, which every function of react-cheminfo/conformers runs at module scope: a site that never imports that door bundles none of it.

react-cheminfo/ir is a viewer rather than a helper, and it is a door for the usual reason: reading a spectrum drags the formats one may arrive in — ir-spectrum for the band correlations and the absorbance/transmittance pair, convert-to-jcamp for writing them back out, and a JCAMP or SPC reader fetched only once the bytes turn out to need it. react-cheminfo/raman opens the same viewer for Raman spectra — one value axis, the Raman shift running left to right, the bands picked on intensity and no correlation table — and react-cheminfo/uvvis opens it for UV-vis spectra, read by uv-spectrum over their wavelengths in nanometres, absorbance first, with every λmax picked and no correlation table, and for fluorescence spectra, the intensity emitted over the same wavelengths with every emission maximum picked; loadUvVisSpectra says which of the two a file holds. So a page imports each technique's viewer by name while all four share one shell. The chart it is drawn with is not behind that door: scales, ticks, plot geometry, the zoom gestures, the label placement and the panel chrome are in core and ui, because a spectrum of any kind is drawn with them and the next viewer built on them should find them already there rather than reach through an infrared package for them.

react-cheminfo/mass is the infrared viewer's sibling for mass spectra: the same shell, the same three buckets of state, the same chart underneath, and neither imports the other. It reads JCAMP-DX through jcampconverter — the same release ir-spectrum reads through — and two columns of text through xy-parser, each reader fetched only once the bytes turn out to need it. A profile is peak picked by ms-spectrum as it is read, and every spectrum is judged high or low resolution on the strongest evidence it carries: the resolving power measured on its peak widths, else the instrument its file names, else the decimals its masses were written with — which then decides how many decimals every mass is written with. The peaks of the selected spectrum are labelled as the chart is zoomed.

react-cheminfo/xps is the third of the spectrum viewers, for X-ray photoelectron spectra: the same shell, the same three buckets of state, the same chart underneath, and none of the three imports another. It reads VAMAS and JCAMP-DX through xps-analysis and two columns of text through xy-parser, each reader fetched only once the bytes turn out to need it, and picks peaks with ml-gsd. Binding energy is drawn decreasing from left to right as a reversed axis, never as reversed data, so every search, integration and peak pick keeps an ascending x. A fit is the file's: the components, regions and sensitivity factors CasaXPS wrote are drawn and quantified as written, and a peak is never mistaken for a component.

react-cheminfo/xtb is the door a page opens only when a visitor asks for a better number. refineConformers in react-cheminfo/conformers orchestrates a refinement against the framework-free GeometryRelaxer contract — which react-cheminfo/core owns, so the code choosing a relaxer loads neither openchemlib nor WebAssembly; xtbRelaxer from this subpath is the implementation, and it import()s xtb-wasm on the first call, so a site that offers refinement as an action still ships nothing extra to a visitor who never clicks it.

react-cheminfo/storage is what a site needs so a crash loses nothing: openSiteDatabase opens IndexedDB through idb and turns a blocked upgrade or a browser refusing storage into an error rather than a hang; createAutosave writes a changing value at most every two seconds, never two writes at once, and at once when the page is hidden; createTabClaims says which tab holds which piece of work through Web Locks, so a tab that crashed lets go by itself; createRestoreGuard stops a page reopening, on every load, the work that took it down; and writeArchive / readArchive with <BackupMenu> or useBackupRestore take everything out as one file and back in.

It also keeps a site's preferences as workspaces: createWorkspaces fills the site's tree of preference signals from the workspace last used, keeps every change at once — a localStorage journal, then IndexedDB — and offers the site's own workspaces beside the visitor's, saved, renamed, reset and carried as a .<site>-workspace.json file. <SettingsDialog> from react-cheminfo/ui is their dialog: the workspace at the top, vertical sections, and Cancel, Apply and Apply and save, each change shown on the page as it is set; <PanelStatusTable> sets each panel hidden, available, folded or open. A panel with settings of its own turns to them with <PanelSettingsButton> and usePanelSettings, on the same terms: the gear gives way to a green tick that keeps the changes and a red cross that puts them back. A site's document is writeSiteDocument / readSiteDocument: one .<site>.zip holding its data files as they open anywhere, and, when asked, the view and the workspace, which <SaveDocumentDialog> offers.

react-cheminfo/vite is the one door with a split bill: cheminfoPrerender needs nothing beyond Vite's own plugin type, while ogCardHtml renders a mark and therefore loads React — but on the call, not on the import, so the plugin stays reachable from a checkout that has neither.

The stylesheet ships from styles/, which tsc leaves alone, and is reached through a wildcard subpath exactly as react-science serves its own.

What is in it

Area …/core …/ui
Site identity siteById, findSiteByHost, siteTokensCss SiteMark, Wordmark, SiteTheme, SiteTile, EcosystemButton, EcosystemLinks
Chrome — SiteHeader, SiteFooter, NavLink, NavMenuButton, MenuButton, AccountButton, useCompactHeader
An empty tool — EmptyGuide, EmptyGuideSection, ShortcutList, ShortcutKeys
Waiting for data — DataDropZone
Languages LANGUAGES, DEFAULT_LANGUAGE, LANGUAGE_LABELS, isLanguage, MessageCatalog, CHROME_CATALOG, setMessageSession, loadCatalogs, startTranslateMode, LANGUAGE_PARAM, withLanguageParam useT, useChromeT, useLanguage, LanguageSelect, SiteLanguage
Citation formatCitation, formatCitations, citationSegments, downloadCitation, citedReferences, doiUrl, referenceVenue CiteButton, CitationMenu, CitationPreview
Contact CONTACT_FORM_URLS, contactFormUrl, contactUrl ContactButton
Share & embed parseShareConfig, applyShareConfig, buildShareUrl, buildEmbedCode, isHidden, visibleShareParts, applySharePreset, findSharePreset, the param codecs, syncPreferencesWithUrl, preferenceEffect, pinnedPreferenceSearch, applyPreferencesFromSearch, writePreferencesToSearch, sealed links: openSealedAddress, sealShareUrl, sealQuery, unsealQuery ShareDialog, ShareButton, HiddenPartsProvider, PagePart, useIsHidden, SealedLinkRefused
Routing & head createTabRouter, readRoute, writeRoute, subscribeToRoute, adoptLegacyHashAddress, writeDocumentMeta, pageLocation, pageHistory useTabRoute
Indexing injectPageMeta, pageHeadTags, pageDocumentMeta, fill, PAGE_HEAD_MARKER, PAGE_BODY_MARKER, robotsTxt, sitemapXml, noscriptIndex, structuredDataScript, assertRoutes, routeFor, homeRoute cheminfoPrerender, ogCardHtml, OG_WIDTH, OG_HEIGHT (all /vite)
Pedagogy parseGlossaryMarkers, localStorageProgressStore, progressSummary, finishValidation GlossaryText, SyntaxTooltip, HintLadder, ExerciseActions, ExerciseProgressHeader, TutorialStepStrip, ReferenceGrid, TestCaseList
Graded series pickSeeded, freshSeed, parseSeed, seedParam, createAttemptStore, createProgressController, readAnswerNumber, emptyCase, unreadableCase ExerciseSeries, QuestionCard, AnswerFields, ExerciseList, useAttemptRecords, PracticePage, CalculatorTable (with styles/practice.css)
Formulas readFormula, normalizeFormula, canonicalFormula, atomCounts, formulaHtml, formulaText (all /formula) CopyableFormula, QuestionFormula, QuestionCompound, FormulaInput (all /formula)
Site shell pageContentOf; the pages option of cheminfoPrerender (/vite) SiteShell, ShareControl
Properties copyableText PropertyTable
Clipboard & files writeToClipboard, downloadBlob, downloadText, sanitizeFileName, toDelimited, readDelimited, readXYTable, downloadFigure, figureSvg, figurePng, cropFigure, figureSize, dpiScale, withPngDpi ClickToCopy, CopyButton, CopyableValue, CodeBlock, DelimitedTextDialog, FigureDownload
Formatting & colour formatInteger, formatDecimal, formatBytes, pluralize, readableInk, contrastRatio, COLOR_SCALES, resolveColorScale, formatColorScale, colorAt, swatchAt, sampleScale, colorScaleGradient, rgbToHsv ColorScaleLegend, ColorScaleSelect, ColorScaleEditor, ColorScaleBar
Number entry readNumber, numberText, isPartialNumber, stepNumber, snapToStep NumberInput
Linked highlight hoverHighlight, selectHighlight, highlightStateOf, highlightMatches, sameHighlightKey highlightTarget; the dimmed flag of a TrackedLineChart series
Range filter isRangeBounded, isRangeDomain, rangeHandles, movedRange, settleHandles, sameRange, rangeHistogramPath, OPEN_RANGE RangeSlider
Widgets CREDITS, credits ErrorBoundary, CollapsibleSection, CapsuleFilter, HelpTooltip, CreditsList
Hooks & state createWorkerChannel, persistBucket, persistSignalBucket useDebouncedValue, useContainerSize, useListKeyboardNavigation, useDisclosure
Requests fetchJson, ApiError, readApiError, keySetParam, readHiddenParts, formatHiddenParts, fitDescription, DESCRIPTION_MIN, DESCRIPTION_MAX, openingRoute, anchorHref useAsync (signal, pending)
Chemistry atomicOrbitalsOf, configurationOf, classifyMolfile, readStructure, toMolfileExport AtomicOrbitalViewer (/orbital), StructureEditor, Structure, MoleculeExport (/structure)
Conformers generateConformers, continueConformers, refineConformers, conformerShape, isSameMinimum, rankByEnergy, minimiseConformer, DEFAULT_CONFORMER_OPTIONS, CONFORMER_STRATEGIES (all /conformers); GeometryRelaxer, readRelaxableGeometry, centredRmsd —
Spectrum charts chartScale, chartAxisScale, plotRect, zoomDomain, zoomSelection, labelStackBaselines, layoutAnnotationBoxes, profilePath, stickPath, guidePlacement, readoutPlacement, stackCapacity, nextSpectrumColor, panelStyles, statusBar Axes, AxisGuide, ChartTracker, ChartStack, SelectionRect, TraceLegend, PlotCaption, useChartZoom, useChartPointer, useChartSize, SidePanelStack, PanelRail, PanelRow, Section, FilterInput, AboutDialogFrame, DocumentationDialogFrame, FigureDownload, useFigureDownload
Infrared and Raman loadIrSpectra, toIrSpectrum, pickBands, pickBandAt, editBandsAt, bandsFromIrPeaks, irPeaksFromBands, bandsFromRamanPeaks, ramanPeaksFromBands, bandsFromUvPeaks, uvPeaksFromBands, loadUvVisSpectra, assignBands, BAND_ASSIGNMENTS, irSpectrumToJcamp, IR_MODE_RULES, IR_TECHNIQUE_RULES, nearestBandAt, planBandLabels, irReducer (all /ir) IrEditor, IrChart, BandsPanel, SpectraPanel, MetadataPanel, useIrEditor (all /ir); RamanEditor (/raman); UvVisEditor, FluorescenceEditor (/uvvis)
Mass spectra loadMassSpectra, toMassSpectra, analyseSpectrum, decideResolution, resolutionOf, mzDecimalsOf, planPeakLabels, peakTableRows, massSpectrumToJcamp, massReducer (all /mass) MassEditor, MassChart, SpectraPanel, PeaksPanel, MetadataPanel, useMassEditor (all /mass)
X-ray photoelectron loadSpectra, detectFormat, readAnalysisBlocks, pickPeaks, assignElements, referenceLevels, applyBackground, shirleyBackground, tougaardBackground, integrateWindow, quantifyComponents, toBindingEnergy, toKineticEnergy, xpsReducer (all /xps) XpsEditor, XpsChart, SpectraPanel, PeaksPanel, ElementsPanel, ComponentsPanel, CompositionPanel, useXpsEditor (all /xps)
Spectra FILTER_CATALOG, filterMenu, settingsProblems, normalizationFilters, addFilter, moveFilter, setFilterOption, principalComponentChoices, clampPrincipalComponents, EMPTY_SETTINGS (all /spectra) SpectraSettingsEditor, FilterChainEditor, PrincipalComponentSelect (all /spectra)
About resolveAbout, aboutProblems AboutPage
Which build is running formatBuiltAt, shortCommit, BuildInfo AboutVersion, cheminfoBuildInfo (/vite)
Slideshows parseTalk, splitDemoLinks, slideActionForKey, parseTalkOrigin, buildTalkManifest (all /slides) Slideshow, SlideView, TalkList, BackToSlides (all /slides), cheminfoTalks (/vite)
Token guard findTokenViolations cheminfo-check-tokens, cheminfo-check-messages, cheminfo-check-deploy (the bins)
Figures chartScale, chartAxisScale, chartAxisTitle, chartShare, chartColumnExtent, chartBinCounts, chartSeriesColor, rowMatrix, stackedMatrix, emptiestCorner, placeOverlayCard, overlayMetrics ChartFrame, ChartAxis, TrackedLineChart, OverlayBar, OverlaySelect, OverlaySegmented, OverlayToggle, OverlayNumber, OverlayLegend, OverlayCaption, OverlayReadout
Projections pcaResult, embeddingResult, projectionTabs, loadingProfiles, explainedShares, confidenceEllipse, projectEllipse, pointsInPolygon, resolveProjectionGroups, resolveProjectionShapes, PROJECTION_COPY PcaViewer, ProjectionViewer, ScatterPlot, ScatterMatrix
Parallel coordinates parallelAxisOf, parallelAxisLayouts, parallelIncludedMask, parallelKeptCount, parallelNearestRow, parallelSegmentAt, parallelBandAt, parallelRangeOf, parallelPalette, parallelColorSteps, paintParallelLines ParallelCoordinates
Periodic table PERIODIC_ELEMENTS, elementBySymbol, elementByAtomicNumber, cellOf, placedElements, elementByArrowKey, tableGeometry, EMPTY_BLOCK, categorySwatch, CATEGORY_LABELS PeriodicTable, ElementCell, CategoryLegend, periodicTableMinWidth
Taxonomy TAXON_RANKS, PRINCIPAL_RANKS, rankOrder, isPrincipalRank, isGenusOrBelow, isAboveGenus, principalLineage, lineageFromNodes, lineageFromRanks, genusOrBelow, ncbiTaxonomyUrl, splitOrganismName TaxonLineage, OrganismName, useRankLabel
Framed by a host hostStructureLoader, readHostMolecule (/structure, which carries OpenChemLib, so /host does not); STRUCTURE_KINDS, IR_JDX_SCHEMA, IR_PEAKS_SCHEMA, RAMAN_JDX_SCHEMA, RAMAN_PEAKS_SCHEMA, BAND_STRENGTHS, connectHost, selectInputs, checkValue, kindAccepts, parseRequest, OUTPUT_DELAY (all /host); startStandInHost, sendStandInHostRequest, SCIPEAKS_ETHYLBENZENE_LOAD, sciPeaksEthylbenzeneIrLoad, sciPeaksEthylbenzeneRamanLoad (/host-testing) useHostConnection (/host)
Answer cards CARD_PARAM, ONLY_PARAM, isCardAddress, parseOnlyParam, sitePageUrl, cardPageUrl, framedPageUrl, roomForPanels; answerAddresses, joinAddress, viewOrigins, cardMeta, pageAnswer, siteView (all /server) CardFrame, CardDetailsButton, CardStatus, CardMissing, CardFigure, CopyTableButton, fitCardToFrame, useFitCardToFrame, useOverlayRoom, holdRoomForPanels (all /card); StructureExportButton, useStructureDraft (/structure)

Everything in that table is exported from ./core, ./ui, ./slides, ./vite or ./structure and nothing else is: the sub-components a component is built from, the parsers a helper calls and the internals of a hook stay inside the package, reachable only by their own path. If it is exported, it is supported.

The rule for a new site is short: react-cheminfo first, react-science second, your own code last. The full import table and the checklist live in websites/CLAUDE.md.

Seeing it

Storybook is the demo, and every exported component has one — 287 stories:

npm run dev              # the book on http://localhost:10815
npm run test-e2e         # Playwright opens all 287 and fails on any console error

The Brand toolbar at the top retunes --brand / --brand-alt, so any story can be read as it would look on any site of the family.

The sources are organised the other way round — one folder per feature (src/citation, src/ecosystem, src/share, src/seo, src/pedagogy, …), each holding a core/ and a ui/ half — and src/core.ts, src/ui.ts, src/vite.ts, src/structure.ts and src/orbital.ts are the barrels the entry points point at. ESLint forbids a core/ folder from importing react, react-dom or anything under a ui/, which is what keeps the framework-free entry points honest; src/seo/vite is the one half named for its consumer rather than for a framework, because a build plugin is neither. src/shared holds what more than one of them is built on: MenuButton, the shape every button of a site header takes, which is why CiteButton and EcosystemButton differ only in their glyph and their menu, and why both accept the compact and placement props of the exported HeaderButtonProps.

Components

CiteButton

The Cite entry of a site header: one button opening the work at its DOI, copying its reference in the style a journal asks for, and saving the files a reference manager imports.

import { CiteButton } from 'react-cheminfo/ui';

<CiteButton reference={PAPER} />;
  • HTML and Markdown, each in the four styles chemists are asked for — ACS, Nature, RSC and Wiley. An HTML copy is written to the clipboard in both flavours, so Word and Google Docs keep the emphasis while a plain editor receives a clean line; that is why plain text is not a separate entry.
  • BibTeX, RIS and the DOI link, which carry no style.
  • RIS and BibTeX files, served with the MIME types Zotero, Mendeley and EndNote recognise, so opening the saved file imports it.
  • A hover preview of exactly what each entry copies or saves.
  • compact drops the text and the caret, leaving the icon alone to open the menu, for a header that has run out of room. The label stays what the pointer and a screen reader are told.

Adding a style means one function in citation/core/segments.ts and one entry in CITATION_STYLES — the three output formats and the preview follow.

A site is often built on more than one work — the tool it wraps, and the platform it runs on — and a reader handed two references has to be told what is what. Pass works rather than reference, each one carrying the words that say what citing it credits:

<CiteButton
  works={[
    {
      reference: SURGE_PAPER,
      what: 'The isomer generator',
      note: 'Cite it for the enumeration: every structure here comes from surge.',
    },
    {
      reference: PLATFORM_PAPER,
      what: 'The browser platform',
      note: 'Cite it for the site itself.',
    },
  ]}
/>

The menu then opens on a line asking for all of them — guidance writes that line when the default does not fit — and lists every work with what it covers. Each work holds its own article and its own reference, in the default style; the sections below carry the whole set at once, so one copy pastes both citations and one saved file holds both records (references.ris). Nothing changes for a site asking for a single work.

The works the family shares are held here rather than copied into each site, so none of them can name a different version of the same paper — import them from react-cheminfo/core and put the site's own work first:

Export The work
PLATFORM_PAPER / PLATFORM_WORK Chemical data processed in the browser, which is what makes these applications rather than queues of jobs. Every site.
TEACHING_PAPER / TEACHING_WORK Two decades of online teaching, which is what the tutorials and the exercises are. The sites that teach.
OPEN_CHEMISTRY_PAPER / OPEN_CHEMISTRY_WORK Chemistry's data published open and machine actionable. The sites that read research data where it was deposited.
import { PLATFORM_WORK, TEACHING_WORK } from 'react-cheminfo/core';

<CiteButton works={[OWN_WORK, TEACHING_WORK, PLATFORM_WORK]} />;

A dataset is cited too. Give the reference kind: 'dataset' and a repository in place of the journal, volume and pages: every style writes it the way it writes data, BibTeX as a @misc entry and RIS as a DATA record, and its entry opens the dataset rather than an article.

const RECORD: DatasetReference = {
  kind: 'dataset',
  authors: [{ given: 'J.', family: 'Waser' }],
  title: 'Raw data for the article "Accessing CF3-Cyclopropenes"',
  repository: 'Zenodo',
  year: 2026,
  doi: '10.5281/zenodo.18380123',
};

<CiteButton
  works={[{ reference: RECORD, what: 'This dataset' }, OPEN_CHEMISTRY_WORK]}
/>;

ContactButton

The Contact entry of a site header. SiteHeader draws it after the site's utilities and just before Share, which a site passes in its own share prop so that it always comes last; a site gets the entry by updating the package: it opens the family's contact form in a new tab, in the language of the page (English when the form is not written in it), with the site and the page already filled in. The form is opened, never embedded, so nothing is loaded from it before the click.

The forms are set once, in CONTACT_FORM_URLS, as addresses holding {site} and {page}; while it is empty no entry is drawn. A site passes contact={false} to SiteHeader to leave the entry out, or its own address to open another form.

EcosystemButton

The Tools entry of a site header: one button opening every other site of the family, each behind its own little logo and the two colours it owns.

import { EcosystemButton } from 'react-cheminfo/ui';

<EcosystemButton currentSiteId="vcl" />;
  • currentSiteId is the one thing that differs per site: that tile is shown with a you are here label and is not a link. Passing nothing links them all.
  • compact drops the text and the caret, leaving the icon alone to open the menu, for a header that has run out of room. The label stays what the pointer and a screen reader are told.
  • A tile lights up in the colour of the site it opens, so running the pointer down the grid is what makes the pairs of colours read.

AccountButton

The utility that says who a site is answering to: an invitation when nobody is signed in, and the account's own mark when somebody is.

import { AccountButton } from 'react-cheminfo/ui';

<AccountButton
  identity={
    user === null ? null : { name: user.displayName, detail: user.email }
  }
  loading={loading}
  signInHref="/login"
  onSignIn={() => navigate({ kind: 'login' })}
  onSignOut={() => void signOut()}
  signInLabel="Teacher sign in"
/>;
  • The two states are told apart by whether an identity is on screen, never by the direction of an arrow. Blueprint's log-in and log-out are the same arrow either side of the same door; at 14 px among four other monochrome glyphs, a site drawing one reports a signed-out visitor as signed in. So the invitation is the words Sign in — it carries no glyph, which is what keeps the bar from reducing it to a mark — and being signed in is the person's own initials.
  • loading draws neither state, at the width the mark will take. A bar that offers to sign in and then turns into a name has told the visitor something false, and that is the state they act on first.
  • items are the account's own pages, listed in the menu above Sign out.
  • signInHref keeps the invitation a real link, so a middle click opens a tab; a site with no page for it passes onSignIn alone and opens a dialog.
  • A site on the shared accounts draws the AccountMenu of service-accounts-frontend, which feeds this button from the session.

AtomicOrbitalViewer

One hydrogen-like atomic orbital, screened by Slater's rules, sampled in the browser and drawn as a signed isosurface with molstar.

import { AtomicOrbitalViewer } from 'react-cheminfo/orbital';

<AtomicOrbitalViewer atomicNumber={26} orbitalId="3dz2" />;
  • The maths is exact, not a cartoon: a hydrogen-like radial function with its n − ℓ − 1 nodes, times a real spherical harmonic with its ℓ angular ones. The Slater orbital a hybridisation model uses is nodeless and would draw a 3s exactly like a 1s, only fatter.
  • The canvas is behind a React.lazy boundary and nothing this entry point exports pulls molstar in statically, so a page that never draws an orbital never downloads it.
  • A WebGL probe runs before molstar is touched (probeViewerCapability), so a locked-down school machine gets a sentence rather than a blank rectangle.
  • Every orbital is drawn at one canonical size. Molstar's camera clamps its near plane, so uranium's 4f — which reaches 0.35 Å — would otherwise stay a dot in the corner; the true extent is reported as ⟨r⟩ instead.
  • The isovalue is a weighted quantile of the samples (isocontourCutoff), never molstar's computeOrbitalIsocontourValues: that one abandons any field whose mean ψ² falls under an absolute 1e-5, which reads the orbital's size rather than its shape, and left 2588 of 7460 orbitals blank. A quantile has no scale of its own.
  • The isosurface is extracted on the CPU (tryUseGpu: false). Molstar's GPU marching cubes quantises the field to 255 steps on upload, which terraces xenon's 4p, and pits a diffuse outer lobe with voxel-sized dimples. The surface is a thin shell whatever the box holds, so the CPU path costs 13–44 ms even at 152³.
  • resolution takes a { floor, cap } pair as well as a number, and then each orbital picks its own: one resolution for a whole table leaves xenon's innermost 4p lobe spanning 6.6 voxels while the outer one spans 41. A nodeless orbital stays on the floor and costs nothing extra.
  • sample accepts a worker-backed sampler when a site would rather not spend ~25 ms of its main thread per orbital. runAtomicSample from react-cheminfo/core is the function that worker calls, and it imports neither React nor molstar.

The maths is exported on its own from react-cheminfo/core — atomicOrbitalsOf, configurationOf, slaterScreening, radialProfile, radialNodeRadii, sampleAtomicOrbital, orbitalContour — so a site can draw its own radial plot, list an element's orbitals, or print a screened charge without mounting anything.

Each mark keeps the geometry of that site's own logo where it has one, redrawn on a plate of the site's own colour so every mark of the family still reads as one row, and carrying the site's answering colour on exactly one element — which is what stops it collapsing into a flat shape at 16 px.

NMRium keeps its artwork whole: the symbol is the one from Logo.tsx of the NMRium sources, in its orange, on the plum its logo sets the rest of the wordmark in. ChemCalc is the one mark that is ours — its logo is an atom that is unreadable small, so the tile draws what the tool returns, an isotopic pattern, in ChemCalc's own indigo and teal.

The colours a name is set in are the site's own and are not retuned to reach the 4.5:1 of body text: ChemCalc's teal and NMRium's orange both land just under it, which is why a name is set bold, where 3:1 is the threshold.

StructureEditor

The canvas a structure is drawn on, sized so none of its toolbar is cut off, and loaded from whatever notation the caller has.

import { StructureEditor } from 'react-cheminfo/structure';

<StructureEditor
  value={smiles}
  inputFormat="smiles"
  revision={revision}
  minHeight={280}
  onChange={(change) => setSmiles(change.smiles)}
/>;
  • The canvas takes a pasted structure itself, so a site needs no box beside it to paste into. Click the canvas and ⌘/Ctrl-V reads a molfile, a SMILES or an idCode out of the clipboard and adds it to the drawing — openchemlib listens for the paste on the editor and parses the text with Molecule.fromText. Focus is what decides it, not the pointer: a paste while the caret is elsewhere goes to that element and the canvas never sees it. ⌘/Ctrl-C copies the structure back out. A box is still worth having where a structure arrives as text the visitor keeps — a share link, a value they read back — and not merely to get a SMILES onto the canvas.
  • A structure can be dropped on the canvas, or opened from its corner. A molfile, an SDF (its first record), a SMILES or an idCode, as a file or as text dragged from another page, replaces the drawing and is reported through onChange like an edit; a 3D molfile is laid out flat. Text that holds none leaves the drawing as it was and says so on the canvas. importable={false} turns both off. A page that reads more than the canvas does — an XYZ, a PDB, a geometry it must keep in 3D — passes onImport and receives the files or the text unread, then loads its structure back through value and revision. <StructureDrop onDrop> gives any other area of the page the same drop and the same veil, and readStructureTransfer reads a paste or a drop into the same shape.
  • The editor is uncontrolled: value is read at mount and again on every change of revision, never on every render. Feeding the drawing back into value would replace every coordinate under the pen, so bump revision when an example, a share link or a Clear button loads a structure the editor did not produce.
  • onChange fires once the drawing has been still for debounce milliseconds (300 by default) with every notation read out at once — idCode, SMILES, molfile, formula.
  • minHeight is a floor, not a height: the container grows to whatever the toolbar needs, measured when the lazy canvas arrives rather than on a deadline, so a slow chunk never leaves half the palette cut off.
  • help (on by default) explains each toolbar button in a tooltip and puts a guide to the mouse and the keyboard in the corner, which F1 opens too — the paste and copy keys are listed there under Anywhere.
  • splash (on by default) says on an empty molecule canvas how to start: draw with the toolbar, click and paste a molfile, a SMILES or an idCode, or drop a file when importable is on. The paste keys are drawn as ⌘ V on a Mac and Ctrl V elsewhere, decided as openchemlib decides which modifier it reads. It lets the pointer through and goes away with the first atom.
  • The canvas sits behind React.lazy, so a page that never opens an editor never downloads openchemlib, and react-cheminfo/structure stays importable when those optional peers are absent.

ColorScaleSelect

The colours a quantity is read with, chosen by looking at them rather than by reading their names — and, behind the last entry, built from anchors of the reader's own.

import { COLOR_SCALES, resolveColorScale, swatchAt } from 'react-cheminfo/core';
import { ColorScaleSelect } from 'react-cheminfo/ui';

<ColorScaleSelect label="Colour scale" value={scale} onChange={setScale} />;

const { scale } = resolveColorScale(text); // 'plasma', or 'hsv-long,0-0000ff,1-ff0000'
swatchAt(scale, 0.5); // { background, foreground }
  • The choice is one string, which is what goes in the state and in the address: the id of one of COLOR_SCALES, or a scale spelled out. Nothing else has to be carried, and resolveColorScale never throws — a scale renamed since a course page was written falls back to viridis rather than blanking the figure.
  • Ten scales are offered: viridis, plasma, magma, inferno, cividis and turbo; greys for a photocopier; cool-warm and blue-red for a quantity read away from a middle; and the rainbow, which orders nothing but is what a course usually draws.
  • A custom scale is anchors plus a path. rgb mixes the three channels, which is what a browser gradient does; hsv turns along the colour wheel the short way, keeping the saturation instead of fading through the grey in the middle; hsv-long turns the other way, which is how two anchors of one hue draw a whole rainbow. ColorScaleEditor is that editor on its own, for a site that wants it inline.
  • A scale that turns is sampled, never handed to CSS as its two ends — a browser only interpolates the straight line between two colours. colorScaleGradient and ColorScaleBar do the sampling.

SpectraSettingsEditor

Everything spectra-processor can be told, laid out in the order it happens — and the principal components a score plot is then drawn against.

import { EMPTY_SETTINGS, settingsProblems } from 'react-cheminfo/core';
import { SpectraSettingsEditor } from 'react-cheminfo/ui';

<SpectraSettingsEditor
  value={settings}
  onChange={setSettings}
  spectrumIds={processor.getIDs()}
  principalComponents={{
    selection,
    onSelectionChange: setSelection, // { x: 0, y: 1 } — columns of the score matrix
    count: scores.columns,
    explainedVariance: pca.getExplainedVariance(),
    settings: pcaOptions,
    onSettingsChange: setPcaOptions,
  }}
/>;

processor.setNormalization(settings.processor.normalization);
processor.getPostProcessedData(settings.postProcessing);
  • The types come from the packages that own them, not from a copy. src/spectra/core/settings.ts is the only file naming spectra-processor or ml-signal-processing, and it reads the shapes off their public surface — ConstructorParameters<typeof SpectraProcessor>[0], FilterXYType. Both are optional peers and every import is import type, so a site that does not use this component downloads neither.
  • All 23 chain steps are offered, grouped as a chain is built — baseline, smoothing and derivatives, scaling, the two axes, housekeeping — with every option each one takes, down to the nested peak picking of calibrateX. The catalog is a Record keyed by the step name, so the day ml-signal-processing adds a filter this package stops compiling instead of quietly not offering it.
  • The order is the meaning, so it is editable and it is checked. A step moves with two arrow buttons — no drag dependency, and it works from the keyboard. Scaling placed before the baseline it depends on, a second resampling, a crop that leaves the spectra on grids that no longer line up: each is named where it sits.
  • Nothing blocks. A reader mid-thought may hold settings the processor would throw on. settingsProblems says which value it would throw on — separating what fails from what merely looks unintended — and the editor still takes the edit.
  • A cleared box means "whatever upstream does", never undefined written into the settings, which is the only way back to a default once it has been typed over. A half-typed 1e- is kept as typed rather than parsed away.
  • The components are named PC1 and handed back as 0. The label is what a chemist writes; the number is the column of the score matrix, because that is what indexes it. clampPrincipalComponents keeps a selection saved on other data inside the components that exist, and keeps the two axes apart so a plot never collapses onto its diagonal. FilterChainEditor and PrincipalComponentSelect are each usable on their own.

PcaViewer

A finished principal component analysis, read by somebody who has never met one.

import { PCA } from 'ml-pca';
import { PcaViewer } from 'react-cheminfo/ui';

const pca = new PCA(rows, { scale: true });

<PcaViewer
  pca={pca}
  rows={rows}
  scaled
  projected={newBatch} // placed into the finished model, drawn hollow
  variables={{
    kind: 'named',
    names: ['Sepal length', 'Sepal width', 'Petal length', 'Petal width'],
    unit: 'cm',
  }}
  samples={{
    ids,
    labels: names, // what the card and the map write; `ids` stay the keys
    groupings: [
      { id: 'species', label: 'Species', groups: species }, // colours the dots
      { id: 'cluster', label: 'Cluster', groups: clusters }, // shapes them
    ],
    fields: (index) => [
      { label: 'Sepal length', value: `${rows[index][0]} cm` },
    ],
  }}
  onSelectionChange={(ids) => setPicked(ids)}
  onTrackVariable={(track) => setHovered(track?.readout ?? null)}
/>;
  • Four tabs, and only the ones the data can fill. projectionTabs reads the result rather than a prop: a principal component analysis publishes a share of the variance and a set of weights, so it gets the map, every pair, what differs and how much each explains. A UMAP embedding publishes neither, so it gets the map alone — one tab and no tab strip, instead of three empty ones.
  • It never imports ml-pca. PcaLike is a structural type — predict, getExplainedVariance, getEigenvalues, getLoadings — which ml-pca's PCA satisfies as it stands, with no adapter and no cast. The package has no runtime dependency on it, and embeddingResult is the same door for coordinates from anywhere else.
  • An outline says what share of a group it holds, not how many standard deviations it is. In two dimensions those are not the numbers anybody remembers: an ellipse at one standard deviation holds about 39% of the points, not 68%. The picker therefore offers 50% of samples … 99% of samples, and the legend writes the same words, so the figure cannot promise a coverage it does not have.
  • The outline is projected, not rotated. The two axes almost never carry the same units per pixel, so an ellipse turned by an angle in the data is not turned by that angle on the screen. Handing an SVG <ellipse> the data-space angle draws every tilted group slightly wrong and never says so.
  • A sample the model was built from is filled; one placed into it afterwards is hollow. pcaResult(pca, { rows, projected }) appends the second kind and records where they start, because the two are not equally trustworthy: a fitted sample helped choose where the axes point and is bound to land somewhere reasonable, while a projected one can land anywhere — and landing far out is the finding, not a fault.
  • Two groupings of the same samples, on one picture. samples.groupings is a list: the first colours the dots and the second shapes them, and the reader can give either to any grouping from the bar and the cog. A grouping of more groups than there are shapes is refused as a whole rather than drawn with two groups under one mark, and the hover card names every grouping. It is what a clustering is read with: the clusters it found in colour, the classes you gave as shapes.
  • Colour means one thing per tab and the legend says which. Groups on the map and the pair grid, components on the other two, drawn from two orders of the Okabe–Ito palette that are disjoint over their first four, so blue-is-setosa on one tab is never blue-is-PC1 on the next. OverlayLegend has no default title: a legend that does not name its encoding is how a reader carries the wrong meaning from one tab to the next.
  • The direction of a component is arbitrary, so it is never a colour. Sign is carried by geometry — above or below the zero line — and each component is flipped so its strongest measurement comes out positive, because otherwise refitting on one more sample mirrors the whole map and reads as a fault.
  • Selecting is a drag. Plain drag replaces the selection, shift adds, alt removes, and the caption says which while the pointer is still down. onSelectionChange hands back sample ids, so a caller never keeps a second index. Pointer capture, cancellation and lost capture all funnel into one path, which is what stops a lasso being left half-drawn.

ProjectionViewer and the floating options

PcaViewer is a thin adapter over ProjectionViewer, which draws any dimension-reduction result — a k-means run with its centroids, a UMAP embedding, coordinates read from a file — from one ProjectionResult. The pieces below it are meant to be built on:

Domain Holds
src/chart MatrixLike read where it stands, linear scales, round ticks, the SVG frame, the tracked line chart
src/overlay The card of controls that floats over a figure, and the caption, legend and readout that explain one
src/scatter Lasso selection, hover, confidence ellipses, the plot and the pair grid
src/projection The result model, the two adapters, and the viewer

The overlay domain is the part most likely to turn up elsewhere. A card rests at three quarters strength and wakes when the pointer reaches the figure or the keyboard reaches one of its controls; only its ground fades, never its text, because a label at three quarters over a busy scatter is unreadable exactly when it is wanted. One or two controls stay in the strip and everything an expert changes lives behind a cog — concealment is the default, and a control is visible only because somebody put it there deliberately. Below about 420 pixels the whole bar folds into that cog, and the tab order is the same folded or open.

<OverlayLayer width={width} density="compact">
  <OverlayBar placement={emptiestCorner(points)} more={<Expert />}>
    <OverlaySelect caption="Across" value={x} options={axes} onChange={setX} />
  </OverlayBar>
</OverlayLayer>

Saving a figure

Every projection viewer carries a save glyph in its bar: a reader picks PNG or SVG, picks a resolution, and gets the figure as a file. The panel writes out the pixels each resolution would produce, and fileName names what arrives — the view is appended, so fileName="ecstasy" saves ecstasy-map.png.

The control behind it is FigureDownload, and it is not tied to the viewer: it takes the id of the box a figure sits in, so it can stand in a bar, a toolbar or a menu without the component that drew the figure handing anything over.

<FigureDownload targetId="pca-figure" fileName="ecstasy-map" />
<div id="pca-figure">
  <PcaViewer pca={pca} rows={rows} samples={samples} />
</div>

Three things it does that a screenshot does not. Everything inside the box is saved, so a view that is really sixteen charts arrives as one picture with each placed back where the reader saw it. The tokens the figure is drawn from are resolved first — a var(--border) that left the site would draw a chart with no axes. And the chrome floating over the picture is left behind: an OverlayLayer marks itself, so the cog in the corner never lands in the middle of the scatter.

The resolution applies to both formats: a PNG is painted with that many more pixels, and an SVG, drawn the same, opens that many times larger. Neither changes the figure's shape. Pass renderFigure and the panel also offers a size: as shown, 4:3, 16:9, a journal column (8.5 cm, where the text prints at the size it reads on screen) or a width × height typed in. The figure is then drawn again off the page at that size, so its axes and labels are laid out for the new shape rather than stretched, and saved once it has stopped moving. Draw exactly what is on screen; only the box changes.

<FigureDownload
  targetId="spectrum"
  fileName="spectrum"
  renderFigure={(size) => <Spectrum width={size.width} height={size.height} />}
/>

A chart that measures its own container can ignore size: the copy is mounted in a box of exactly that size.

A figure drawn in HTML rather than SVG is saved too. Mark its box data-figure="html", and each ground, border, outline and line of words in it is painted into the file from where the browser put it, so the copy matches the screen. Anything marked data-figure="chrome" inside it stays behind. Images, gradients, shadows and ::before / ::after content are not painted.

PeriodicTable is one, so the save glyph can sit wherever the site keeps its controls. Point it at a box around the table and anything that belongs in the picture, such as the site's own colour key:

<div className="no-print">
  <PropertySelect … />
  <FigureDownload targetId="table-figure" fileName="electronegativity" />
</div>
<div id="table-figure" data-figure="html">
  <PeriodicTable headers swatchOf={swatchFor} />
  <Legend … />
</div>

Marking the box itself data-figure="html" paints everything in it, the key's labels included. The corner that selects the whole table shows itself only when pointed at or reached from the keyboard, and is neither printed nor saved.

Every PNG states its resolution, so a document places it at the size the figure has on the page rather than at one pixel per point: a pixel of the page is a ninety-sixth of an inch, so 2× is 192 dpi.

A figure drawn to the size it prints at — a structure at a journal's bond length — takes unit="dpi": the resolution is then offered as 150, 300, 600 and 1200 dpi, the panel writes out the printed size in centimetres, and the SVG keeps the size it states.

A page whose file carries more than its picture — the SMILES and InChIKey behind a drawn structure — passes writeFigure in place of targetId. It returns the SVG document and its size in pixels of the page; the SVG is saved as written, and the PNG is painted from it. With your own trigger, the hook takes the same options, and onScaleChange hands back the multiple picked, for a page that keeps it:

const { isOpen, setOpen, panel } = useFigureDownload({
  writeFigure: async () => ({ markup: await writeDrawing(), width, height }),
  unit: 'dpi',
  defaultScale: dpiScale(600),
  onScaleChange: (scale) => savePreference(figureDpi(scale)),
});

downloadFigure(target, options), downloadFigureSvg(figure, options), figureSvg and figurePng are the same thing without the button, for a site saving a figure from its own menu; withPngDpi and pngDpi write and read the resolution of any PNG.

cropFigure(figure) cuts a figure another page rendered whole — a 3D viewer's pane, a chart with the room around it — down to what is painted on it, with a thin margin, before it is placed in a drawing or pasted in a slide. The background is read off the four corners, white or transparent alike. A figure holding one image, as a WebGL viewer writes it, is cut in its pixels; any other SVG narrows its view box and stays a vector. A figure with nothing to take away comes back as it was. paintedBox(pixels, width, height) is the scan underneath, for a canvas of your own.

Copying a figure, so it opens again

Copy — in FigureDownload, the 3D viewer's export panel and the structure picture pane — puts one item on the clipboard, in three renderings:

Rendering Read by
image/png a chat, an office program
text/html: the PNG at the size it was drawn, carrying the figure as data-cheminfo-figure a document; draw and write
image/svg+xml, where the browser takes it a vector editor

The figure is the SVG the site saves, with withPageFigureSource writing where it came from and what the page showed. So draw and write paste a figure that opens again on its site, not a picture of one. It rides in the HTML because the HTML is the one rendering every browser hands a paste: Chrome lists an SVG but gives it to no paste event, and Firefox and Safari take none. Safari rewrites the HTML of a paste from another site, and keeps the attribute.

A site copying a figure itself writes the same item, from the click, with both still promises:

copyFigure(png, withPageFigureSource(svg));
// or beside renderings of its own
navigator.clipboard.write([
  new ClipboardItem({
    ...figureClipboardParts(png, figure),
    'text/plain': molfile,
  }),
]);

A page taking a paste reads the figure in the paste event, before its data is gone: readClipboardFigure(event.clipboardData.getData('text/html')). In a Playwright spec, readClipboardHtml(page) from react-cheminfo/host-testing reads what a Copy left on the clipboard.

RangeSlider

A range picked with two handles, its two values written under the track, where a click turns either into a box to type an exact number.

import { RangeSlider } from 'react-cheminfo/ui';

<RangeSlider
  label="Molecular weight"
  unit="g/mol"
  domain={[0, 1000]}
  step={10}
  value={range}
  histogram={counts}
  onPreview={setCountRange}
  onChange={setRange}
/>;
  • A handle at the end of the track is an open side. The range is { min, max }, either side null; the track only covers the values the page knows about, so a value past it is never cut by a filter nobody touched.
  • onChange fires when the range settles — a handle released, a value typed, the range cleared — so a page that fetches on it fetches once per gesture. onPreview follows every step of a drag, for what is cheap to redraw.
  • The handles are Blueprint's, so the keyboard, touch screens and screen readers are handled there; the stylesheet dresses them in the brand colour.
  • histogram takes one count per equal-width bin, drawn over the track with the part the handles keep in the brand colour.

TaxonLineage and OrganismName

Where an organism sits in the tree of life, as a breadcrumb, and its name set the way nomenclature sets it.

import { lineageFromNodes } from 'react-cheminfo/core';
import { OrganismName, TaxonLineage } from 'react-cheminfo/ui';

<OrganismName name="Catharanthus roseus (L.) G. Don" />;

<TaxonLineage
  lineage={lineageFromNodes(taxa, taxId)}
  taxonHref={(taxon) => `/taxonomy/${taxon.taxId}`}
  onTaxonSelect={(taxon) => navigate(`/taxonomy/${taxon.taxId}`)}
  ncbiLink
/>;
  • A lineage is { rank, name, taxId? }[], from the top down. It is read from a map of NCBI nodes pointing at their parents (lineageFromNodes), from a record of names by rank as octochemdb stores it (lineageFromRanks), or written as it is.
  • The principal ranks are shown by default — domain, kingdom, phylum, class, order, family, genus, species — with the last taxon kept whatever its rank. principal={false} shows every clade, for a page walking the tree.
  • Each rank is a tooltip, or a small label over the name with showRanks. The labels are the chrome's words, translated with it; useRankLabel() names a rank the same way anywhere else on the page.
  • A taxon is a link only when taxonHref gives it an address, and onTaxonSelect takes over its plain click so the site moves without a reload. current marks the last taxon as the page being read; ncbiLink adds the way out to the NCBI Taxonomy Browser.
  • Italics follow the rank, then the name. The names from the genus down go through splitOrganismName, which sets the genus and the epithets in italics and the authority, the strain, sp., a cultivar and the words announcing a rank (subsp., var., f.) upright. It reads a name by its shape, as the source wrote it, and leaves upright what it cannot place.

The type is TaxonLineage in react-cheminfo/core and the component TaxonLineage in react-cheminfo/ui; a file needing both imports one of them under another name.

ParallelCoordinates

Many rows against many properties at once: one vertical axis per column, one line per row, and a brush on every axis.

import { ParallelCoordinates } from 'react-cheminfo/ui';
import { parallelAxisOf, parallelIncludedMask } from 'react-cheminfo/core';

const axes = useMemo(
  () => [
    parallelAxisOf(rows, {
      id: 'mw',
      label: 'MW',
      unit: 'g/mol',
      value: (r) => r.weight,
    }),
    parallelAxisOf(rows, { id: 'clogp', label: 'cLogP', value: (r) => r.logP }),
    parallelAxisOf(rows, {
      id: 'mutagenic',
      label: 'Mutagenic',
      domain: [0, 2],
      ticks: [
        { value: 0, label: 'none' },
        { value: 1, label: 'low' },
        { value: 2, label: 'high' },
      ],
      value: (r) => r.mutagenic,
    }),
  ],
  [rows],
);
const included = useMemo(
  () => parallelIncludedMask(axes, ranges, rows.length),
  [axes, ranges, rows.length],
);

<ParallelCoordinates
  axes={axes}
  width={width}
  height={340}
  colorAxis="clogp"
  ranges={ranges}
  onRangeChange={setRange}
  included={included}
  hovered={hovered}
  onHoverChange={setHovered}
  selected={picked}
  onRowClick={pick}
  renderTooltip={({ index }) => <MF mf={rows[index].mf} />}
/>;
<MoleculeTable included={included} />;
  • It is told nothing about what the rows are. Every axis hands over one ArrayLike<number> read in place, so a repaint costs an indexed read rather than a call per value, and the same figure draws a library of molecules, a set of runs and a table of measurements. parallelAxisOf is the one place a row type is named, and it runs once per data change.
  • The table beside it filters on the same answer. parallelIncludedMask is exported and is what the figure itself uses: a plot drawing one line under a table showing fourteen rows is not a rounding error, it is two programs.
  • A brush reports on release, never during the drag. The band follows the pointer live — that costs nothing, it is one component's own state — while the interval reaches the caller once per gesture, so a table filtering on it is asked to re-render once rather than sixty times a second. onRangePreview is there for a caption, and for nothing else.
  • The brush is arithmetic, not a library. Create by dragging on a bare axis, move the band, drag either edge, clear by pressing outside it: four answers to one question, which part of the axis the press landed on. Written that way the whole gesture is unit-tested in a runtime with no DOM, which is what a brush attached to an element can never be — and it keeps 15.8 kB of d3-brush out of every site that draws one.
  • The axes end exactly on the data. No nicing, because the reader is comparing rows against each other and the best one has to touch the top of its axis rather than float below a round number nothing reached. nice turns it on per axis when round numbers really are the point, and a column with no spread at all is opened by half a unit so that it still draws.
  • A coded quantity writes its own graduations. domain: [0, 2] with ticks reading none, low, high is what a risk column needs; 2 on an axis means nothing to anybody.
  • The lines are a canvas, everything else is not. Ten thousand polylines are not ten thousand elements, so the mass is painted — the rows a brush left out in one batched path underneath, the kept ones over them — while the axes, the bands, the names and the card stay SVG and HTML. The consequence is worth knowing before choosing it: FigureDownload copies SVG off the page, so a saved figure keeps the axes and loses the lines.
  • A canvas cannot read a custom property, and the accent is the one colour a site owns, so the figure asks the browser what its own --accent, --text, --text-muted and --surface resolved to and paints with the answer. ink overrides any of them.
  • The card is a render prop, because what belongs in it is the caller's subject — a formula through react-mf, a structure, a name — and a readout of strings cannot hold any of that.

A graded series, and the shell around it

A pedagogic site that asks a series of questions writes its pool and its grader, and nothing else. SeriesTool describes them; ExerciseSeries draws the list beside the open question, the progress over the series, the length picker and the new-series button. It reads no address: the page hands it the seed, the length and the share of the row, and is told when one changes.

import { createAttemptStore, parseSeed } from 'react-cheminfo/core';
import { ExerciseSeries } from 'react-cheminfo/ui';

const ATTEMPTS = createAttemptStore({ key: 'atoms' }); // atoms:attempts:v1

<ExerciseSeries
  tool={ELECTRONS}
  store={ATTEMPTS}
  label={(ion) => <QuestionFormula formula={ion.formula} />}
  prompt={(ion) => <p>How many electrons has {ion.name}?</p>}
  seed={parseSeed(route.params.seed)}
  onSeed={(seed, history) => writeSeed(tab, seed, history)}
  count={questionCount}
  onCount={setQuestionCount}
  split={SERIES_SPLIT.parse(route.params.split ?? null)}
  onSplit={(split) => writeSplit(tab, split)}
/>;

pickSeeded draws the series. Its draw option names the generator call a site's links were written with — getFloat for atoms, inorganic and moles, getUint32 with wrap for dbe — so every ?seed= already handed out opens the same questions. seedParam reads a seed a link pins, and ?seed=each for one series per student. createProgressController(signal) is the handful of actions an exercise page performs on progress kept in a signal.

Formulas come from their own door, because they are read by mf-parser and drawn by react-mf: readFormula reads NO3-, SO4-- and CuSO4.5H2O the way a student types them, which the parser alone reads as neutral or as a decimal; CopyableFormula copies a formula as text and as HTML, so a paste keeps its subscripts.

SiteShell is the chrome around a page: the theme, the bar with About · Cite · Tools · Contact · Share, the page, and the footer, with nothing but the page under ?embed. A site fills the slots between the shared entries (actionsBeforeAbout, actionsAfterAbout, actionsAfterCite, actions, language, account) and never re-orders them. Given the site's router, it marks the tab on show and serves the About tab as AboutPage. Its share takes what ShareControl takes — the Share entry and its dialog in one, which is also what a header's own share prop takes.

A prerendered site that writes text for a few of its pages passes them as pages to cheminfoPrerender; every other address then says the name and the sentence its route is indexed under.

Framed by a notebook

A page that frames a tool — an electronic lab notebook such as SciPeaks, a course — can hand it typed data instead of writing it into the link. The tool says cheminfo:hello to its parent, the parent answers cheminfo:connect with a private MessageChannel port, and the data arrives on that port as { kind, value } items, each checked against pipeline.cheminfo.org's JSON Schema before the tool reads it. A tool taking a structure writes two things:

// src/tool.ts
import type { ToolInterface } from 'react-cheminfo/host';
import { STRUCTURE_KINDS } from 'react-cheminfo/host';
import { hostStructureLoader } from 'react-cheminfo/structure';

export const TOOL: ToolInterface = {
  name: '3d',
  inputs: [{ key: 'structure', kinds: STRUCTURE_KINDS }],
  outputs: [],
};

export const loadFromHost = hostStructureLoader((molecule, label) => {
  loadSmiles(molecule.toIsomericSmiles(), label);
});
// App.tsx, right after the hook that applies the address
useHostConnection(TOOL, { load: loadFromHost, build: BUILD_INFO });
  • Nothing is downloaded unless the page is framed: the connection and ajv are one lazy chunk, fetched once a parent or an opener exists.
  • A structure that does not read is answered, not shown: the host gets unreadable with one sentence it can display, and the screen does not change. A molfile whose atoms all sit on one point — SciPeaks stores some that way — is laid out again.
  • The same document twice is applied once, so a host re-sending a sample never overwrites what the visitor did with it.

Spectra in, bands back

A measured spectrum travels as its JCAMP-DX and the bands kept for it, and its bands come back alone:

Kind Value A band
spectra.ir.jdx { jdx, peaks? } { wavenumber, absorbance?, transmittance?, strength? }
spectra.ir.peaks { peaks } the same
spectra.raman.jdx { jdx, peaks? } { wavenumber, intensity?, strength? }
spectra.raman.peaks { peaks } the same
spectra.uv.jdx { jdx, peaks? } { wavelength, absorbance?, transmittance?, strength? }
spectra.uv.peaks { peaks } the same
  • The item names the spectrum: its id is the host's reference, and the bands come back under it; its label is what the tool shows. One item is one spectrum: when its JCAMP-DX holds several, the bands belong to the first.
  • A strength is a word, weak, medium or strong. A transmittance is a fraction, not a percentage; an infrared wavenumber is above zero, a Raman shift may be negative, and a UV-vis wavelength is in nanometres. Only the position — wavenumber, or wavelength for UV-vis — is required: a value left out is read off the spectrum.
  • Absent peaks are not empty peaks. Absent, none were ever picked and the tool picks its own; empty, the host kept none and the tool keeps none.
  • What comes back replaces what the host kept: every band of the spectrum, ascending, one per wavenumber.

bandsFromIrPeaks / bandsFromRamanPeaks and irPeaksFromBands / ramanPeaksFromBands, bandsFromUvPeaks / uvPeaksFromBands (/ir, the Raman pair also from /raman and the UV-vis pair from /uvvis) convert between those bands and the viewer's. No tool declares the bare spectra.ir, spectra.raman or spectra.uv: each would take a list of bands for a spectrum, and pipeline's own spectra.ir is a computed spectrum, not a measurement.

A mass spectrum, and the ions found in it

A mass spectrum travels as its JCAMP-DX alone: a tool picks its peaks again. What a host looks for in it — the fragments a drawing cuts a molecule into — travels as a list of ions, and comes back as the peak each was found at:

Kind Value One entry
spectra.ms.jdx { jdx }
spectra.ms.ions { ions } { id, mz, mf?, radical?, label? }
spectra.ms.assignments { assignments } { id, mz, intensity }, the peak found
  • An ion is named by the host: its id comes back with the peak it was found at, whose intensity is a percentage of the spectrum's tallest peak. An ion found at no peak does not come back.
  • mf is the ion's formula with its charge, in mf-parser notation (C7H7O(+1)), and radical marks an unpaired electron. The tool writes them at the peak; without them it writes the m/z alone.
  • What comes back replaces what was sent before, so an ion no longer found is simply absent.

An input with many: true takes every item of its kinds, and one with optional: true may go without; the request is refused only when no input got anything. load may return a promise, and requests are served in order.

A tool that would have to compute everything again if it were opened at another address takes settings: how it shows what it holds, driven from a host's toolbar with the frame and the document left alone. The tool owns them, and the host only shows what the tool says:

useHostConnection(TOOL, {
  load,
  settings: {
    read: () => ({ surface: String(surface.value) }),
    apply: ({ surface: share }) => {
      if (isShare(share)) surface.value = Number(share);
    },
    subscribe: (heard) =>
      effect(() => {
        surface.value;
        heard();
      }),
  },
});

// In the host: a tool with none leaves `settings` at null, so it offers nothing.
const tool = useFramedTool({ url, host, document });
tool.settings?.surface; // what the tool says, whoever changed it last
tool.setSettings({ surface: '0.7' });
  • settings from the host carries the names that change, each value written as an address writes it. A name the tool does not know is ignored.
  • settings from the tool carries all of them, when a host connects and whenever one changes — a request, or the tool's own visitor. So a control in the host never goes stale.
  • A tool may describe its settings, so a host draws their controls with no code of its own per tool: fields returns a choice, a switch or a number for each, and travels with the values, so a choice that follows what the tool holds — the conformers on screen — follows it. <ToolSettingControls> draws them, in one row for a toolbar or as a form for a panel:
settings: {
  read, apply, subscribe,
  fields: () => [
    { key: 'surface', label: 'Surface', type: 'toggle' },
    { key: 'id', label: 'Conformer', type: 'choice', choices: conformerChoices.value },
  ],
},

// In the host:
<ToolSettingControls
  fields={tool.settingFields}
  values={tool.settings}
  onChange={tool.setSettings}
  layout="form"
/>

A tool hands values back through outputs, a store read synchronously — a computed signal does it:

// One item per value, with its `key` and the `id` of the item it came from.
const peaks = computed(() => peakItems(measured.value, hostIds.value));

useHostConnection(TOOL, {
  load,
  outputs: {
    read: () => peaks.value,
    subscribe: (heard) => peaks.subscribe(heard),
  },
  build: BUILD_INFO,
});
  • get is answered with the current values: { ok: true, items }.
  • output is sent unasked when a value the host's load accepts, by its key or its kind, changes: { type: 'output', document, items }, 300 ms after the last edit. It carries only the items that changed, each with the id of the item it was made from, so a host knows which spectrum to save them on.
  • Opening a document sends nothing back. What the tool shows once load returns is what the host already has, so read must reflect a load by then.
  • Every picture handed back says where it came from, and carries what it shows. An image.svg gets a <cheminfo:source site href generator digest> in its metadata, in https://www.cheminfo.org/ns/source/2: the tool's name, the page's address as it stood (embed, hide and card left out), the build, and a digest of the picture. Inside it, the page's states in three named parts, each in the site's own format and versioned by it: <cheminfo:data> — what the figure is: 3d's conformers, lcao's geometry, plate's lanes and spots, chemcalc's spectra, the periodic table's colouring — <cheminfo:view> — where it stood — and <cheminfo:preferences> — the workspace it was made in. A part is JSON a person can read, gzipped in base64 once it passes 32 KB. The data is the document: a site opens the figure again from it, with nothing computed anew and the same whatever the site computes differently since; the address only says where. withFigureSource, readFigureSource, readFigureStates and figureAddress (/core) write and read it, and cropFigure keeps it.
  • An HTML page and a ZIP say the same. withHtmlSource writes the same envelope as JSON — { "format": "cheminfo", "version": 2, "site", "generator", "href", "data", "view", "preferences" } — in a <script type="application/json" id="cheminfo-source"> closing the page's head, and readHtmlSource reads it back. A site's .<site>.zip names the same format in its manifest.json, with view.json and preferences.json beside its data. A reader takes the parts it knows and leaves the rest, so a part or a field is added without breaking an older reader.
  • One way to open a figure again, on every site. A tool takes its own figures on FIGURE_INPUT, an optional figure input of kind image.svg, and reads them with loadedFigureStates; a host hands one back whole with figureToolDocument(svg, id). A reader opens a saved .<site>.svg with <OpenFigureButton site onOpen>, or drops one on a zone calling open from useOpenFigure (/host): a figure of another site of the family offers to open on that site instead. edited says the picture was redrawn in another program since. Ctrl+S saves the site's figure through useSaveShortcut.
  • A figure opens with the latest build, and in the one that saved it on request. On Cloudflare Pages every deployment keeps an address of its own, and the source records it as revision — filled by withFigureSource from the build given to useHostConnection, with nothing for a site to write. <OpenFigureButton> then offers Original version beside itself for a figure another deployment of the site saved; a site with its own opener renders <SavedRevisionButton svg site> (/host) with the SVG of the file it opened, which openedFigureSvg (/storage) reads out of an SVG or a .<site>.zip. savedRevisionAddress (/core) accepts only another deployment of the running site's own Pages project, never an address a file chooses.
useHostConnection(TOOL, {
  load: async (inputs) => {
    const figure = await loadedFigureStates(inputs, TOOL.name);
    if (figure?.data) openState(figure.data.value);
    else loadStructure(inputs);
  },
  states: () => ({
    data: { version: 1, value: { conformers: shownSet.value } },
  }),
});

react-cheminfo/playwright holds the configuration every site's browser suite runs with — specs in e2e/, one worker, two retries and the GitHub reporter on CI, a running server reused locally — so a site writes only where it is served:

import { defineConfig, devices } from '@playwright/test';
import { cheminfoPlaywrightConfig } from 'react-cheminfo/playwright';

export default defineConfig(
  cheminfoPlaywrightConfig({
    baseURL: 'http://localhost:10919',
    device: devices['Desktop Chrome'],
  }),
);

webServer takes one server or several (a backend and a frontend), and overrides lays anything else over the defaults.

Locally a suite opens one browser per two cores, never more than 16: every browser loads its pages from the one server the suite starts, so on a big machine more of them only time out. PLAYWRIGHT_WORKERS — a count, or a share of the cores such as 25% — sets the number on any machine, over a site's own overrides.workers and over CI.

react-cheminfo/host-testing serves a stand-in host for Playwright on 127.0.0.1, and exports SCIPEAKS_ETHYLBENZENE_LOAD: the message SciPeaks' tool tab sends for a sample, byte for byte. visualizer-helper's tests pin the same message, so a change on either side fails a test rather than a notebook.

const host = await startStandInHost();
await page.goto(
  host.pageUrl(`${origin}/?embed=1`, [SCIPEAKS_ETHYLBENZENE_LOAD]),
);
const { results } = await page.evaluate(readStandInHostLog);

sciPeaksEthylbenzeneIrLoad(jdx) and sciPeaksEthylbenzeneRamanLoad({ adamantane, map }) are the loads SciPeaks' tool tab is to send for that sample's infrared and Raman spectra: the stored bands translated as its readers are to translate them — the letter to a word, the visualizer's _highlight dropped, a spectrum with no bands sent without peaks. Hand them the attachments' JCAMP-DX. sendStandInHostRequest sends a later get.

Reading what a link asks for

A site reads the configuration its address carries in one place, beside its vocabulary, and its components ask that place, never the address:

export const { isEmbedded, isHidden } = createPageShareConfig(SHARE_VOCABULARY);

The address is read the first time anything asks, through pageLocation(), so an old address a router adopts at startup and a sealed link both count. read() reads it again, once a router has rewritten it, or in a test.

Protecting a link

The share dialog can hand out a sealed link: the whole query string moved into one s token, so a class can neither read nor edit what the link sets up, and the page's address stays fixed while it is used.

https://isomers.cheminfo.org/exercises?s=e1.x9dasYuwqR9SZzw…

A site offers it by opening its address before anything reads it. Everything that reads the address at load moves to a second module:

// src/main.tsx
import { openSealedAddress } from 'react-cheminfo/core';

const opened = await openSealedAddress();
const { start } = await import('./start.tsx');
start(opened); // renders <SealedLinkRefused /> when opened.status is 'refused'

Calling openSealedAddress is what makes ShareDialog offer Protect this link; a site that never calls it never hands out a link it cannot open. A sealed page is always embedded. Its address is held in memory, so site code reads the address through pageLocation() and writes it through pageHistory(), never through location and history directly.

The token is deflated, then encrypted under an 8-byte HMAC tag that is also the AES-CTR counter, so the same query always gives the same link, and an edited one is refused. The key ships in every bundle: this stops a class, not someone who digs the key out of the code. A token, once handed out, must open for ever: a new key or format ships under a new version prefix (e2).

Speaking four languages

Every word this library puts on a page — the header, the Tools menu, the Cite menu, the share dialog, the About page, the structure editor's guide, the 3D toolbar — is in src/locales, one flat JSON file per language, en.json being the source of truth. English, French, German and Spanish are complete, and npm run check-messages fails on a key English does not declare, a placeholder a translation adds or drops, and an empty message.

A site gets a translated chrome by saying what language it is in, and nothing else. <SiteLanguage value> already tells every link out of the page which language to carry; the chrome now reads the same declaration:

<SiteLanguage value={language === 'en' ? undefined : language}>
  <SiteHeader
    siteId="isomers"
    nav={nav}
    actions={<ShareButton onClick={open} />}
  />
</SiteLanguage>

English is part of the page, because it is what a missing message falls back to; the other three arrive as a chunk of their own, so a site nobody reads in German never downloads the German. A site with a language switch draws it with <LanguageSelect value onChange>, which names each language in its own words.

A site's own text is a catalog of its own, read with the same runtime:

import { MessageCatalog } from 'react-cheminfo/core';
import en from '../locales/en.json' with { type: 'json' };

export const SITE_CATALOG = new MessageCatalog({
  id: 'isomers.cheminfo.org',
  repository: 'cheminfo/isomers.cheminfo.org',
  directory: 'src/locales',
  source: en,
  translations: {
    fr: () => import('../locales/fr.json'),
  },
});

The lazy imports carry no with { type: 'json' }: a bundler turns a JSON file into a JavaScript module, and the attribute makes the browser refuse the chunk it is served for being text/javascript. The English import is static, so the bundler resolves it before a browser ever sees it and may keep the attribute.

const t = useT(SITE_CATALOG) then reads it in the language of the page, typed by the English keys; t.or(key, fallback) is for a key built from the data, which the catalog may not have caught up with. Only the plain {name} argument of ICU MessageFormat is filled — a plural or a select needs the formatter itself, which an ordinary visit must not pay for, so check-messages refuses one and the page is written with a key per branch instead.

In translate mode the page hands both catalogs over at once, so a translator editing a site also edits the chrome. startTranslateMode reads ?translate= before it imports anything, so an ordinary visit pays for none of it:

void startTranslateMode({
  search: location.search,
  catalogs: [CHROME_CATALOG, SITE_CATALOG],
  tables: TRANSLATABLE_TABLES,
  origin: import.meta.env.VITE_TRANSLATE_ORIGIN,
});

It formats through setMessageSession, which every useT listens to, so an edit typed in the overlay redraws the page without the site holding a version of its own; and an overlay that never arrives hands the page back as it was.

Still English, with the runtime in place and no catalog yet: the spectra settings editor and the projection panels (react-cheminfo/spectra, the projection viewers), which no site of the family reads in a second language yet.

Routing a translated site

A router given languages reads the language off the prefix and writes it back, English bare, so the share vocabulary, the query and the mount stay where they were:

const router = createTabRouter<Tab>({
  tabs: ['generator', 'exercises'],
  home: 'generator',
  languages: ['en', 'fr', 'de'],
});

router.parse('/fr/exercises?embed'); // { tab: 'exercises', language: 'fr', … }
router.format({ tab: 'exercises', language: 'de' }); // '/de/exercises'

readRoute moves a ?lang= a sibling handed over into the path, once, in place; a prefix the address already carries always wins, so a deep link is never steered. openingRoute(router, preferred) opens a bare / in the language the reader last chose, and nothing else. writeRoute keeps the language on screen when the route names none.

Beside it: persistSignalBucket takes legacyKeys (adopted once, through a migrate when the shape changed) and syncAcrossTabs; createSiteAddresses answers anchorHref and storedAssetSrc; a vocabulary takes legacyEmbedParts, so ?hide=header still opens framed; keySetParam writes ?flags= with none for the empty set; createWorkerChannel takes a failure hook, retryAfterWorkerFailure, and runs in-process when a browser refuses the worker; oclResourcesUrl() comes from ./conformers and the openchemlibResources({ keep }) plugin that writes the file it points at from ./vite; and resetCamera, focusPoint, sceneSphere and subscribeHoverLabel from ./molstar/core.

Carrying talks

A deck is one Markdown file in the repository of the site whose subject it teaches — front matter, slides separated by a --- line, and a <!-- layout: … --> comment where the default is not what is wanted. The format and the player are react-cheminfo/slides; the addresses and the authoring rules are in rules/slideshows.md.

import { parseTalk, Slideshow } from 'react-cheminfo/slides';
import 'react-cheminfo/styles/slides.css';

const talk = parseTalk(source);

<Slideshow
  talk={talk}
  talkId="20260824_IMSC"
  index={slide}
  onIndex={(next) => navigate(`/talks/${id}?slide=${next}`)}
  layouts={{ octochemdb: OctoChemDbFlow }}
/>;

The index is controlled: the site owns the address, so a link can open one slide and a demo link can come back to it. Arrow keys, space and PageUp/Down navigate, Home and End jump, f is fullscreen, b and w blank the screen, n shows the speaker's notes. A layout name the player does not know falls back to content rather than breaking a deck in front of a room, and a site adds its own through layouts.

The embed layout puts a live tool on the slide — the last bare URL of the body is framed — which is what our ?embed mode exists for.

A title slide whose body ends with a bare URL frames that address on the right half and keeps the title on the left, so a talk opens on the tool running rather than on a picture of it.

A procedure, done by the tool beside it

The steps layout teaches a procedure: its list is on the left half, the tool framed live on the right, and the arrows walk the list one line at a time before they leave the slide. The line on show is lit, the lines done stay readable, the lines to come are faint; a click on a line goes to it, a click on the lit one plays it again.

<!-- layout: steps -->

## A reflux in one minute

1. Double-click **Round-bottom flask**: it lands on the page
2. Click **Allihn condenser**, then the ringed neck: it fits
3. Click **Heating mantle**, then under the flask

https://glassware.cheminfo.org/?embed=1&demo=reflux

?demo= names a script the tool owns, one step per line. It plays the step the slide is on through the tool's own controls, with a cursor carrying the line's number:

// main.tsx — decided before anything is loaded
const name = readDemoName(location.search);
if (name !== null) {
  const { startDemo } = await import('./demos/startDemo.ts');
  startDemo(name);
}

// demos/startDemo.ts
import 'react-cheminfo/styles/demo.css';
import { serveDemo, type Demo } from 'react-cheminfo/demo';

const REFLUX: Demo = {
  reset: emptyPage,
  steps: [
    (demo) => demo.doubleClick(tile('Round-bottom flask')),
    async (demo) => {
      await demo.click(tile('Allihn condenser'));
      await demo.click('.joint-marks__free');
    },
    async (demo) => {
      await demo.click(tile('Heating mantle'));
      await demo.click(pointIn(flask, 0.5, 1.15));
    },
  ],
};
serveDemo('reflux', REFLUX);

A drag from a draggable element — a tile of a panel — is carried as the browser carries it: a faint copy of the tile travels under the cursor and is dropped where it is let go, with the page's own dragstart and drop handlers reading one DataTransfer. A drag from anything else is a pointer drag.

A target is an element, a selector, a point, or a function answering one, read again until it is there. A step back or a jump resets the tool and replays the steps before at once, so every step starts from the same place. The events go to the tool's real handlers; a capture of the demo's pointer is taken as done, and while the frame is not the focused page no focus is taken, so the presenter's keys keep moving the slides. The slide and the tool speak four messages — cheminfo:demo-step, -ready, -done, -failed — which a tool's end-to-end suite posts to the page itself to play every demo through.

The format itself is framework-free and is also exported from react-cheminfo/core, so a backend gathering the family's manifests reads them without loading React.

A site that fullscreens its own shell — so a slide can open the live tool and come back still presenting — passes fullscreen={{ isFullscreen, onToggle }}, and the f key and the bar's button drive that instead of the player's own element.

Publishing a site's decks to the family is one plugin:

import { cheminfoTalks } from 'react-cheminfo/vite';

plugins: [cheminfoTalks({ site: 'chemcalc' })];

It writes dist/talks.json and dist/talks/<id>.md, which is what learn.cheminfo.org reads to list and play every site's talks beside its own.

Prerendering a site

A static site has no server to rewrite a head per request, so a crawler gets whatever came off the wire. cheminfoPrerender writes one real HTML file per routed address at build time, each with its own title, description and canonical, plus sitemap.xml, robots.txt, the JSON-LD block and the noscript crawl path. Without it every address carries the same head and a search engine folds the whole site into one result.

The template says where the head goes

index.html is the template, and it carries no title and no description of its own: it declares where they go, with two comments.

<head>
  <meta charset="utf-8" />
  <link rel="icon" href="%BASE_URL%favicon.svg" />
  <!--cheminfo:head-->
</head>
<body>
  <div id="root"></div>
  <!--cheminfo:body-->
</body>

That is the whole contract, and it is what makes the rest of this small: the head is written, never found and operated on. Nothing is parsed and nothing is searched for but the marker, so a byte order mark, an implicit head, a </head> the page's own prose displays or a bundled script quotes in a string, and a <title> inside an <svg> all leave the result exactly as it is — and no duplicate title or description is possible, because the template carries none. A page missing <!--cheminfo:head--> throws rather than shipping headless.

<!--cheminfo:body--> is where the noscript crawl path goes, and a site running with noscript: false needs no such marker.

It is a Vite plugin, and the whole of a live caller — 3d.cheminfo.org — is:

// vite.config.ts
import react from '@vitejs/plugin-react';
import { cheminfoPrerender } from 'react-cheminfo/vite';
import { defineConfig } from 'vite';

import { PAGE_ROUTES } from './src/seo/routes.ts';

export default defineConfig({
  plugins: [
    react(),
    cheminfoPrerender({
      site: '3d',
      routes: PAGE_ROUTES,
      operatingSystem: 'Any browser with WebGL2',
    }),
  ],
});

routes is one RouteMeta[] the site owns and three things read: this plugin, injectPageMeta on a server that has one, and writeDocumentMeta in the running app after an in-app move. A page missing from it is a page a search engine only ever sees as the home page.

npm run dev fills the same two markers from the home route, so a developer never opens a page showing its markers and dev reads like production.

The mount lives in origin, and nothing else

origin is where the site is served, mount path included — one value carrying both, because that is exactly what a canonical link, an og:url and a sitemap entry need. Everything else is read back off it: robots.txt writes its Allow and Disallow under the mount, the noscript index links under the mount, and every absolute address is built on it. There is no second option to keep in step.

cheminfoPrerender({ site: 'isomers', routes: PAGE_ROUTES });
// origin defaults to https://isomers.cheminfo.org — the site owns its host,
// the mount is '', and /exercises is written at dist/exercises.html.

cheminfoPrerender({
  site: 'isomers',
  routes: PAGE_ROUTES,
  origin: 'https://learn.cheminfo.org/isomers',
});
// the same build, mounted under /isomers: canonicals and the sitemap read
// https://learn.cheminfo.org/isomers/exercises, and robots.txt allows /isomers/.

The files on disk are laid out from the build's own root either way — dist/exercises.html, never dist/isomers/exercises.html. It is the server that puts them under the mount. An origin must be an absolute http or https address or it is refused: one written localhost:3000 parses with localhost: as its scheme, which would read the mount back as /3000.

robots: false skips robots.txt for a site shipping its own, category: false skips the JSON-LD block, and noscript: false leaves the site without its only crawl path — so leave it on.

The card a link unfurls into

ogCardHtml draws public/og.png from the site's own record — its mark, its two colours, its name — so the card never drifts from the mark the site shows. It returns a page for a headless browser to screenshot, and it is the one export of this entry point that loads React, on the call rather than on the import.

The cheminfo-og-image bin does the screenshot with Playwright's Chromium, which every site already installs for its browser suite, so a site writes one script line rather than a script:

"og-image": "cheminfo-og-image 3d"

--description <text> replaces the tagline on the card, and --out <file> writes somewhere other than public/og.png.

On a site that does have a server

The same head is written per request instead: injectPageMeta(index, { site, routes, origin, url }), next to the tracking-script injection, on /, /index.html and the SPA fallback alike — the served page is the same template, with the same two markers. robotsTxt and sitemapXml are the same two calls, answered as routes rather than written to disk. A server writing more into the same place composes it itself, with pageHeadTags and fill(page, PAGE_BODY_MARKER, noscriptIndex(…)). All of that is react-cheminfo/core, so a backend loads no React and no Vite.

On Cloudflare Pages

Pages serves the build as it is, so there is no server and no entrypoint to put the analytics snippet in a page. onRequest from react-cheminfo/pages adds it per request instead, from the project's TRACKING_SCRIPT variable, and returns everything that is not a page untouched:

// functions/_middleware.ts
export { onRequest } from 'react-cheminfo/pages';

It is a door of its own so the Functions bundler reads two small files and never the rest of core, whose JSON imports an older esbuild cannot parse.

Keep the bundles out of the function with a public/_routes.json, so a page view costs one invocation rather than one per asset:

{ "version": 1, "include": ["/*"], "exclude": ["/assets/*"] }

On a Node server, next to an API

A prerendered site can also be deployed as a server, with its API and its MCP tools beside the pages. staticSite from react-cheminfo/server serves the build by the rules the static hosts follow: /titration is titration.html with no redirect, an unknown address is 404.html and never the home page, pages are revalidated on every visit and assets/ is kept for a year, every answer carries Access-Control-Allow-Origin: * (a page framed by a chat's sandboxed view fetches all its files cross-origin), the TRACKING_SCRIPT snippet is in every page, and /health answers the deploy script. Only a file the build wrote is ever served.

import { fastify } from 'fastify';
import { staticSite } from 'react-cheminfo/server';

const app = fastify();
// …the API under /v1/…
await app.register(staticSite, {
  root: 'dist',
  trackingScript: process.env.TRACKING_SCRIPT,
  redirects: { '/precipitation': '/equilibrium' },
});

Addresses under reserved (default ['/v1/']) are left to the API, which answers its own 404. Without a build, staticSite logs it and serves the API alone. fastify (5) and @fastify/static (10) are optional peers, installed by a site that has a server.

basePath (BASE_PATH) serves one image at a mount chosen at startup, behind a proxy that takes the path off: it is written into the <base href="/"> of every page when the build is read, and in front of every redirect.

A tool site's server, from its environment

A site that serves its tools beside its pages has nothing of its own to write but the tools. readServerConfig reads the environment once and refuses a malformed value; toolSiteServer builds Fastify, registers the API the site passes, then staticSite; startToolServer listens and closes on SIGTERM and SIGINT, setting process.exitCode = 1 when a close fails.

// server/main.ts
import { join } from 'node:path';

import {
  readServerConfig,
  startToolServer,
  toolSiteServer,
} from 'react-cheminfo/server';

const config = readServerConfig(process.env, {
  siteId: 'osiris',
  port: 10_620,
  root: join(import.meta.dirname, '../dist'),
});

await startToolServer(
  (settings) =>
    toolSiteServer({
      siteId: 'osiris',
      config: settings,
      // name: 'osiris.cheminfo.org', version: the repository root's release
      registerApi: (app, { name, version }) =>
        registerTools(app, name, version),
    }),
  config,
);
Variable Read as Default
PORT an integer from 1 to 65535 the site's own port
HOST the interface listened on 0.0.0.0
TRACKING_SCRIPT the analytics snippet, verbatim nothing loaded
SITE_URL the address answers link to, path kept, ending in / the site's own address
PUBLIC_URL this server's address, which a chat frames, ending in / http://localhost:<PORT>/
TRUST_PROXY an address, a CIDR, a list of either, or true; a hop count throws false
SERVER_ROOT the build served the root option, or dist
BASE_PATH the mount, behind a proxy that takes it off none

The pieces are exported on their own for a backend that is not a tool site: parseTrustProxy, readEnvNumber (blank is unset, a malformed value throws), readHttpAddress, readRepositoryVersion (the root package.json, found as cheminfoBuildInfo finds it), healthRoute (GET /health, silent), listenSite (listen, and close on a signal, then run onClose), and the type-only FastifyTyped (@fastify/type-provider-typebox is an optional peer).

A single-page app, served by its backend

A backend that serves a Vite single-page app answers every address the app routes with the built page, under the head of that address and a crawl path of its own. spaSite does that, and writes sitemap.xml and robots.txt from the route table at the address the site is served at; serves the build's files, with assets/ kept for a year; leaves /v1/ and /docs to the API; and answers /health.

await app.register(spaSite, {
  root: join(import.meta.dirname, '../../frontend/dist'),
  site: 'wikidata',
  siteUrl: process.env.SITE_URL, // blank: the origin the request arrived on
  trackingScript: process.env.TRACKING_SCRIPT,
  noscript: { heading: 'wikidata.cheminfo.org', hrefs: 'relative' },
  // A compound under its own name; undefined falls back to the table.
  page: (url) => compoundPage(db, url),
  sitemapPaths: () => db.pageIds().map((id) => `/compound/${id}`),
});

The route table is the one the app reads, written beside the page at build time by cheminfoRouteTable from react-cheminfo/vite, which also runs assertRoutes when the build starts; readRouteTable(root, fallback) reads it back. notFoundStatus: 404 with knownRoute answers an address the app does not route with a real 404, structuredData takes a function writing the whole block, and indexed: false keeps an admin site out of every index.

// frontend/vite.config.ts
plugins: [react(), cheminfoRouteTable({ routes: ROUTES })],

Writing to molecules.cheminfo.org

createMoleculesClient from react-cheminfo/core is the client a site writes its source through: startImport, importState, submit, lookup and purge, each attempt with its own deadline, retried on 429, 502, 503, 504 or no answer with doubling, spread pauses that honour Retry-After, bodies gzipped. It reads no Node module.

const molecules = createMoleculesClient({
  baseUrl: 'https://molecules.cheminfo.org',
  token: process.env.MOLECULES_TOKEN_LOTUS,
  onRetry: (details) =>
    logger.warn(details, 'molecules.cheminfo.org: retrying'),
});
const { importKey } = await molecules.startImport();

Saving what a search matches

<MoleculeExport> saves the molecules a search matches, not only the page on show, up to 10,000: as an SDF with 2D coordinates invented for each structure, or as TXT, tab separated, with an isomeric SMILES in the first column. A site writes only the generator that reads its pages:

import type { MoleculeFileRow } from 'react-cheminfo/structure';
import { MoleculeExport } from 'react-cheminfo/structure';

async function* searchPages(signal: AbortSignal) {
  for (let offset = 0; ; offset += 500) {
    const answer = await search({ offset, limit: 500 }, signal);
    yield answer.items.map((item): MoleculeFileRow => ({
      idCode: item.idCode,
      name: item.title,
      fields: { mf: item.mf, mw: item.mw },
    }));
    if (offset + 500 >= answer.total) return;
  }
}

<MoleculeExport read={searchPages} fileName="matches" total={total} />;

The file is written in the browser, in slices, with its progress and a Cancel button: a molfile costs tens of milliseconds for a large natural product, which 10,000 times over belongs on no public server. Reading stops at the limit, so the pages after it are never asked for.

Answers shown in a chat

A site's tool server answers mcp.cheminfo.org with a link to the page people share and a card: the same page on this server under ?embed=1&card=1, which the chat frames inline, and the page itself over the full screen. The card and the page read the same parameters, so they never disagree about what was asked.

The server writes both addresses with react-cheminfo/server. siteUrl and publicUrl may be an origin or an origin and a mount, with or without a trailing slash, and a page is named from the site's own root: every helper joins them with exactly one slash.

import {
  answerAddresses,
  cardMeta,
  pageAnswer,
  siteView,
} from 'react-cheminfo/server';

const addresses = answerAddresses({ siteUrl, publicUrl });
// { link: 'https://equilibrium.cheminfo.org/titration?ind=thymol', meta }
const answer = pageAnswer(addresses, '/titration', { ind: 'thymol' });
// A query written by the site's own codec, and what only the card reads.
const meta = cardMeta(addresses, `/?${libraryQuery}`, {
  hide: ['editor'],
  extra: { rows: '10' },
});
const view = siteView({ uri: 'ui://vcl/page', name, description, addresses });

A precise question gets a card about that and nothing else. A tool that looks one thing up — a compound, an element, a molecule's predictions — takes an optional only parameter listing the facts the question asked, and passes it on: cardMeta(addresses, path, { only: ['formula'] }) writes ?card=1&only=formula. The card then draws what the answer is about (its structure and name) and those facts alone, and its Details opens the whole page. The card reads the list with parseOnlyParam(value, known), which keeps the facts it knows, so a missing, empty or unknown only draws the card as it always was. In the tool, declare it tolerant, so a malformed list never fails the call:

only: z.array(z.enum(FACTS)).optional().catch(undefined).describe('…'),

A card address longer than maxLength (8000) is not carried: meta is then undefined and the chat draws the answer itself, while link still names the page. These helpers are built on embedMeta and embedView, so cheminfo-mcp-server (an optional peer) is installed by a site that imports react-cheminfo/server. The addresses alone — CARD_PARAM, ONLY_PARAM, parseOnlyParam, sitePageUrl, cardPageUrl, framedPageUrl — are in react-cheminfo/core, for the page.

The card itself is drawn with react-cheminfo/card and react-cheminfo/styles/card.css:

import 'react-cheminfo/styles/card.css';
import {
  CardDetailsButton,
  CardFigure,
  CardFrame,
  CopyTableButton,
} from 'react-cheminfo/card';
import { StructureExportButton } from 'react-cheminfo/structure';

<CardFrame>
  <p className="card__header card__title">pH 8.72 at equivalence</p>
  <CardFigure
    fileName="titration"
    tools={<StructureExportButton smiles={smiles} />}
  >
    <TitrationChart />
  </CardFigure>
  <div className="card__end">
    <CopyTableButton table={() => toDelimited(rows)} />
    <CardDetailsButton address={{ cardParams: ['scale', 'ph'] }} />
  </div>
</CardFrame>;

CardFrame reports the card's height to the frame and grows the card while a dialog or a popover it opened is open, so the frame holds it — only when the page is framed. A card that keeps its own root uses ref={fitCardToFrame}, or useFitCardToFrame(ref). CardDetailsButton opens the card's address without card, embed, hide and the parameters only the card reads, in a new tab when framed and in place otherwise. Their words come from the chrome's catalog, in the language of the page.

Until its answer comes, a card shows <CardStatus failure={error} />: a spinner, then why the answer could not be had, with Details either way. A page that has no card shows <CardMissing />.

A site's end-to-end tests frame a card as a chat does, three frames deep and sandboxed, with frameAsChat, or in the one frame of a page with frameAsCard, both from react-cheminfo/host-testing. Either page records every message the card posts in the log readChatFrameLog reads; readLastHeight is the height it reported last, to poll for. readOpenedAddress is the address a page was loaded from, before it rewrote it, and resetClipboard, readClipboardText and readClipboardTypes read back what a card copied.

const card = await frameAsCard(page, url);
await expect.poll(() => readLastHeight(page)).toBe(height);

Who an About names

On a site Luc Patiny made alone, "Provided by" holds everything: the names beside the logo are the site's people, and the list under them is the family: Luc Patiny, who had the idea and coordinates; Daniel Kostro, at Zakodium, on a site crediting react-science; Michaël Zasso, at Zakodium, for the tooling, and OpenChemLib where it is credited.

people: [{ name: 'Luc Patiny' }],
providedBy: ['epfl'],

A site made with anybody else names them, each with one line, and the page then separates two things that would otherwise read as one. "Who made it" lists the site's leads, Luc Patiny, then its contributors; "Who maintains what it runs on", further down, lists who keeps its libraries going, from FAMILY_MAINTAINERS and what the site credits.

contributors: [{ name: 'Régis Turin', role: 'wrote the exercises.' }],

A site built around somebody else's science names them in leads, which puts them first and has Luc Patiny's line say he wrote the web interface instead of having had the idea. Where a paper has an author-contribution statement, the roles come from it, and his line may too, written among the contributors. {organisation} in a role is where the link goes:

leads: [
  {
    name: 'Adrian Mirza',
    role: 'ideated SECS with Kevin Maik Jablonka and developed the backend, at {organisation}.',
    organisation: { name: 'LamaLab', href: 'https://lamalab.org' },
  },
],

aboutProblems refuses a role written for Luc Patiny on a site naming no leads, and an organisation without its place in the role.

Saying which build is running

A report of something going wrong is worth answering only when we know what was running, and a hand-written version in a source file is wrong by the next release. So the build states it: cheminfoBuildInfo resolves the release, the instant and the commit once, and the About page reads them.

The About says it once, in the badge in the corner of its hero: 1.2.1 · 2026-09-16 09:41 UTC, whose hover card lists the commit, the instant to the second and the react-cheminfo version. A site with no release yet shows its commit on the badge instead. Nothing further down the page repeats it.

// vite.config.ts
import { cheminfoBuildInfo } from 'react-cheminfo/vite';

plugins: [react(), cheminfoBuildInfo(), cheminfoPrerender({ ... })];
// src/about.ts
import { BUILD_INFO } from 'react-cheminfo/build-info';

export const ABOUT: AboutContent = { siteId: 'smiles', build: BUILD_INFO, ... };

react-cheminfo/build-info is a real module that exports undefined, and the plugin fills it in during the build. So a Playwright spec or a unit test that imports the same About record in plain Node still resolves it — a virtual specifier does not, and the whole suite dies on the import — and a build without the plugin simply shows no build badge.

Where each value comes from:

  • version — the package.json at the repository root, which is the one release-please bumps. A site whose tool lives in frontend still reports the root version rather than the 0.0.0 that workspace was scaffolded with.

  • builtAt — the instant the build ran, to the second, honouring SOURCE_DATE_EPOCH.

  • commit — read out of .git as files, never by running git: the image build has no git binary and the shared docker-image workflow passes no build argument. A site therefore lets three paths back into the build context, which costs a few kilobytes:

    # .dockerignore
    .git
    !.git/HEAD
    !.git/refs
    !.git/packed-refs
    

    Without them the commit is simply left out, and GITHUB_SHA is used when the environment names one.

A Cloudflare Pages build also records deployment, the address that one deployment keeps (CF_PAGES_URL), which every figure the site saves names so it can be opened again in the build that wrote it.

The same record is written to build-info.json in the build, so the whole family answers one question with one request each:

curl -s https://smiles.cheminfo.org/build-info.json

The same plugin lists what the build hands to its visitors, read off the finished bundle, workers included and tree-shaken packages left out: packages.json (name, version, licence, and whether the family maintains it) and licenses.txt (every licence text). <AboutPage> fetches the list, so Built on shows our packages in full and folds the others, with nothing for a site to wire. A page with no list still draws the works its record credits.

A page waiting for data

<DataDropZone> is what a page shows until it has something to work on. It takes a file dropped on it, a paste from anywhere on the page, and a file chosen with its Open button, so the page needs no other button for them:

<DataDropZone
  title="Drop, paste or open a mass spectrum"
  description="As JCAMP-DX, or as columns of m/z and intensity."
  accept=".jdx,.dx,.txt,.csv"
  onFiles={addFiles}
  onText={addText}
  onPasteClick={openPasteDialog}
/>
  • onText takes text pasted, or dragged in from another page. Without it the zone takes files only, and leaves text to the page around it.
  • onPasteClick is what the Paste button does; without it there is no button, and the keyboard still pastes. A button cannot read the clipboard without asking for permission, and a frame may not be allowed to ask, so the page opens a box to paste into.
  • A click on the zone itself asks for nothing, so a visitor can click into a framed page and press Ctrl+V.
  • actions holds whatever else starts the page, after Open and Paste.

A tool that holds nothing

The space a tool leaves empty says how to start. <EmptyGuide> is drawn over the tool's view while it holds nothing, and the site stops rendering it at the first stroke:

{
  nothingDrawn && !embedded && !drawing ? (
    <EmptyGuide
      actions={[
        { label: 'Start drawing', icon: 'draw', onSelect: startDrawing },
      ]}
      help={
        <EmptyGuideSection title="Point at an atom and press">
          <ShortcutList
            rows={[{ keys: ['c', 'n', 'o'], label: 'Set the element' }]}
          />
        </EmptyGuideSection>
      }
      linksTitle="Learn"
      links={[
        { label: 'The first talk', href: '/talks/first', onSelect: openFirst },
      ]}
      hints={['Paste a SMILES to add it to the drawing.']}
      figures={[
        { src: schemeUrl, alt: 'A reaction scheme', onSelect: copyScheme },
      ]}
    />
  ) : null;
}
  • The way out is a big button. Faint figures read as content already on the page, so actions stand on one line over them, the first in the site's colour, and the site stops rendering the guide when one is pressed. figuresTitle finishes that line by saying what a click on a figure does.
  • Help is what stays: how to start, the keys. A hint is what changes: one sentence at a time, the next one after hintInterval, and another one first on the next visit, chosen by the clock.
  • The figures are faint pictures of what the tool can make, side by side in the height left, each taking its share of the row; { stack: [...] } puts several one above the other in one place of it. A view under 640 px wide or 520 px tall shows the words alone, the buttons first. One with an onSelect comes up to full strength under the pointer, and a click does what it says.
  • It is grey, and never in the way. The pointer goes through it to the tool, except on a button, a link or a figure with an onSelect, so a first stroke can start on the words. A site passes no actions while a tool is in hand, as a stroke must not land on a button. Its parent has to be positioned.
  • A framed page shows none: what frames it says what to do there.

<ShortcutList> and <ShortcutKeys> draw the keys, here and in a site's help.

An editor on a narrow window

A figure and the panels that drive it share a row while the window has room for both. Under COLLAPSE_PANELS_BELOW (900 px) the figure keeps the whole row, and one panel at a time covers it from the rail, to be used and sent away again:

<PanelRow
  collapsed={collapsed} // readPanelsCollapsed(), then watchPanelsCollapsed()
  ratio={ratio}
  defaultRatio={72}
  min={50}
  max={85}
  onRatio={writeSplit}
  view={<Figure />}
  panels={shownIds.length > 0 ? <PanelStack /> : null}
/>

The row reads no store, so the editor keeps three rules:

  • One panel at a time. With the panels collapsed, what the stack draws and what the rail lights is the one panel on show. A rail press swaps it, and the second press, the cross and the fold send it away.
  • The workspace stays as it was. The panel on show is not written to the list of open panels, so a wider window finds what it left, plus the panel that was on screen.
  • Only somebody asking opens one. A rail press or a link naming the panel does. A gesture on the figure that opens a panel beside it with room only adds it to the list: selecting an atom must not cover the atom.

The panel takes the whole row, with the rail beside it to swap it or send it away. The figure stays mounted underneath and comes back as it was.

With room, an editor may let the double click on the bar hide the panels. Read split= with closableSplitParam, then pass open={ratio !== SPLIT_CLOSED} and an onOpenChange that writes SPLIT_CLOSED, or the share to go back to. A link then opens the way it was shared, as ?split=100. Without those two props the double click returns the row to its default share.

<MassEditor> and <IrEditor> do all of this themselves, so a site rendering one writes nothing.

A drawing editor's plane

draw.cheminfo.org and glassware.cheminfo.org lay a drawing out the same way, and react-cheminfo/drawing holds what they share. Coordinates are in points with y pointing down: Point, Box, the paper as DrawPage and the saved figure as FigureSize.

  • The plane: distance, boundsOf, polygonContains and the rest of the geometry, the smooth curves through vertices, and the shifts that align or spread a selection (arrangementShifts).
  • Pages and the camera: pageStops for the grid of sheets, frameCamera, zoomAround and the zoom presets.
  • Captions: parseRichText reads a caption's marks, applies the chemical rules and draws a formula as mf-parser reads it; writeRichText writes it back.
  • Curves edited by hand: a CurveShape takes a point on its line (withPointAt, its shape unchanged) and loses one (withoutPoint); shapeHandleAt, shapeLineAt and dragShapeHandle find and drag a point or a control point, kept in line with its opposite, with Alt for a corner and shift for 15° steps; curveHandleMarks draws the squares and rings.
  • Files: chooseSaveFile, chooseOpenFile and writeFile over the File System Access API. Each returns undefined where the browser has no picker, so a caller keeps a download. The kinds of file are the site's, as PickerType.
  • Pieces of the canvas: the dashed selection outline and its handles, the page mask, the swatch row, a full-screen stage, usePrinting and useSeen.

The components draw class names the site styles; the curve handles are coloured by react-cheminfo/styles/drawing.css. A panel's settings kept in a tree of preference signals are read and restored by preferenceSettings, from react-cheminfo/ui; pass { batch } from @preact/signals-react so a restore is one change.

react-cheminfo/caption-editor is the box a caption is typed in, over the drawing at its anchor: ProseMirror over the same rich text, with a toolbar for bold, italic, scripts, size, font, alignment and the chemical rules. It is a door of its own because ProseMirror is a sixth of a drawing page's script, so a site loads it with import() once a caption may be typed, and imports react-cheminfo/styles/caption-editor.css for its look. <CaptionEditor> knows no document: it is given the text, the anchor in pixels and the zoom, and hands back the text, alignment, font and chemical rules to onCommit; realigned, the box stays where it is on screen, and where the anchor goes is the caller's.

Styling

No stylesheet, nothing for a consumer to import. Every rule is a module-level CSSProperties constant applied with style, the way react-mf does it — so there is no import step, no load-order surprise, and no specificity fight with Blueprint, since an inline rule outranks every selector.

A component never hard-codes a site's identity: what differs per site is passed in as props, or read from a CSS variable (--brand, --brand-alt, --accent) so each site keeps the two colours it owns.

Development

npm run dev

opens Storybook on http://localhost:10815 — every component of the package, under every prop it takes, with the controls to set them and the snippet that reproduces what is on show. The Brand toolbar retunes --brand / --brand-alt / --accent on the canvas, so you can watch a component carry a site's two colours. Components land there as they move into the package.

A story is a *.stories.tsx file in stories/, written in CSF: a default export naming the component, and one export per state the sites use it in. The prose and the types of the controls come from the props' own JSDoc, read off the source by @storybook/addon-docs, so a component is documented by documenting its props — never by writing a page for it. stories/headerButton.ts already describes the controls every header button shares.

npm run build-storybook   # storybook-static/

is what GitHub Pages serves at https://cheminfo.github.io/react-cheminfo/, built and deployed by .github/workflows/pages.yml on every push to main.

Testing

Unit tests cover the framework-free half, which is where the citation formats and the site list live:

npm run test         # vitest + type-check + tokens + messages + eslint + prettier

The components themselves are almost entirely interaction — Blueprint popovers with hover delays, submenus, clipboard writes in two flavours, file downloads — so they are tested in a real browser rather than a simulated DOM:

npx playwright install --with-deps chromium   # once
npm run test-e2e                              # or test-e2e-ui to watch

npm run test-e2e starts Storybook itself and drives each story on its own address (/iframe.html?id=…), so there is nothing to launch first. It is a separate command from npm run test, because it needs a browser that a plain CI job does not have; its own workflow installs one.

Testing a local build

Before publishing, test the packed artifact rather than the sources: it is what a site will actually install, and it is where a missing file or a wrong exports entry shows up.

npm run tsc                # build lib/
npm pack                   # react-cheminfo-0.0.0.tgz

Then, from a throwaway app — or from the site you are about to adopt it in:

npm i ../react-cheminfo/react-cheminfo-0.0.0.tgz

Import from every entry point, so the check covers the framework-free halves too:

import { CITATION_STYLES, formatCitation } from 'react-cheminfo/core';
import { CiteButton, EcosystemButton } from 'react-cheminfo/ui';
import { cheminfoPrerender } from 'react-cheminfo/vite';

Install the tarball on its own once — with no other dependency — and import react-cheminfo/core and react-cheminfo/vite from it. Every peer is optional, so npm installs none of them, and either import failing means a module that should be framework-free is loading one at module scope.

Reinstall the tarball after every npm pack — npm caches it by name and version, and this package stays at 0.0.0 until release-please cuts the first one.

License

MIT

Releases

Packages

Contributors

Languages