react-cheminfo 0.73.0 → 0.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/lib/ecosystem/core/sites.d.ts.map +1 -1
  2. package/lib/ecosystem/core/sites.js +26 -0
  3. package/lib/ecosystem/core/sites.js.map +1 -1
  4. package/lib/ecosystem/core/types.d.ts +1 -1
  5. package/lib/ecosystem/core/types.d.ts.map +1 -1
  6. package/lib/ecosystem/ui/glyphs.d.ts.map +1 -1
  7. package/lib/ecosystem/ui/glyphs.js +7 -0
  8. package/lib/ecosystem/ui/glyphs.js.map +1 -1
  9. package/lib/formula/core/elementRanges.d.ts +25 -0
  10. package/lib/formula/core/elementRanges.d.ts.map +1 -0
  11. package/lib/formula/core/elementRanges.js +35 -0
  12. package/lib/formula/core/elementRanges.js.map +1 -0
  13. package/lib/formula/core/formulaCandidates.d.ts +59 -0
  14. package/lib/formula/core/formulaCandidates.d.ts.map +1 -0
  15. package/lib/formula/core/formulaCandidates.js +50 -0
  16. package/lib/formula/core/formulaCandidates.js.map +1 -0
  17. package/lib/formula/core/index.d.ts +4 -0
  18. package/lib/formula/core/index.d.ts.map +1 -1
  19. package/lib/formula/core/index.js +2 -0
  20. package/lib/formula/core/index.js.map +1 -1
  21. package/lib/formula/ui/ElementRangeFields.d.ts +19 -0
  22. package/lib/formula/ui/ElementRangeFields.d.ts.map +1 -0
  23. package/lib/formula/ui/ElementRangeFields.js +23 -0
  24. package/lib/formula/ui/ElementRangeFields.js.map +1 -0
  25. package/lib/formula/ui/FormulaLookup.d.ts +47 -0
  26. package/lib/formula/ui/FormulaLookup.d.ts.map +1 -0
  27. package/lib/formula/ui/FormulaLookup.js +74 -0
  28. package/lib/formula/ui/FormulaLookup.js.map +1 -0
  29. package/lib/formula/ui/FormulaLookupResults.d.ts +20 -0
  30. package/lib/formula/ui/FormulaLookupResults.d.ts.map +1 -0
  31. package/lib/formula/ui/FormulaLookupResults.js +45 -0
  32. package/lib/formula/ui/FormulaLookupResults.js.map +1 -0
  33. package/lib/formula/ui/index.d.ts +2 -0
  34. package/lib/formula/ui/index.d.ts.map +1 -1
  35. package/lib/formula/ui/index.js +1 -0
  36. package/lib/formula/ui/index.js.map +1 -1
  37. package/lib/i18n/core/chromeCatalog.d.ts +1 -1
  38. package/lib/i18n/core/chromeCatalog.d.ts.map +1 -1
  39. package/lib/locales/de.json +38 -0
  40. package/lib/locales/en.json +38 -0
  41. package/lib/locales/es.json +38 -0
  42. package/lib/locales/fr.json +38 -0
  43. package/lib/locales/it.json +38 -0
  44. package/lib/structure/core/fragmentIdCode.d.ts +15 -0
  45. package/lib/structure/core/fragmentIdCode.d.ts.map +1 -0
  46. package/lib/structure/core/fragmentIdCode.js +25 -0
  47. package/lib/structure/core/fragmentIdCode.js.map +1 -0
  48. package/lib/structure/core/index.d.ts +2 -0
  49. package/lib/structure/core/index.d.ts.map +1 -1
  50. package/lib/structure/core/index.js +1 -0
  51. package/lib/structure/core/index.js.map +1 -1
  52. package/lib/structure/core/structureSearch.d.ts +44 -0
  53. package/lib/structure/core/structureSearch.d.ts.map +1 -0
  54. package/lib/structure/core/structureSearch.js +31 -0
  55. package/lib/structure/core/structureSearch.js.map +1 -0
  56. package/lib/structure/core/substructureMatch.d.ts +33 -0
  57. package/lib/structure/core/substructureMatch.d.ts.map +1 -0
  58. package/lib/structure/core/substructureMatch.js +73 -0
  59. package/lib/structure/core/substructureMatch.js.map +1 -0
  60. package/lib/structure/ui/Structure.d.ts +18 -0
  61. package/lib/structure/ui/Structure.d.ts.map +1 -1
  62. package/lib/structure/ui/Structure.js +2 -2
  63. package/lib/structure/ui/Structure.js.map +1 -1
  64. package/lib/structure/ui/StructureSearchDialog.d.ts +55 -0
  65. package/lib/structure/ui/StructureSearchDialog.d.ts.map +1 -0
  66. package/lib/structure/ui/StructureSearchDialog.js +77 -0
  67. package/lib/structure/ui/StructureSearchDialog.js.map +1 -0
  68. package/lib/structure/ui/StructureSvg.d.ts +9 -0
  69. package/lib/structure/ui/StructureSvg.d.ts.map +1 -1
  70. package/lib/structure/ui/StructureSvg.js +7 -2
  71. package/lib/structure/ui/StructureSvg.js.map +1 -1
  72. package/lib/structure/ui/index.d.ts +4 -0
  73. package/lib/structure/ui/index.d.ts.map +1 -1
  74. package/lib/structure/ui/index.js +2 -0
  75. package/lib/structure/ui/index.js.map +1 -1
  76. package/lib/structure/ui/useStructureSearchModes.d.ts +18 -0
  77. package/lib/structure/ui/useStructureSearchModes.d.ts.map +1 -0
  78. package/lib/structure/ui/useStructureSearchModes.js +36 -0
  79. package/lib/structure/ui/useStructureSearchModes.js.map +1 -0
  80. package/lib/structure/ui/useToolbarFloor.d.ts.map +1 -1
  81. package/lib/structure/ui/useToolbarFloor.js +20 -10
  82. package/lib/structure/ui/useToolbarFloor.js.map +1 -1
  83. package/package.json +4 -3
  84. package/src/ecosystem/core/sites.ts +26 -0
  85. package/src/ecosystem/core/types.ts +3 -1
  86. package/src/ecosystem/ui/glyphs.tsx +46 -0
  87. package/src/formula/core/elementRanges.ts +44 -0
  88. package/src/formula/core/formulaCandidates.ts +109 -0
  89. package/src/formula/core/index.ts +15 -0
  90. package/src/formula/ui/ElementRangeFields.tsx +65 -0
  91. package/src/formula/ui/FormulaLookup.tsx +184 -0
  92. package/src/formula/ui/FormulaLookupResults.tsx +89 -0
  93. package/src/formula/ui/index.ts +2 -0
  94. package/src/locales/de.json +38 -0
  95. package/src/locales/en.json +38 -0
  96. package/src/locales/es.json +38 -0
  97. package/src/locales/fr.json +38 -0
  98. package/src/locales/it.json +38 -0
  99. package/src/structure/core/fragmentIdCode.ts +25 -0
  100. package/src/structure/core/index.ts +10 -0
  101. package/src/structure/core/structureSearch.ts +68 -0
  102. package/src/structure/core/substructureMatch.ts +85 -0
  103. package/src/structure/ui/Structure.tsx +24 -0
  104. package/src/structure/ui/StructureSearchDialog.tsx +218 -0
  105. package/src/structure/ui/StructureSvg.tsx +35 -0
  106. package/src/structure/ui/index.ts +4 -0
  107. package/src/structure/ui/useStructureSearchModes.ts +51 -0
  108. package/src/structure/ui/useToolbarFloor.ts +19 -9
  109. package/styles/chrome.css +164 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The dialog a structure search is drawn in, shared by every site that
3
+ * filters a collection by structure.
4
+ *
5
+ * This file stays free of `openchemlib`: the editor loads it behind its own
6
+ * `React.lazy`, and an example is read only once it is clicked.
7
+ */
8
+
9
+ import {
10
+ Button,
11
+ Dialog,
12
+ DialogBody,
13
+ DialogFooter,
14
+ HTMLSelect,
15
+ } from '@blueprintjs/core';
16
+ import type { ReactElement, ReactNode } from 'react';
17
+ import { useState } from 'react';
18
+
19
+ import { formatPercent } from '../../format/core/numbers.ts';
20
+ import type { ChromeKey } from '../../i18n/core/chromeCatalog.ts';
21
+ import { useChromeT } from '../../i18n/ui/useT.ts';
22
+ import { isEmptyIdCode } from '../core/editorValue.ts';
23
+ import type {
24
+ StructureSearchExample,
25
+ StructureSearchMode,
26
+ StructureSearchQuery,
27
+ } from '../core/structureSearch.ts';
28
+ import {
29
+ STRUCTURE_SEARCH_MODES,
30
+ similarityThresholds,
31
+ } from '../core/structureSearch.ts';
32
+
33
+ import { StructureEditor } from './StructureEditor.tsx';
34
+ import { useStructureDraft } from './useStructureDraft.ts';
35
+ import { useStructureSearchModes } from './useStructureSearchModes.ts';
36
+
37
+ /** What {@link StructureSearchDialog} needs. */
38
+ export interface StructureSearchDialogProps {
39
+ /** Whether the dialog is on screen. */
40
+ isOpen: boolean;
41
+ /** The question the dialog opens on; its drawing is loaded into the canvas. */
42
+ value: StructureSearchQuery;
43
+ /**
44
+ * Called when the dialog closes — by Apply, by Escape or by its × alike —
45
+ * with what it then holds, so no drawing is lost to a stray Escape. The
46
+ * idCode is empty when the canvas was cleared.
47
+ */
48
+ onClose: (query: StructureSearchQuery) => void;
49
+ /**
50
+ * The modes offered, in order.
51
+ * @default STRUCTURE_SEARCH_MODES
52
+ */
53
+ modes?: readonly StructureSearchMode[];
54
+ /**
55
+ * The fragments offered under the canvas.
56
+ * @default benzene, pyridine, amide, sulfonamide and trifluoromethyl
57
+ */
58
+ examples?: readonly StructureSearchExample[];
59
+ /**
60
+ * More controls beside the similarity threshold, such as the fingerprint a
61
+ * site compares.
62
+ * @default undefined
63
+ */
64
+ similarityControls?: ReactNode;
65
+ /**
66
+ * Whether the dialog is rendered through a portal on `document.body`.
67
+ * @default true
68
+ */
69
+ usePortal?: boolean;
70
+ }
71
+
72
+ const DEFAULT_EXAMPLES: ReadonlyArray<{ key: ChromeKey; smiles: string }> = [
73
+ { key: 'structure.search.exampleBenzene', smiles: 'c1ccccc1' },
74
+ { key: 'structure.search.examplePyridine', smiles: 'c1ccncc1' },
75
+ { key: 'structure.search.exampleAmide', smiles: 'CC(=O)N' },
76
+ { key: 'structure.search.exampleSulfonamide', smiles: 'S(=O)(=O)N' },
77
+ { key: 'structure.search.exampleTrifluoromethyl', smiles: 'FC(F)F' },
78
+ ];
79
+
80
+ /**
81
+ * Draw a structure and say how it is matched: contained, similar, or the same
82
+ * compound with or without its stereochemistry and tautomerism.
83
+ *
84
+ * What is drawn stays a draft until the dialog closes, and the canvas is never
85
+ * reloaded under the pen: only opening the dialog and clicking an example put
86
+ * a structure in it.
87
+ * @param props - See {@link StructureSearchDialogProps}.
88
+ * @returns The dialog.
89
+ */
90
+ export function StructureSearchDialog(
91
+ props: StructureSearchDialogProps,
92
+ ): ReactElement {
93
+ const {
94
+ isOpen,
95
+ value,
96
+ onClose,
97
+ modes = STRUCTURE_SEARCH_MODES,
98
+ examples,
99
+ similarityControls,
100
+ usePortal = true,
101
+ } = props;
102
+ const t = useChromeT();
103
+ const modeLabels = useStructureSearchModes(modes);
104
+ const draft = useStructureDraft(value.idCode);
105
+ const [mode, setMode] = useState(value.mode);
106
+ const [threshold, setThreshold] = useState(value.threshold);
107
+ // An example is read by a lazily loaded module: until it is in the canvas,
108
+ // Apply would hand back the drawing it replaces.
109
+ const [loading, setLoading] = useState(false);
110
+ const offered: readonly StructureSearchExample[] =
111
+ examples ??
112
+ DEFAULT_EXAMPLES.map((example) => ({
113
+ label: t(example.key),
114
+ smiles: example.smiles,
115
+ }));
116
+
117
+ function close(): void {
118
+ const idCode = isEmptyIdCode(draft.value) ? '' : draft.value;
119
+ onClose({ idCode, mode, threshold });
120
+ }
121
+
122
+ async function loadExample(smiles: string): Promise<void> {
123
+ setLoading(true);
124
+ try {
125
+ const { fragmentIdCode } = await import('../core/fragmentIdCode.ts');
126
+ const idCode = fragmentIdCode(smiles);
127
+ if (idCode !== '') draft.load(idCode);
128
+ } finally {
129
+ setLoading(false);
130
+ }
131
+ }
132
+
133
+ return (
134
+ <Dialog
135
+ isOpen={isOpen}
136
+ onOpening={() => {
137
+ draft.load(value.idCode);
138
+ setMode(modes.includes(value.mode) ? value.mode : (modes[0] ?? mode));
139
+ setThreshold(value.threshold);
140
+ }}
141
+ onClose={close}
142
+ usePortal={usePortal}
143
+ title={t('structure.search.title')}
144
+ className="structure-search"
145
+ >
146
+ <DialogBody>
147
+ <div className="structure-search__row">
148
+ {modeLabels.map((option) => (
149
+ <Button
150
+ key={option.mode}
151
+ size="small"
152
+ active={mode === option.mode}
153
+ intent={mode === option.mode ? 'primary' : 'none'}
154
+ text={option.label}
155
+ title={option.help}
156
+ onClick={() => setMode(option.mode)}
157
+ />
158
+ ))}
159
+ </div>
160
+ {mode === 'similarity' && (
161
+ <div className="structure-search__row">
162
+ <span className="structure-search__label">
163
+ {t('structure.search.similarityLabel')}
164
+ </span>
165
+ <HTMLSelect
166
+ minimal
167
+ value={threshold}
168
+ aria-label={t('structure.search.threshold')}
169
+ title={t('structure.search.thresholdHelp')}
170
+ options={similarityThresholds(threshold).map((option) => ({
171
+ value: option,
172
+ label: t('structure.search.thresholdOption', {
173
+ percent: formatPercent(option, 0),
174
+ }),
175
+ }))}
176
+ onChange={(event) =>
177
+ setThreshold(Number(event.currentTarget.value))
178
+ }
179
+ />
180
+ {similarityControls}
181
+ </div>
182
+ )}
183
+ <StructureEditor
184
+ fragment
185
+ value={draft.value}
186
+ revision={draft.revision}
187
+ onChange={(change) => draft.update(change.idCode)}
188
+ />
189
+ <div className="structure-search__row structure-search__examples">
190
+ <span className="structure-search__label">
191
+ {t('structure.search.examples')}
192
+ </span>
193
+ {offered.map((example) => (
194
+ <Button
195
+ key={example.label}
196
+ variant="minimal"
197
+ size="small"
198
+ text={example.label}
199
+ title={example.help}
200
+ onClick={() => void loadExample(example.smiles)}
201
+ />
202
+ ))}
203
+ </div>
204
+ </DialogBody>
205
+ <DialogFooter
206
+ actions={
207
+ <Button
208
+ intent="primary"
209
+ icon="search"
210
+ text={t('structure.search.apply')}
211
+ loading={loading}
212
+ onClick={close}
213
+ />
214
+ }
215
+ />
216
+ </Dialog>
217
+ );
218
+ }
@@ -17,6 +17,7 @@ import type { AtomLabelPlacement } from '../core/atomLabels.ts';
17
17
  import { applyAtomLabels } from '../core/atomLabels.ts';
18
18
  import { sourceMolecule } from '../core/sourceMolecule.ts';
19
19
  import type { StructureSource } from '../core/structureSource.ts';
20
+ import { substructureMatch } from '../core/substructureMatch.ts';
20
21
 
21
22
  import { StructurePlaceholder } from './StructurePlaceholder.tsx';
22
23
 
@@ -46,6 +47,15 @@ interface StructureSvgProps {
46
47
  bondHighlight?: number[];
47
48
  /** The colour the highlighted bonds are painted. */
48
49
  bondHighlightColor: string;
50
+ /**
51
+ * A query idCode whose matches are painted in place of the highlights.
52
+ * @default undefined
53
+ */
54
+ substructure?: string;
55
+ /** The colour the substructure match is painted. */
56
+ substructureColor: string;
57
+ /** How opaque the substructure match is painted, from 0 to 1. */
58
+ substructureOpacity: number;
49
59
  /** Write the index of every atom next to it. */
50
60
  showAtomNumber: boolean;
51
61
  /** Write the index of every bond next to it. */
@@ -94,6 +104,13 @@ export function StructureSvg(props: StructureSvgProps): ReactElement {
94
104
  onAtomClick,
95
105
  onBondClick,
96
106
  showCIPParity,
107
+ atomHighlight,
108
+ atomHighlightColor,
109
+ bondHighlight,
110
+ bondHighlightColor,
111
+ substructure,
112
+ substructureColor,
113
+ substructureOpacity,
97
114
  ...rest
98
115
  } = props;
99
116
  const { kind, value, coordinates } = source;
@@ -106,6 +123,14 @@ export function StructureSvg(props: StructureSvgProps): ReactElement {
106
123
  () => readDrawing(kind, value, coordinates, labelsKey, atomLabelPlacement),
107
124
  [kind, value, coordinates, labelsKey, atomLabelPlacement],
108
125
  );
126
+ // Matched on the very molecule drawn, so the indices are the drawn atoms.
127
+ const match = useMemo(
128
+ () =>
129
+ drawing === null || substructure === undefined
130
+ ? null
131
+ : substructureMatch(drawing.molecule, substructure),
132
+ [drawing, substructure],
133
+ );
109
134
 
110
135
  if (drawing === null) {
111
136
  return (
@@ -118,6 +143,16 @@ export function StructureSvg(props: StructureSvgProps): ReactElement {
118
143
  return (
119
144
  <SvgRenderer
120
145
  {...rest}
146
+ atomHighlight={match === null ? atomHighlight : match.atoms}
147
+ atomHighlightColor={
148
+ match === null ? atomHighlightColor : substructureColor
149
+ }
150
+ bondHighlight={match === null ? bondHighlight : match.bonds}
151
+ bondHighlightColor={
152
+ match === null ? bondHighlightColor : substructureColor
153
+ }
154
+ atomHighlightOpacity={match === null ? undefined : substructureOpacity}
155
+ bondHighlightOpacity={match === null ? undefined : substructureOpacity}
121
156
  molecule={drawing.molecule}
122
157
  noCarbonLabelWithCustomLabel={drawing.labelled}
123
158
  suppressCIPParity={!showCIPParity}
@@ -25,6 +25,10 @@ export type { StructureDropProps } from './StructureDrop.tsx';
25
25
  export { StructureDrop } from './StructureDrop.tsx';
26
26
  export type { StructureEditorHelpProps } from './StructureEditorHelp.tsx';
27
27
  export { StructureEditorHelp } from './StructureEditorHelp.tsx';
28
+ export type { StructureSearchDialogProps } from './StructureSearchDialog.tsx';
29
+ export { StructureSearchDialog } from './StructureSearchDialog.tsx';
30
+ export type { StructureSearchModeLabel } from './useStructureSearchModes.ts';
31
+ export { useStructureSearchModes } from './useStructureSearchModes.ts';
28
32
  export type { StructureExportDialogProps } from './StructureExportDialog.tsx';
29
33
  export { StructureExportDialog } from './StructureExportDialog.tsx';
30
34
  export type { StructureExportButtonProps } from './StructureExportButton.tsx';
@@ -0,0 +1,51 @@
1
+ import type { ChromeKey } from '../../i18n/core/chromeCatalog.ts';
2
+ import { useChromeT } from '../../i18n/ui/useT.ts';
3
+ import type { StructureSearchMode } from '../core/structureSearch.ts';
4
+ import { STRUCTURE_SEARCH_MODES } from '../core/structureSearch.ts';
5
+
6
+ /** A mode, with what a button says and what its tooltip adds. */
7
+ export interface StructureSearchModeLabel {
8
+ /** The mode. */
9
+ mode: StructureSearchMode;
10
+ /** What the button says. */
11
+ label: string;
12
+ /** One line on what the mode finds. */
13
+ help: string;
14
+ }
15
+
16
+ const MODE_KEYS: Record<StructureSearchMode, [ChromeKey, ChromeKey]> = {
17
+ substructure: [
18
+ 'structure.search.substructure',
19
+ 'structure.search.substructureHelp',
20
+ ],
21
+ similarity: [
22
+ 'structure.search.similarity',
23
+ 'structure.search.similarityHelp',
24
+ ],
25
+ exact: ['structure.search.exact', 'structure.search.exactHelp'],
26
+ exactNoStereo: [
27
+ 'structure.search.exactNoStereo',
28
+ 'structure.search.exactNoStereoHelp',
29
+ ],
30
+ exactNoStereoTautomer: [
31
+ 'structure.search.exactNoStereoTautomer',
32
+ 'structure.search.exactNoStereoTautomerHelp',
33
+ ],
34
+ };
35
+
36
+ /**
37
+ * The modes in the reader's language, for a site that shows the mode in force
38
+ * beside the drawing.
39
+ * @param modes - The modes, in order.
40
+ * @returns Each mode with its label and its one-line help.
41
+ */
42
+ export function useStructureSearchModes(
43
+ modes: readonly StructureSearchMode[] = STRUCTURE_SEARCH_MODES,
44
+ ): StructureSearchModeLabel[] {
45
+ const t = useChromeT();
46
+ return modes.map((mode) => ({
47
+ mode,
48
+ label: t(MODE_KEYS[mode][0]),
49
+ help: t(MODE_KEYS[mode][1]),
50
+ }));
51
+ }
@@ -41,18 +41,28 @@ export function useToolbarFloor(
41
41
  if (container === null) return;
42
42
 
43
43
  return watchToolbar(container, (toolbar) => {
44
+ let frame = 0;
44
45
  const size = new ResizeObserver(() => {
45
- // offsetHeight, because a dialog opens under a scaling transform and a
46
- // measured rectangle would be that of the half drawn toolbar.
47
- const height = toolbar.offsetHeight;
48
- // Zero is a toolbar being torn down or not yet laid out, never a
49
- // toolbar that needs no room: writing the fallback for it would undo a
50
- // correct floor and leave the palette cut off.
51
- if (height === 0) return;
52
- container.style.minHeight = `${toolbarFloorHeight(height, minHeight)}px`;
46
+ // Written on the next frame: growing the box from inside this callback
47
+ // resizes the editor element above the toolbar, whose own observer the
48
+ // browser then drops with a "ResizeObserver loop" error.
49
+ cancelAnimationFrame(frame);
50
+ frame = requestAnimationFrame(() => {
51
+ // offsetHeight, because a dialog opens under a scaling transform and
52
+ // a measured rectangle would be that of the half drawn toolbar.
53
+ const height = toolbar.offsetHeight;
54
+ // Zero is a toolbar being torn down or not yet laid out, never a
55
+ // toolbar that needs no room: writing the fallback for it would undo
56
+ // a correct floor and leave the palette cut off.
57
+ if (height === 0) return;
58
+ container.style.minHeight = `${toolbarFloorHeight(height, minHeight)}px`;
59
+ });
53
60
  });
54
61
  size.observe(toolbar);
55
- return () => size.disconnect();
62
+ return () => {
63
+ cancelAnimationFrame(frame);
64
+ size.disconnect();
65
+ };
56
66
  });
57
67
  }, [minHeight, revision]);
58
68
 
package/styles/chrome.css CHANGED
@@ -889,6 +889,37 @@ a.source-badge:hover {
889
889
  }
890
890
  }
891
891
 
892
+ /* The structure search dialog: the modes above the canvas, the fragments to
893
+ start from under it. A drag on the canvas that leaves it would otherwise
894
+ select the labels. */
895
+ .structure-search {
896
+ width: min(56rem, calc(100vw - 2rem));
897
+ user-select: none;
898
+ }
899
+
900
+ .structure-search :is(input, textarea) {
901
+ user-select: text;
902
+ }
903
+
904
+ .structure-search__row {
905
+ display: flex;
906
+ flex-wrap: wrap;
907
+ align-items: center;
908
+ margin-bottom: 0.6rem;
909
+ gap: 0.35rem;
910
+ }
911
+
912
+ .structure-search__examples {
913
+ margin-top: 0.6rem;
914
+ margin-bottom: 0;
915
+ }
916
+
917
+ .structure-search__label {
918
+ margin-right: 0.25rem;
919
+ color: var(--text-muted);
920
+ font-size: 0.8125rem;
921
+ }
922
+
892
923
  /* ── Tokens for molecules.cheminfo.org ─────────────────────────────────────── */
893
924
 
894
925
  /* The heading, when the tokens were checked, and the button asking again, on
@@ -1729,6 +1760,139 @@ span.taxon-lineage__name {
1729
1760
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1730
1761
  }
1731
1762
 
1763
+ /* ── A formula lookup ────────────────────────────────────────────────────── */
1764
+
1765
+ /* Only the panel's own look: where it sits — a column beside a question, a
1766
+ sticky rail, a shadow — is the page's, through the class it passes. */
1767
+ .formula-lookup {
1768
+ display: flex;
1769
+ flex-direction: column;
1770
+ border: 1px solid var(--border);
1771
+ background: var(--surface);
1772
+ }
1773
+
1774
+ .formula-lookup__header {
1775
+ display: flex;
1776
+ align-items: center;
1777
+ padding: 0.25rem 0.5rem 0.25rem 0.75rem;
1778
+ border-bottom: 1px solid var(--border);
1779
+ color: var(--text-muted);
1780
+ font-size: 0.8rem;
1781
+ font-weight: 700;
1782
+ gap: 0.25rem;
1783
+ letter-spacing: 0.04em;
1784
+ text-transform: uppercase;
1785
+ }
1786
+
1787
+ .formula-lookup__header > .bp6-button {
1788
+ margin-left: auto;
1789
+ }
1790
+
1791
+ .formula-lookup__body {
1792
+ display: flex;
1793
+ flex-direction: column;
1794
+ padding: 0.75rem;
1795
+ gap: 0.5rem;
1796
+ }
1797
+
1798
+ .formula-lookup__field {
1799
+ display: flex;
1800
+ flex-direction: column;
1801
+ color: var(--text-muted);
1802
+ font-size: 0.8rem;
1803
+ font-weight: 600;
1804
+ gap: 2px;
1805
+ }
1806
+
1807
+ .formula-lookup__hint {
1808
+ color: var(--text-muted);
1809
+ font-size: 0.75rem;
1810
+ line-height: 1.4;
1811
+ }
1812
+
1813
+ /* Two elements to a line: each box holds two or three digits. */
1814
+ .formula-lookup__ranges {
1815
+ display: grid;
1816
+ margin-top: 0.25rem;
1817
+ gap: 0.25rem 0.5rem;
1818
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1819
+ }
1820
+
1821
+ .formula-lookup__range {
1822
+ display: flex;
1823
+ min-width: 0;
1824
+ align-items: center;
1825
+ gap: 3px;
1826
+ }
1827
+
1828
+ .formula-lookup__range .bp6-input-group {
1829
+ min-width: 0;
1830
+ flex: 1 1 0;
1831
+ }
1832
+
1833
+ .formula-lookup__range .bp6-input {
1834
+ padding-right: 4px;
1835
+ padding-left: 4px;
1836
+ text-align: center;
1837
+ }
1838
+
1839
+ .formula-lookup__element {
1840
+ width: 1.3rem;
1841
+ font-variant-numeric: tabular-nums;
1842
+ font-weight: 700;
1843
+ }
1844
+
1845
+ .formula-lookup__dash {
1846
+ color: var(--text-muted);
1847
+ font-size: 0.75rem;
1848
+ }
1849
+
1850
+ .formula-lookup__results {
1851
+ display: flex;
1852
+ flex-direction: column;
1853
+ padding: 0.75rem;
1854
+ border-top: 1px solid var(--border);
1855
+ gap: 0.25rem;
1856
+ }
1857
+
1858
+ /* A search still to come leaves the band empty rather than collapsing it. */
1859
+ .formula-lookup__results:empty {
1860
+ min-height: 2.5rem;
1861
+ }
1862
+
1863
+ .formula-lookup__count {
1864
+ color: var(--text-muted);
1865
+ font-size: 0.75rem;
1866
+ font-weight: 600;
1867
+ }
1868
+
1869
+ .formula-lookup__list {
1870
+ display: flex;
1871
+ overflow-y: auto;
1872
+ max-height: 40vh;
1873
+ flex-direction: column;
1874
+ padding: 0;
1875
+ margin: 0;
1876
+ list-style: none;
1877
+ }
1878
+
1879
+ /* A formula is never split across two lines. */
1880
+ .formula-lookup__list li {
1881
+ display: flex;
1882
+ align-items: baseline;
1883
+ justify-content: space-between;
1884
+ padding: 2px 0;
1885
+ font-size: 0.85rem;
1886
+ gap: 0.5rem;
1887
+ white-space: nowrap;
1888
+ }
1889
+
1890
+ .formula-lookup__ppm {
1891
+ color: var(--text-muted);
1892
+ font-size: 0.72rem;
1893
+ font-variant-numeric: tabular-nums;
1894
+ }
1895
+
1732
1896
  /* ── A list of exercises ─────────────────────────────────────────────────── */
1733
1897
 
1734
1898
  .exercise-list {