From 13f5c418a84f405697b40c517e4459053dc9629d Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Tue, 1 Sep 2026 12:40:23 -0500 Subject: [PATCH 1/3] fix(biochem): clarify reaction mapping colours and bump 3.5.2 --- CHANGELOG.md | 7 ++ VERSION.md | 2 +- lib/utils/atomMappingColors.ts | 68 +++++++----------- lib/utils/colorDistance.ts | 2 +- package.json | 2 +- tests/unit/utils/mappingPaletteSafety.test.ts | 69 +++++++++++++++---- tests/unit/utils/moleculeHighlights.test.ts | 22 +++--- 7 files changed, 98 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a535b1fc..395f6ac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [3.5.2] - 2026-09-01 + +### Fixed +- Atom-mapping colours in reactions with many mapped groups are now clearly distinguishable; the warm/brown band was removed and mapped colours no longer collide with unmapped phosphorus or oxygen atom colours. + +--- + ## [3.5.1] - 2026-08-31 ### Fixed diff --git a/VERSION.md b/VERSION.md index d5c0c991..87ce4929 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -3.5.1 +3.5.2 diff --git a/lib/utils/atomMappingColors.ts b/lib/utils/atomMappingColors.ts index 3e0deab8..46d759a6 100644 --- a/lib/utils/atomMappingColors.ts +++ b/lib/utils/atomMappingColors.ts @@ -59,55 +59,33 @@ export interface AtomMappingColorPlan { /** * Colour-vision-deficiency-safe categorical palette for atom mapping groups. * - * Colours are assigned by group order (`MAPPING_PALETTE[index % length]`) and - * are consumed in three SVG roles by `components/ui/MoleculeRenderer.tsx`: - * RDKit highlight halo fills, atom-label `fill:` text, and bond `stroke:`. - * The molecule canvas is always white (`lib/theme.ts` sets both `background.default` - * and `background.paper` to `#ffffff`; there is no dark mode) and RDKit draws - * unmapped atoms and bonds in black, so every entry must read against white - * *and* stay clearly distinct from black. + * Colours are assigned by sorted group order and rendered as RDKit highlight halos, + * atom-label fills, and bond strokes on the white molecule canvas. The set uses six + * hue families; repeated green and blue families are split by about 31 L* so they + * read as dark versus bright green and deep versus sky blue rather than near-twins. + * The warm band is deliberately excluded: on white, the 3:1 contrast requirement + * makes warm colours dark, which reads as brown; it also avoids collisions with + * RDKit's unmapped phosphorus and oxygen colours. * - * Design constraints, all asserted by `tests/unit/utils/mappingPaletteSafety.test.ts`: + * Measured for this eight-colour set: minimum normal-vision dE76 is 48.6; minimum + * protan/deutan dE76 is 23.4; minimum contrast is 3.01:1 against white and 2.22:1 + * against black; minimum dE76 from black is 50; and minimum dE76 from any RDKit + * default (#FF0000, #FF7F00, #0000FF, #CCCC00, #00FF00) is 29.4. * - * 1. Entries 0-3 are the four Okabe-Ito (Okabe & Ito 2008; Wong, *Nature Methods* - * 8:441, 2011) colours that clear the contrast bar unmodified. The remaining - * Okabe-Ito members are deliberately excluded: orange `#E69F00` (2.25:1) and - * sky blue `#56B4E9` (2.31:1) fail against white, and darkening them to pass - * collapses the set's own separation (darkened orange approaches vermillion, - * darkened sky blue approaches blue), which defeats the purpose. - * 2. Entries 4-7 extend the set under the same rules rather than borrowing from - * a palette that was never CVD-checked. - * 3. Contrast against white is >= 3:1 for every entry (WCAG 2.1 SC 1.4.11 - * Non-text Contrast, the applicable bar for bond strokes and atom glyphs as - * graphical objects). - * 4. Under simulated protanopia and deuteranopia — the common red-green - * deficiencies, ~8% of males — the minimum CIE-Lab dE76 between any two - * entries is 19.0. No pair is separated by red-versus-green hue alone. - * 5. Every entry stays far from black (min dE76 59.4) so mapped atoms never read - * as unmapped. - * 6. Each reaction selects the palette subset that maximises its minimum normal, - * protan, and deutan separation before assigning sorted group order; for four - * groups this improves the minimum dE76 from 19.00 to 36.14. - * - * Deliberately eight colours, not more: a longer list only helps if its members - * stay distinguishable. The previous twelve-colour set collapsed to dE76 4.22 - * under deuteranopia (`#8C564B` brown vs `#20854E` green), i.e. it was ambiguous - * for every reaction; eight true colours are ambiguous only once a reaction - * exceeds eight mapping groups and the palette wraps. - * - * Known limitation: under tritanopia (~0.01% prevalence, both sexes) vermillion - * and reddish purple converge (dE76 0.96). That is inherent to Okabe-Ito itself - * and is accepted here in favour of red-green separation. + * `selectMappingColors` chooses a maximum-minimum-separation subset when the group + * count fits the palette. When it exceeds the palette length, it returns this full + * palette unchanged; `buildAtomMappingColorPlan` then cycles it with + * `index % selection.length`, so distinct groups can share a colour. */ export const MAPPING_PALETTE: readonly string[] = [ - '#0072B2', // blue - '#D55E00', // vermillion - '#009E73', // bluish green - '#CC79A7', // reddish purple - '#FA3C5A', // rose red - '#0A5A14', // deep green - '#960A82', // magenta - '#0A5AE6', // indigo blue + '#355214', // dark green + '#3FAA18', // bright green + '#00A398', // teal + '#2994FF', // sky blue + '#0B26D5', // deep blue + '#6F2183', // violet + '#FF14EF', // magenta + '#E00069', // crimson ]; const paletteDistances: readonly (readonly number[])[] = MAPPING_PALETTE.map((color) => diff --git a/lib/utils/colorDistance.ts b/lib/utils/colorDistance.ts index e14d36f7..9f309d5e 100644 --- a/lib/utils/colorDistance.ts +++ b/lib/utils/colorDistance.ts @@ -58,7 +58,7 @@ export function deltaE76(a: string, b: string): number { /** * Minimum normal/protan/deutan distance. Tritanopia is excluded because accepted - * Okabe-Ito #D55E00/#CC79A7 tritan dE76 0.96 would flatten every subset ranking. + * #00A398/#2994FF tritan dE76 4.95 would flatten every subset ranking. */ export function perceptualDistance(a: string, b: string): number { return Math.min( diff --git a/package.json b/package.json index c451af04..8b75212a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modelseed-ui", - "version": "3.5.1", + "version": "3.5.2", "private": true, "scripts": { "predev": "node scripts/sync-version-from-env.mjs", diff --git a/tests/unit/utils/mappingPaletteSafety.test.ts b/tests/unit/utils/mappingPaletteSafety.test.ts index 0904b0d3..e8c69335 100644 --- a/tests/unit/utils/mappingPaletteSafety.test.ts +++ b/tests/unit/utils/mappingPaletteSafety.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { MAPPING_PALETTE, selectMappingColors } from '@/lib/utils/atomMappingColors'; +import { buildAtomMappingColorPlan, MAPPING_PALETTE, selectMappingColors } from '@/lib/utils/atomMappingColors'; import { deltaE76, perceptualDistance } from '@/lib/utils/colorDistance'; import { buildAtomOrbitColorPlan } from '@/lib/utils/atomOrbitColors'; import type { AtomMappingPair } from '@/lib/utils/atomMapping'; @@ -27,6 +27,9 @@ const MIN_DELTA_E_NORMAL = 25; /** ...and under the common red-green deficiencies. */ const MIN_DELTA_E_CVD = 15; +// At these sizes the maximum-minimum subset beats the palette-order prefix. +const COUNTS_WHERE_SELECTION_BEATS_PREFIX = new Set([2, 3, 4, 5, 7]); + type Rgb = readonly [number, number, number]; const hexToRgb = (hex: string): Rgb => { @@ -121,7 +124,7 @@ describe('colour maths used by these checks', () => { expect(contrastRatio(WHITE, WHITE)).toBeCloseTo(1, 5); expect(toLab(WHITE)[0]).toBeCloseTo(100, 3); expect(toLab(BLACK)[0]).toBeCloseTo(0, 3); - expect(deltaE('#0072B2', '#0072B2')).toBe(0); + expect(deltaE('#355214', '#355214')).toBe(0); // Deuteranopia collapses pure red against pure green; normal vision does not. const normal = deltaE('#FF0000', '#00FF00'); const deutan = deltaE(simulate('#FF0000', 'deutan'), simulate('#00FF00', 'deutan')); @@ -130,11 +133,11 @@ describe('colour maths used by these checks', () => { }); it('flags the previously shipped palette that motivated this change', () => { - // The old 12-colour set: brown #8C564B and green #20854E were indistinguishable - // under deuteranopia. Proves these thresholds can actually fail a bad palette. + // The retired 12-colour set: brown #8C564B and green #20854E were + // indistinguishable under deuteranopia. Proves these thresholds can fail. const legacy = [ - '#0072B2', '#D55E00', '#009E73', '#CC79A7', '#E69F00', '#56B4E9', - '#8C564B', '#7F3FBF', '#BC3C29', '#20854E', '#6F99AD', '#EE4C97', + '#E69F00', '#56B4E9', '#8C564B', '#7F3FBF', '#BC3C29', '#20854E', + '#6F99AD', '#EE4C97', '#0072B2', '#D55E00', '#009E73', '#CC79A7', ]; expect(worstPair(legacy, 'deutan').delta).toBeLessThan(MIN_DELTA_E_CVD); expect(Math.min(...legacy.map((c) => contrastRatio(c, CANVAS)))).toBeLessThan(MIN_CONTRAST_ON_CANVAS); @@ -148,8 +151,8 @@ describe('MAPPING_PALETTE', () => { for (const color of MAPPING_PALETTE) expect(color).toMatch(/^#[0-9A-F]{6}$/); }); - it('opens with the Okabe-Ito colours that clear the contrast bar unmodified', () => { - expect(MAPPING_PALETTE.slice(0, 4)).toEqual(['#0072B2', '#D55E00', '#009E73', '#CC79A7']); + it('uses the high-salience six-family palette in its documented order', () => { + expect(MAPPING_PALETTE).toEqual(['#355214', '#3FAA18', '#00A398', '#2994FF', '#0B26D5', '#6F2183', '#FF14EF', '#E00069']); }); it('drops the low-contrast and CVD-confusable entries of the previous palette', () => { @@ -181,13 +184,12 @@ describe('MAPPING_PALETTE', () => { expect(delta, `closest ${kind} pair: ${pair[0]} vs ${pair[1]}`).toBeGreaterThanOrEqual(MIN_DELTA_E_CVD); }); - // Documented, accepted limitation rather than a silent gap: tritanopia (~0.01% - // prevalence) merges Okabe-Ito's vermillion and reddish purple. Pinned so that any - // future palette change surfaces its tritan behaviour instead of hiding it. - it('has a known tritanopia limitation inherited from Okabe-Ito', () => { + // Tritanopia is excluded from subset ranking; pin its actual worst pair so a + // future palette change makes that trade-off visible. + it('has the documented tritanopia limitation', () => { const { delta, pair } = worstPair(MAPPING_PALETTE, 'tritan'); - expect(delta).toBeLessThan(MIN_DELTA_E_CVD); - expect(pair).toEqual(['#D55E00', '#CC79A7']); + expect(delta).toBeCloseTo(4.95, 1); + expect(pair).toEqual(['#00A398', '#2994FF']); }); }); @@ -198,7 +200,13 @@ describe('selectMappingColors', () => { const sequential = worstPerceptualPair(MAPPING_PALETTE.slice(0, count)); expect(selected).toBeGreaterThanOrEqual(sequential); expect(selected).toBeGreaterThanOrEqual(MIN_DELTA_E_CVD); - if (count < MAPPING_PALETTE.length) expect(selected).toBeGreaterThan(sequential); + // At 6 and 8, the leading palette run is already an optimal subset, so the + // maximiser agrees with the prefix; this tie is never a regression. + if (COUNTS_WHERE_SELECTION_BEATS_PREFIX.has(count)) { + expect(selected).toBeGreaterThan(sequential); + } else { + expect(selected).toBeCloseTo(sequential, 9); + } } }); @@ -232,6 +240,37 @@ describe('selectMappingColors', () => { ); } }); + + it('addresses the rxn00018 high-salience mapping colour report', () => { + expect(selectMappingColors(8)).toEqual([ + '#355214', '#3FAA18', '#00A398', '#2994FF', + '#0B26D5', '#6F2183', '#FF14EF', '#E00069', + ]); + expect(selectMappingColors(4)).toEqual(['#3FAA18', '#00A398', '#2994FF', '#0B26D5']); + for (const [left, right] of pairs(MAPPING_PALETTE)) { + expect(deltaE76(left, right), `${left} vs ${right}`).toBeGreaterThanOrEqual(45); + } + for (const color of MAPPING_PALETTE) { + for (const rdkitDefault of ['#FF0000', '#FF7F00', '#0000FF', '#CCCC00', '#00FF00']) { + expect(deltaE76(color, rdkitDefault), `${color} vs ${rdkitDefault}`).toBeGreaterThanOrEqual(25); + } + } + expect(selectMappingColors(MAPPING_PALETTE.length + 1)).toEqual(MAPPING_PALETTE); + const mappingPairs: AtomMappingPair[] = Array.from({ length: MAPPING_PALETTE.length + 1 }, (_, index) => ({ + left: { compoundId: `cpd${String(index).padStart(5, '0')}`, element: 'O', index: 1 }, + right: { compoundId: `cpd${String(index + 100).padStart(5, '0')}`, element: 'O', index: 1 }, + leftAtoms: [{ compoundId: `cpd${String(index).padStart(5, '0')}`, element: 'O', index: 1 }], + rightAtoms: [{ compoundId: `cpd${String(index + 100).padStart(5, '0')}`, element: 'O', index: 1 }], + hasSymmetryGroup: false, + raw: '', + })); + const inventories = Object.fromEntries(mappingPairs.flatMap(({ left, right }) => [ + [left.compoundId, { O: 1 }], [right.compoundId, { O: 1 }], + ])); + const plan = buildAtomMappingColorPlan(mappingPairs, inventories); + expect(plan.legend).toHaveLength(MAPPING_PALETTE.length + 1); + expect(plan.legend[0].color).toBe(plan.legend[MAPPING_PALETTE.length].color); + }); }); describe('palette assignment', () => { diff --git a/tests/unit/utils/moleculeHighlights.test.ts b/tests/unit/utils/moleculeHighlights.test.ts index 56091fa5..69a4144f 100644 --- a/tests/unit/utils/moleculeHighlights.test.ts +++ b/tests/unit/utils/moleculeHighlights.test.ts @@ -114,44 +114,44 @@ describe('moleculeHighlights', () => { it('recolours a heteroatom half-bond', () => { const svg = ""; - const result = applyBondColors(svg, { 0: '#0072B2' }); - expect(result).toContain('stroke:#0072B2'); + const result = applyBondColors(svg, { 0: '#355214' }); + expect(result).toContain('stroke:#355214'); expect(result).not.toContain('#FF0000'); }); it('recolours both halves of a bond', () => { const svg = `${BOND_PATH}`; - const result = applyBondColors(svg, { 0: '#0072B2' }); - expect(result.match(/stroke:#0072B2/g)).toHaveLength(2); + const result = applyBondColors(svg, { 0: '#355214' }); + expect(result.match(/stroke:#355214/g)).toHaveLength(2); }); it('recolours every hex stroke declaration in a bond style', () => { const svg = ""; - expect(applyBondColors(svg, { 0: '#0072B2' })).toBe( - "", + expect(applyBondColors(svg, { 0: '#355214' })).toBe( + "", ); }); it('recolours standalone stroke attributes', () => { const svg = ''; - expect(applyBondColors(svg, { 1: '#009E73' })).toContain('stroke="#009E73"'); + expect(applyBondColors(svg, { 1: '#3FAA18' })).toContain('stroke="#3FAA18"'); }); it('leaves fills and atom label glyphs untouched', () => { const atomLabel = ""; const bond = ""; - const result = applyBondColors(`${atomLabel}${bond}`, { 0: '#0072B2' }); + const result = applyBondColors(`${atomLabel}${bond}`, { 0: '#355214' }); expect(result).toContain(atomLabel); - expect(result).toContain('fill:none;stroke:#0072B2'); + expect(result).toContain('fill:none;stroke:#355214'); }); it('leaves non-hex bond strokes unchanged', () => { const svg = ""; - expect(applyBondColors(svg, { 0: '#0072B2' })).toBe(svg); + expect(applyBondColors(svg, { 0: '#355214' })).toBe(svg); }); it('recolours lowercase hex bond strokes', () => { const svg = ""; - expect(applyBondColors(svg, { 0: '#0072B2' })).toContain('stroke:#0072B2'); + expect(applyBondColors(svg, { 0: '#355214' })).toContain('stroke:#355214'); }); }); From b58ea9891f4d2f0aea7aa4b0add95ff167d71762 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Tue, 1 Sep 2026 21:07:57 -0500 Subject: [PATCH 2/3] feat(biochem): off-white molecule canvas and thin dark outline on atom glyphs Visual experiment requested by Sam Seaver for the atom-mapping display. Adds two pure SVG post-processing helpers in lib/utils/moleculeHighlights.ts: - applyMoleculeBackground: recolours RDKit's unclassed background rect from #FFFFFF to off-white #F5F5F5, inserting one when absent. - applyAtomGlyphOutline: gives every atom letter path a thin #333333 stroke (width 1.0) with paint-order='stroke', so the outline paints beneath the fill and each atom keeps its mapping colour while staying distinct from the lighter canvas. Both are applied once in MoleculeRenderer, after the existing colour passes and immediately before the SVG is committed to state. Only elements whose class is anchored atom-N are outlined, so highlight ellipses, bond paths and unclassed geometry are untouched. Fallback, PNG and error branches are unchanged. Stroke width chosen from rendered-pixel measurement: RDKit's viewBox equals the pixel size, so 1.0 is a constant 0.5px visible rim at both the 134px inline molecule and the 360px enlarged preview, darkening the outline without fattening the glyphs. --- components/ui/MoleculeRenderer.tsx | 4 +- lib/utils/moleculeHighlights.ts | 45 +++++++++++++++++++ .../unit/components/MoleculeRenderer.test.tsx | 2 + tests/unit/utils/moleculeHighlights.test.ts | 33 ++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/components/ui/MoleculeRenderer.tsx b/components/ui/MoleculeRenderer.tsx index db6273fa..e7bfe141 100644 --- a/components/ui/MoleculeRenderer.tsx +++ b/components/ui/MoleculeRenderer.tsx @@ -6,7 +6,7 @@ import Skeleton from '@mui/material/Skeleton'; import Typography from '@mui/material/Typography'; import { getRDKit } from '@/lib/rdkit'; import { getCompoundImageUrl } from '@/lib/api/biochem'; -import { applyAtomLabelColors, applyBondColors, buildExplicitAtomLabels, buildMoleculeHighlightPlan, elementInventoryFromMolJson, elementSymbolForAtomicNumber } from '@/lib/utils/moleculeHighlights'; +import { applyAtomGlyphOutline, applyAtomLabelColors, applyBondColors, applyMoleculeBackground, buildExplicitAtomLabels, buildMoleculeHighlightPlan, elementInventoryFromMolJson, elementSymbolForAtomicNumber } from '@/lib/utils/moleculeHighlights'; import type { HeavyAtomGraph } from '@/lib/utils/inchiAtomOrder'; /** @@ -164,6 +164,8 @@ export default function MoleculeRenderer({ if (currentBondColors && Object.keys(currentBondColors).length > 0) { svg = applyBondColors(svg, currentBondColors); } + svg = applyMoleculeBackground(svg); + svg = applyAtomGlyphOutline(svg); if (!cancelled) { setSvgString(svg); setState('svg'); diff --git a/lib/utils/moleculeHighlights.ts b/lib/utils/moleculeHighlights.ts index a2690696..47b7a28e 100644 --- a/lib/utils/moleculeHighlights.ts +++ b/lib/utils/moleculeHighlights.ts @@ -109,6 +109,51 @@ export function buildMoleculeHighlightPlan( return { atomColors, bondColors }; } +export const DEFAULT_MOLECULE_BACKGROUND_COLOR = '#F5F5F5'; +export const DEFAULT_ATOM_GLYPH_OUTLINE_COLOR = '#333333'; +export const DEFAULT_ATOM_GLYPH_OUTLINE_WIDTH = 1.0; + +export function applyMoleculeBackground(svg: string, color = DEFAULT_MOLECULE_BACKGROUND_COLOR): string { + if (typeof svg !== 'string' || !svg) return svg; + + try { + let backgroundFound = false; + const withBackground = svg.replace(/]*>/gi, (tag) => { + if (backgroundFound || /\bclass=(['"])(.*?)\1/.test(tag)) return tag; + const styleMatch = /\bstyle=(['"])(.*?)\1/.exec(tag); + if (!styleMatch || !/stroke:\s*none\b/i.test(styleMatch[2])) return tag; + backgroundFound = true; + return tag.replace(/fill:\s*#[0-9a-f]{6}/i, `fill:${color}`); + }); + if (backgroundFound) return withBackground; + return svg.replace(/]*>/i, (tag) => ( + `${tag}` + )); + } catch { + return svg; + } +} + +export function applyAtomGlyphOutline( + svg: string, + options?: { color?: string; width?: number }, +): string { + if (typeof svg !== 'string' || !svg) return svg; + + const color = options?.color ?? DEFAULT_ATOM_GLYPH_OUTLINE_COLOR; + const width = options?.width ?? DEFAULT_ATOM_GLYPH_OUTLINE_WIDTH; + + try { + return svg.replace(/]*>/gi, (tag) => { + const classMatch = /\bclass=(['"])(.*?)\1/.exec(tag); + if (!classMatch || !/^atom-(\d+)(?:\s|$)/.test(classMatch[2]) || /\bstroke\s*=/.test(tag)) return tag; + return tag.replace(/^>): string { if (typeof svg !== 'string' || !atomColors || Object.keys(atomColors).length === 0) return svg; diff --git a/tests/unit/components/MoleculeRenderer.test.tsx b/tests/unit/components/MoleculeRenderer.test.tsx index 6c427e4b..7fb30587 100644 --- a/tests/unit/components/MoleculeRenderer.test.tsx +++ b/tests/unit/components/MoleculeRenderer.test.tsx @@ -20,6 +20,8 @@ describe('MoleculeRenderer', () => { it('colours atom labels without requesting RDKit highlights', async () => { const { container } = render(); await waitFor(() => expect(container.querySelector('[class="atom-0"]')?.getAttribute('fill')).toBe('#123456')); + expect(container.querySelector('[class="atom-0"]')?.getAttribute('stroke')).toBe('#333333'); + expect(container.querySelector('[class="atom-0"]')?.getAttribute('stroke-width')).toBe('1'); const mol = getMol.mock.results.at(-1)?.value; expect(mol.get_svg).toHaveBeenCalled(); expect(mol.get_svg_with_highlights).not.toHaveBeenCalled(); diff --git a/tests/unit/utils/moleculeHighlights.test.ts b/tests/unit/utils/moleculeHighlights.test.ts index 69a4144f..2ce0e711 100644 --- a/tests/unit/utils/moleculeHighlights.test.ts +++ b/tests/unit/utils/moleculeHighlights.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { + applyAtomGlyphOutline, applyAtomLabelColors, applyBondColors, + applyMoleculeBackground, buildExplicitAtomLabels, buildMoleculeHighlightPlan, elementInventoryFromMolJson, @@ -71,6 +73,37 @@ describe('moleculeHighlights', () => { expect(plan.bondColors).toEqual({ 0: '#ff0000' }); }); + it('replaces the RDKit canvas background and inserts one when absent', () => { + const rdkitSvg = ""; + expect(applyMoleculeBackground(rdkitSvg)).toContain("fill:#F5F5F5;stroke:none"); + expect(applyMoleculeBackground('')).toContain(""); + }); + + it('leaves classified rects untouched', () => { + const rect = ""; + expect(applyMoleculeBackground(`${rect}`)).toContain(rect); + }); + + it('outlines only atom glyph paths while preserving fill', () => { + const glyph = ""; + const result = applyAtomGlyphOutline(glyph); + expect(result).toContain("stroke='#333333'"); + expect(result).toContain("stroke-width='1'"); + expect(result).toContain("paint-order='stroke'"); + expect(result).toContain("fill='#FF0000'"); + expect(applyAtomGlyphOutline(BOND_PATH)).toBe(BOND_PATH); + const ellipse = ""; + expect(applyAtomGlyphOutline(ellipse)).toBe(ellipse); + expect(applyAtomGlyphOutline(result)).toBe(result); + }); + + it('returns non-string and empty SVG inputs unchanged', () => { + expect(applyMoleculeBackground('')).toBe(''); + expect(applyAtomGlyphOutline('')).toBe(''); + expect(applyMoleculeBackground(null as unknown as string)).toBeNull(); + expect(applyAtomGlyphOutline(null as unknown as string)).toBeNull(); + }); + it('recolours atom label fill attributes and styles', () => { const fillAttribute = ""; const fillStyle = ""; From c906a1946f4dea8f2ef7e239d5332dfbce1100b0 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Tue, 1 Sep 2026 23:28:18 -0500 Subject: [PATCH 3/3] chore(release): 3.6.0 --- CHANGELOG.md | 5 ++++- VERSION.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 395f6ac1..64e9ba23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- -## [3.5.2] - 2026-09-01 +## [3.6.0] - 2026-09-01 + +### Added +- Molecule structure images now render on an off-white `#F5F5F5` canvas with a thin `#333333` outline on atom glyphs, so coloured atom labels stay legible. ### Fixed - Atom-mapping colours in reactions with many mapped groups are now clearly distinguishable; the warm/brown band was removed and mapped colours no longer collide with unmapped phosphorus or oxygen atom colours. diff --git a/VERSION.md b/VERSION.md index 87ce4929..40c341bd 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -3.5.2 +3.6.0 diff --git a/package-lock.json b/package-lock.json index 26f600e5..e8142d48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "modelseed-ui", - "version": "3.5.1", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modelseed-ui", - "version": "3.5.1", + "version": "3.6.0", "dependencies": { "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 8b75212a..0c89881e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modelseed-ui", - "version": "3.5.2", + "version": "3.6.0", "private": true, "scripts": { "predev": "node scripts/sync-version-from-env.mjs",