quantui 0.5.1__tar.gz → 0.5.2__tar.gz

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 (71) hide show
  1. {quantui-0.5.1 → quantui-0.5.2}/CHANGELOG.md +26 -1
  2. {quantui-0.5.1/quantui.egg-info → quantui-0.5.2}/PKG-INFO +2 -2
  3. {quantui-0.5.1 → quantui-0.5.2}/README.md +1 -1
  4. {quantui-0.5.1 → quantui-0.5.2}/pyproject.toml +1 -1
  5. {quantui-0.5.1 → quantui-0.5.2}/quantui/__init__.py +1 -1
  6. {quantui-0.5.1 → quantui-0.5.2}/quantui/app.py +29 -36
  7. {quantui-0.5.1 → quantui-0.5.2}/quantui/app_builders.py +35 -13
  8. {quantui-0.5.1 → quantui-0.5.2}/quantui/app_formatters.py +3 -1
  9. {quantui-0.5.1 → quantui-0.5.2}/quantui/app_visualization.py +27 -16
  10. {quantui-0.5.1 → quantui-0.5.2}/quantui/live_log.py +3 -1
  11. quantui-0.5.2/quantui/theme.py +125 -0
  12. {quantui-0.5.1 → quantui-0.5.2}/quantui/visualization_py3dmol.py +6 -1
  13. {quantui-0.5.1 → quantui-0.5.2/quantui.egg-info}/PKG-INFO +2 -2
  14. {quantui-0.5.1 → quantui-0.5.2}/quantui.egg-info/SOURCES.txt +1 -0
  15. {quantui-0.5.1 → quantui-0.5.2}/LICENSE +0 -0
  16. {quantui-0.5.1 → quantui-0.5.2}/MANIFEST.in +0 -0
  17. {quantui-0.5.1 → quantui-0.5.2}/SECURITY.md +0 -0
  18. {quantui-0.5.1 → quantui-0.5.2}/quantui/analytics.py +0 -0
  19. {quantui-0.5.1 → quantui-0.5.2}/quantui/app_analysis.py +0 -0
  20. {quantui-0.5.1 → quantui-0.5.2}/quantui/app_exports.py +0 -0
  21. {quantui-0.5.1 → quantui-0.5.2}/quantui/app_history.py +0 -0
  22. {quantui-0.5.1 → quantui-0.5.2}/quantui/app_runflow.py +0 -0
  23. {quantui-0.5.1 → quantui-0.5.2}/quantui/ase_bridge.py +0 -0
  24. {quantui-0.5.1 → quantui-0.5.2}/quantui/benchmarks.py +0 -0
  25. {quantui-0.5.1 → quantui-0.5.2}/quantui/c_stderr.py +0 -0
  26. {quantui-0.5.1 → quantui-0.5.2}/quantui/cactus.py +0 -0
  27. {quantui-0.5.1 → quantui-0.5.2}/quantui/calc_log.py +0 -0
  28. {quantui-0.5.1 → quantui-0.5.2}/quantui/calculator.py +0 -0
  29. {quantui-0.5.1 → quantui-0.5.2}/quantui/cancellation.py +0 -0
  30. {quantui-0.5.1 → quantui-0.5.2}/quantui/cli.py +0 -0
  31. {quantui-0.5.1 → quantui-0.5.2}/quantui/comparison.py +0 -0
  32. {quantui-0.5.1 → quantui-0.5.2}/quantui/config.py +0 -0
  33. {quantui-0.5.1 → quantui-0.5.2}/quantui/data/js/3Dmol-min.js +0 -0
  34. {quantui-0.5.1 → quantui-0.5.2}/quantui/data/js/3Dmol-min.js.LICENSE.txt +0 -0
  35. {quantui-0.5.1 → quantui-0.5.2}/quantui/data/library/library.sqlite +0 -0
  36. {quantui-0.5.1 → quantui-0.5.2}/quantui/data/manifests/bulk_qm9.json +0 -0
  37. {quantui-0.5.1 → quantui-0.5.2}/quantui/data/manifests/curated.json +0 -0
  38. {quantui-0.5.1 → quantui-0.5.2}/quantui/data/manifests/presets.json +0 -0
  39. {quantui-0.5.1 → quantui-0.5.2}/quantui/descriptor_cards.py +0 -0
  40. {quantui-0.5.1 → quantui-0.5.2}/quantui/freq_calc.py +0 -0
  41. {quantui-0.5.1 → quantui-0.5.2}/quantui/freq_ir_workers.py +0 -0
  42. {quantui-0.5.1 → quantui-0.5.2}/quantui/gpu_offload.py +0 -0
  43. {quantui-0.5.1 → quantui-0.5.2}/quantui/help_content.py +0 -0
  44. {quantui-0.5.1 → quantui-0.5.2}/quantui/ir_plot.py +0 -0
  45. {quantui-0.5.1 → quantui-0.5.2}/quantui/issue_tracker.py +0 -0
  46. {quantui-0.5.1 → quantui-0.5.2}/quantui/log_utils.py +0 -0
  47. {quantui-0.5.1 → quantui-0.5.2}/quantui/molecule.py +0 -0
  48. {quantui-0.5.1 → quantui-0.5.2}/quantui/molecule_library.py +0 -0
  49. {quantui-0.5.1 → quantui-0.5.2}/quantui/nmr_calc.py +0 -0
  50. {quantui-0.5.1 → quantui-0.5.2}/quantui/optimizer.py +0 -0
  51. {quantui-0.5.1 → quantui-0.5.2}/quantui/orbital_visualization.py +0 -0
  52. {quantui-0.5.1 → quantui-0.5.2}/quantui/pes_scan.py +0 -0
  53. {quantui-0.5.1 → quantui-0.5.2}/quantui/preopt.py +0 -0
  54. {quantui-0.5.1 → quantui-0.5.2}/quantui/progress.py +0 -0
  55. {quantui-0.5.1 → quantui-0.5.2}/quantui/pubchem.py +0 -0
  56. {quantui-0.5.1 → quantui-0.5.2}/quantui/reorganization_energy.py +0 -0
  57. {quantui-0.5.1 → quantui-0.5.2}/quantui/results_storage.py +0 -0
  58. {quantui-0.5.1 → quantui-0.5.2}/quantui/security.py +0 -0
  59. {quantui-0.5.1 → quantui-0.5.2}/quantui/session_calc.py +0 -0
  60. {quantui-0.5.1 → quantui-0.5.2}/quantui/structure_providers.py +0 -0
  61. {quantui-0.5.1 → quantui-0.5.2}/quantui/tddft_calc.py +0 -0
  62. {quantui-0.5.1 → quantui-0.5.2}/quantui/user_settings.py +0 -0
  63. {quantui-0.5.1 → quantui-0.5.2}/quantui/utils.py +0 -0
  64. {quantui-0.5.1 → quantui-0.5.2}/quantui/vib_cache.py +0 -0
  65. {quantui-0.5.1 → quantui-0.5.2}/quantui/viz_assets.py +0 -0
  66. {quantui-0.5.1 → quantui-0.5.2}/quantui/viz_backend_router.py +0 -0
  67. {quantui-0.5.1 → quantui-0.5.2}/quantui.egg-info/dependency_links.txt +0 -0
  68. {quantui-0.5.1 → quantui-0.5.2}/quantui.egg-info/entry_points.txt +0 -0
  69. {quantui-0.5.1 → quantui-0.5.2}/quantui.egg-info/requires.txt +0 -0
  70. {quantui-0.5.1 → quantui-0.5.2}/quantui.egg-info/top_level.txt +0 -0
  71. {quantui-0.5.1 → quantui-0.5.2}/setup.cfg +0 -0
@@ -7,6 +7,30 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.2] - 2026-08-03
11
+
12
+ ### Fixed
13
+
14
+ - **Dark mode now has visible panel edges.** Borders, cards, and dividers were
15
+ effectively invisible in Dark mode — measured at 1.14:1 against the panel
16
+ background, well under the 3:1 that WCAG asks of a UI component. Dark mode is
17
+ a whole-page colour inversion rather than a separate palette, so a light grey
18
+ border inverts to a near-black one on a near-black page and disappears. The
19
+ borders are now a mid-tone that stays visible in *both* modes (3.20:1 light,
20
+ 4.30:1 dark). Text contrast was measured too and was never the problem — it
21
+ is 7.24:1 in light mode and slightly *better* inverted — so text colours are
22
+ deliberately unchanged.
23
+
24
+ ### Added
25
+
26
+ - **Every 3-D viewer now has a border around it**, so it is clear where the
27
+ view ends and where the page can be scrolled without dragging the structure.
28
+ This covers the molecule preview, the results and analysis structure views,
29
+ the geometry-optimization trajectory, the vibrational-mode animation, and the
30
+ classical pre-optimization preview. The frame hugs the plot itself rather
31
+ than spanning the window width, and looks the same whichever rendering
32
+ backend (py3Dmol or plotlymol3d) is active.
33
+
10
34
  ## [0.5.1] - 2026-07-31
11
35
 
12
36
  ### Fixed
@@ -484,7 +508,8 @@ Initial public scaffolding of the QuantUI package: `quantui` package with
484
508
  `calculator.py`, basic notebook launcher, Apptainer container definition,
485
509
  MIT license, and project metadata.
486
510
 
487
- [Unreleased]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.5.1...HEAD
511
+ [Unreleased]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.5.2...HEAD
512
+ [0.5.2]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.5.1...v0.5.2
488
513
  [0.5.1]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.5.0...v0.5.1
489
514
  [0.5.0]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.4.1...v0.5.0
490
515
  [0.4.1]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.4.0...v0.4.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantui
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: An open-source frontend for DFT and post-HF quantum chemistry with PySCF
5
5
  Author-email: Jonathan Schultz <nccu-schultz-lab@users.noreply.github.com>
6
6
  License: MIT License
@@ -91,7 +91,7 @@ Dynamic: license-file
91
91
 
92
92
  # QuantUI
93
93
 
94
- [![PyPI](https://img.shields.io/pypi/v/quantui)](https://pypi.org/project/quantui/)
94
+ [![PyPI](https://img.shields.io/pypi/v/quantui?v=2)](https://pypi.org/project/quantui/)
95
95
  [![CI](https://github.com/The-Schultz-Lab/QuantUI/actions/workflows/ci.yml/badge.svg)](https://github.com/The-Schultz-Lab/QuantUI/actions/workflows/ci.yml)
96
96
  [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://the-schultz-lab.github.io/QuantUI/)
97
97
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/The-Schultz-Lab/QuantUI/blob/main/LICENSE)
@@ -1,6 +1,6 @@
1
1
  # QuantUI
2
2
 
3
- [![PyPI](https://img.shields.io/pypi/v/quantui)](https://pypi.org/project/quantui/)
3
+ [![PyPI](https://img.shields.io/pypi/v/quantui?v=2)](https://pypi.org/project/quantui/)
4
4
  [![CI](https://github.com/The-Schultz-Lab/QuantUI/actions/workflows/ci.yml/badge.svg)](https://github.com/The-Schultz-Lab/QuantUI/actions/workflows/ci.yml)
5
5
  [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://the-schultz-lab.github.io/QuantUI/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/The-Schultz-Lab/QuantUI/blob/main/LICENSE)
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
8
8
 
9
9
  [project]
10
10
  name = "quantui"
11
- version = "0.5.1"
11
+ version = "0.5.2"
12
12
  description = "An open-source frontend for DFT and post-HF quantum chemistry with PySCF"
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.9"
@@ -7,7 +7,7 @@ Calculations run locally in the Jupyter session — no cluster or SLURM required
7
7
  PySCF requires Linux/macOS/WSL. Windows users should use the Apptainer container.
8
8
  """
9
9
 
10
- __version__ = "0.5.1"
10
+ __version__ = "0.5.2"
11
11
 
12
12
  import logging
13
13
  from typing import Any
@@ -32,6 +32,7 @@ import quantui
32
32
  import quantui.calc_log as _calc_log
33
33
  import quantui.issue_tracker as _issue_tracker
34
34
  from quantui import molecule_library as _ml
35
+ from quantui import theme as _theme
35
36
  from quantui.app_analysis import (
36
37
  activate_ana_panel as _ana_activate_ana_panel,
37
38
  )
@@ -450,9 +451,6 @@ try:
450
451
  from quantui.visualization_py3dmol import (
451
452
  VIZ_STYLE_OPTIONS as _VIZ_STYLE_OPTIONS,
452
453
  )
453
- from quantui.visualization_py3dmol import (
454
- display_molecule as _display_molecule,
455
- )
456
454
  from quantui.visualization_py3dmol import (
457
455
  render_molecule_html as _render_molecule_html,
458
456
  )
@@ -460,7 +458,6 @@ try:
460
458
  VISUALIZATION_AVAILABLE = True
461
459
  except ImportError:
462
460
  VISUALIZATION_AVAILABLE = False
463
- _display_molecule = None # type: ignore[assignment]
464
461
  _render_molecule_html = None # type: ignore[assignment]
465
462
  _PLOTLYMOL_VIZ = False
466
463
  _PY3DMOL_VIZ = False
@@ -616,7 +613,7 @@ h3 {
616
613
  color: #64748b !important;
617
614
  margin: 24px 0 10px !important;
618
615
  padding-bottom: 5px !important;
619
- border-bottom: 1px solid #e2e8f0 !important;
616
+ border-bottom: 1px solid __Q_BORDER__ !important;
620
617
  }
621
618
 
622
619
  /* Rounded corners on inputs, dropdowns, and buttons -------------------- */
@@ -632,21 +629,6 @@ h3 {
632
629
  background: transparent !important;
633
630
  }
634
631
 
635
- /* 3D molecule-viewer frames — a subtle bounding box so the viewer
636
- extent reads clearly. The light border inverts to dark automatically under
637
- the global dark-mode invert filter. */
638
- .quantui-viewer-frame {
639
- border: 1px solid #c0ccd8 !important;
640
- border-radius: 6px !important;
641
- overflow: hidden !important;
642
- }
643
- /* Collapse the frame to nothing when the viewer output is empty (e.g. before
644
- a calc runs) so no hollow box shows. Degrades to an always-on border where
645
- :has() is unsupported. */
646
- .quantui-viewer-frame:not(:has(.jp-OutputArea-child)) {
647
- border-color: transparent !important;
648
- }
649
-
650
632
  /* Inline "calculating" spinner — shown next to slow on-demand
651
633
  controls (e.g. orbital-isosurface generation) while work is in flight. */
652
634
  @keyframes quantui-spin { to { transform: rotate(360deg); } }
@@ -654,13 +636,20 @@ h3 {
654
636
  display: inline-block;
655
637
  width: 14px;
656
638
  height: 14px;
657
- border: 2px solid #c0ccd8;
639
+ border: 2px solid __Q_BORDER__;
658
640
  border-top-color: #2563eb;
659
641
  border-radius: 50%;
660
642
  animation: quantui-spin 0.7s linear infinite;
661
643
  vertical-align: middle;
662
644
  }
663
- </style>"""
645
+ </style>""".replace(
646
+ # Sentinel substitution rather than an f-string: this block is dense with
647
+ # CSS braces, every one of which would need doubling. If a second sentinel
648
+ # is ever added, substitute the LONGER name first — "__Q_BORDER__" matches
649
+ # inside "__Q_BORDER_STRONG__" and would leave a dangling "_STRONG__".
650
+ "__Q_BORDER__",
651
+ _theme.BORDER,
652
+ )
664
653
 
665
654
  _LAYOUT_TRAITS: frozenset[str] = frozenset(widgets.Layout.class_trait_names())
666
655
 
@@ -2383,7 +2372,7 @@ class QuantUIApp:
2383
2372
  body = rows[1:]
2384
2373
  head_html = "".join(
2385
2374
  f'<th style="padding:4px 10px;text-align:left;'
2386
- f"border-bottom:1px solid #cbd5e1;font-size:12px;"
2375
+ f"border-bottom:1px solid {_theme.BORDER};font-size:12px;"
2387
2376
  f'color:#1e293b">{_html.escape(str(c))}</th>'
2388
2377
  for c in header
2389
2378
  )
@@ -2427,7 +2416,7 @@ class QuantUIApp:
2427
2416
  # reach the parent app.
2428
2417
  iframe_html = (
2429
2418
  '<iframe sandbox="allow-scripts" '
2430
- 'style="width:100%;height:400px;border:1px solid #cbd5e1;'
2419
+ f'style="width:100%;height:400px;border:1px solid {_theme.BORDER};'
2431
2420
  'border-radius:4px" '
2432
2421
  f'srcdoc="{_html.escape(raw, quote=True)}"></iframe>'
2433
2422
  )
@@ -2871,7 +2860,7 @@ class QuantUIApp:
2871
2860
  def _rerender_3d_views(self) -> None:
2872
2861
  """Re-render visible 3D molecule viewers using the router to pick a
2873
2862
  backend per task. Updates the "Rendering with: X" label widgets."""
2874
- if _display_molecule is None:
2863
+ if _render_molecule_html is None:
2875
2864
  return
2876
2865
 
2877
2866
  # Calculate-tab molecule preview (MOLECULE_PREVIEW task).
@@ -2884,15 +2873,19 @@ class QuantUIApp:
2884
2873
  if self._analysis_displayed_molecule is not None:
2885
2874
  chosen = self._resolve_backend(VizTask.ANALYSIS_STRUCTURE_VIEW)
2886
2875
  if chosen is not None:
2887
- self._analysis_mol_output.clear_output()
2888
- with self._analysis_mol_output:
2889
- _display_molecule(
2890
- self._analysis_displayed_molecule,
2891
- backend=str(chosen),
2892
- style=self._viz_style,
2893
- lighting=self._viz_lighting,
2894
- bgcolor=self._plotly_theme_colors()["scene_bgcolor"],
2895
- )
2876
+ # Same render path as the first draw (app_visualization.
2877
+ # _show_result_3d), not display(): the fragment it returns
2878
+ # carries the viewer's own border. Going through display()
2879
+ # here would silently drop that border the moment a user
2880
+ # toggled backends on the Analysis tab.
2881
+ html = _render_molecule_html(
2882
+ self._analysis_displayed_molecule,
2883
+ backend=str(chosen),
2884
+ style=self._viz_style,
2885
+ lighting=self._viz_lighting,
2886
+ bgcolor=self._plotly_theme_colors()["scene_bgcolor"],
2887
+ )
2888
+ self._set_html_output(self._analysis_mol_output, html)
2896
2889
  self._update_analysis_backend_label(chosen)
2897
2890
 
2898
2891
  def _update_analysis_backend_label(self, chosen: VizBackend) -> None:
@@ -5514,7 +5507,7 @@ class QuantUIApp:
5514
5507
  rows.append(f'<div style="{style}">{esc}</div>')
5515
5508
  self._log_output_html.value = (
5516
5509
  '<div style="font-family:monospace;font-size:12px;line-height:1.4;'
5517
- "padding:8px 10px;background:#f8fafc;border:1px solid #e2e8f0;"
5510
+ f"padding:8px 10px;background:#f8fafc;border:1px solid {_theme.BORDER};"
5518
5511
  'border-radius:4px;overflow-x:auto;max-height:550px;overflow-y:auto">'
5519
5512
  + "".join(rows)
5520
5513
  + "</div>"
@@ -5530,7 +5523,7 @@ class QuantUIApp:
5530
5523
  if key and key in HELP_TOPICS:
5531
5524
  entry = HELP_TOPICS[key]
5532
5525
  self.help_content_html.value = (
5533
- f'<div style="border:1px solid #e2e8f0;border-radius:6px;'
5526
+ f'<div style="border:1px solid {_theme.BORDER};border-radius:6px;'
5534
5527
  f'padding:14px 18px;margin:8px 0;background:#f8fafc;max-width:700px">'
5535
5528
  f'<h4 style="margin:0 0 10px;color:#1e293b;font-size:15px;font-weight:700">'
5536
5529
  f'{entry["title"]}</h4>'
@@ -11,6 +11,7 @@ from IPython.display import HTML, display
11
11
 
12
12
  import quantui
13
13
  from quantui import molecule_library as _ml
14
+ from quantui import theme as _theme
14
15
  from quantui.help_content import HELP_TOPICS
15
16
  from quantui.live_log import LiveLog
16
17
 
@@ -153,7 +154,7 @@ def build_status_panel(
153
154
  for k, v in items
154
155
  )
155
156
  return (
156
- '<div style="background:#f8fafc;border:1px solid #e2e8f0;'
157
+ f'<div style="background:#f8fafc;border:1px solid {_theme.BORDER};'
157
158
  "border-left:4px solid #3b82f6;"
158
159
  'padding:12px 16px;border-radius:6px;margin:4px 0 8px">'
159
160
  '<div style="font-weight:600;font-size:14px;color:#1e293b">'
@@ -188,7 +189,7 @@ def build_status_panel(
188
189
  ],
189
190
  )
190
191
  settings_html = widgets.HTML(
191
- '<div style="background:#f8fafc;border:1px solid #e2e8f0;'
192
+ f'<div style="background:#f8fafc;border:1px solid {_theme.BORDER};'
192
193
  "border-left:4px solid #94a3b8;padding:12px 16px;border-radius:6px;"
193
194
  'margin:8px 0 4px">'
194
195
  '<div style="font-weight:600;font-size:14px;color:#1e293b">Settings</div>'
@@ -645,8 +646,20 @@ def build_shared_widgets(
645
646
  # overflow hidden (not auto): the 3D viewer is a fixed-size canvas, so it
646
647
  # needs no scrollbar — clipping a few px of margin avoids an internal
647
648
  # scrollbar that resets to the top on every backend/palette swap.
648
- app.viz_output = widgets.Output(layout=layout_fn(height="510px", overflow="hidden"))
649
- app.viz_output.add_class("quantui-viewer-frame")
649
+ # min_height, not height: the fragment is the info box (~110px) PLUS the
650
+ # 500px canvas plus its border, so the old fixed 510px — sized for the
651
+ # canvas alone — clipped the bottom border off. A minimum still reserves
652
+ # space so the page doesn't jump when a molecule first renders, but lets
653
+ # the box grow if the info box wraps to more lines on a narrow window.
654
+ app.viz_output = widgets.Output(
655
+ layout=layout_fn(min_height="620px", overflow="hidden")
656
+ )
657
+ # No .quantui-viewer-frame here: this output renders via
658
+ # render_molecule_html, whose fragment now carries its own border sized
659
+ # to the viewer's exact pixel width. The class's border would sit on the
660
+ # Output widget, which CANNOT shrink-wrap (JupyterLab's Lumino layout
661
+ # pins its children to the full window width — measured 2026-08-03), so
662
+ # it would draw a second, full-width box around the tight one.
650
663
  # Live calc log — a QuantUI-owned scroll container, not a widgets.Output
651
664
  # (M-LOGSCROLL route C). An Output rebuilds its DOM subtree and resets
652
665
  # scrollTop on every appended line, which made it impossible to scroll up
@@ -663,7 +676,9 @@ def build_shared_widgets(
663
676
  app.run_output.add_class("quantui-run-output")
664
677
  app.result_output = widgets.Output()
665
678
  app.result_viz_output = widgets.Output()
666
- app.result_viz_output.add_class("quantui-viewer-frame")
679
+ # Same as viz_output above — bordered by the rendered fragment itself.
680
+ # overflow is set on the layout since the class no longer supplies it.
681
+ app.result_viz_output.layout.overflow = "hidden"
667
682
  app.comparison_output = widgets.Output()
668
683
  app._last_result_dir = None
669
684
 
@@ -808,10 +823,12 @@ def build_shared_widgets(
808
823
  app.preopt_preview_output = widgets.Output(
809
824
  layout=layout_fn(
810
825
  # 290px viewer + stepper controls (slider / play / compare) below.
811
- height="360px",
826
+ # min_height, not height: build_preopt_preview_html now returns a
827
+ # framed fragment, and a fixed height would clip its bottom border
828
+ # off under overflow:hidden.
829
+ min_height="380px",
812
830
  width="100%",
813
831
  max_width="480px",
814
- border="1px solid #e2e8f0",
815
832
  overflow="hidden",
816
833
  )
817
834
  )
@@ -1279,7 +1296,7 @@ def build_welcome_header(app: Any, *, layout_fn: Any = None) -> None:
1279
1296
  justify_content="flex-start",
1280
1297
  padding="22px 4px 18px",
1281
1298
  margin="0 0 4px",
1282
- border_bottom="1px solid #e2e8f0",
1299
+ border_bottom=f"1px solid {_theme.BORDER}",
1283
1300
  ),
1284
1301
  )
1285
1302
 
@@ -1629,7 +1646,9 @@ def build_results_section(app: Any, *, layout_fn: Any) -> None:
1629
1646
  # switch. Matches the trajectory frame_out fix pattern. 460+20=480
1630
1647
  # accommodates the py3Dmol view (460px) plus a small horizontal pad;
1631
1648
  # 420+20=440 likewise for the 420px view height.
1632
- app.vib_output = widgets.Output(layout=layout_fn(height="440px", width="480px"))
1649
+ # min_height: the vib renderers return a framed fragment, and a fixed
1650
+ # height clips its bottom border off under the tab's overflow rules.
1651
+ app.vib_output = widgets.Output(layout=layout_fn(min_height="450px", width="480px"))
1633
1652
 
1634
1653
  # Vibration animation export: writes the current mode as a self-contained
1635
1654
  # HTML file. Backend selection is independent of the user's default — see
@@ -2006,8 +2025,11 @@ def build_results_section(app: Any, *, layout_fn: Any) -> None:
2006
2025
  )
2007
2026
  app.results_panel = app.results_tab_panel
2008
2027
 
2028
+ # No .quantui-viewer-frame: this renders via render_molecule_html (see
2029
+ # app_visualization._show_result_3d), so the fragment carries its own
2030
+ # border fitted to the viewer. The class would add a second, full-width
2031
+ # box around it — the Output widget cannot shrink-wrap.
2009
2032
  app._analysis_mol_output = widgets.Output()
2010
- app._analysis_mol_output.add_class("quantui-viewer-frame")
2011
2033
 
2012
2034
  # Analysis-tab backend toggle — mirrors the Calculate-tab `viz_backend_toggle`.
2013
2035
  # Created only when both backends are available (matches Calculate-tab
@@ -2239,7 +2261,7 @@ def build_output_tab(app: Any, *, layout_fn: Any) -> None:
2239
2261
  app._log_output_html,
2240
2262
  app._result_log_accordion,
2241
2263
  widgets.HTML(
2242
- '<hr style="border:none;border-top:1px solid #e2e8f0;margin:16px 0 10px"/>'
2264
+ f'<hr style="border:none;border-top:1px solid {_theme.BORDER};margin:16px 0 10px"/>'
2243
2265
  '<p style="color:#94a3b8;font-size:12px;margin:0 0 6px">'
2244
2266
  "Session event log — records molecule loads, calculations, "
2245
2267
  "and issue reports across this session.</p>"
@@ -2315,7 +2337,7 @@ def build_files_tab(app: Any, *, layout_fn: Any) -> None:
2315
2337
  )
2316
2338
  app._files_preview_output = widgets.Output(
2317
2339
  layout=layout_fn(
2318
- border="1px solid #cbd5e1",
2340
+ border=f"1px solid {_theme.BORDER}",
2319
2341
  min_height="220px",
2320
2342
  max_height="420px",
2321
2343
  overflow="auto",
@@ -2403,7 +2425,7 @@ def build_help_section(app: Any, *, layout_fn: Any) -> None:
2403
2425
  layout=layout_fn(
2404
2426
  display="none",
2405
2427
  padding="8px 0",
2406
- border="1px solid #e2e8f0",
2428
+ border=f"1px solid {_theme.BORDER}",
2407
2429
  border_radius="6px",
2408
2430
  padding_left="12px",
2409
2431
  margin="0 0 8px",
@@ -5,6 +5,8 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
  from typing import Any, Optional
7
7
 
8
+ from quantui import theme as _theme
9
+
8
10
 
9
11
  def _result_extra_rows(get: Any) -> str:
10
12
  """Build the shared 'extra' result-card rows from an accessor.
@@ -478,7 +480,7 @@ def format_past_result(data: dict[str, Any], result_dir: Optional[Path] = None)
478
480
  _thumb_html = (
479
481
  f'<img src="data:image/png;base64,{_img_b64}" '
480
482
  f'style="float:right;margin:0 0 6px 14px;border-radius:4px;'
481
- f'border:1px solid #e2e8f0" width="173" height="108" />'
483
+ f'border:1px solid {_theme.BORDER}" width="173" height="108" />'
482
484
  )
483
485
 
484
486
  return (
@@ -11,6 +11,8 @@ from typing import Any, List
11
11
  import ipywidgets as widgets
12
12
  from IPython.display import HTML, display
13
13
 
14
+ from quantui import theme as _theme
15
+
14
16
 
15
17
  @contextmanager
16
18
  def _viz_render_event(app: Any, task: Any, backend: Any, **extras: Any):
@@ -306,9 +308,11 @@ def show_opt_trajectory(
306
308
  return
307
309
 
308
310
  # --- Single-viewer trajectory stepper (all frames preloaded) ---
311
+ # min_height, not height: build_trajectory_viewer_html returns a framed
312
+ # fragment, and a fixed height would clip its bottom border off.
309
313
  viewer_output = widgets.Output(
310
314
  layout=layout_fn(
311
- height="410px", width="100%", max_width="500px", overflow="hidden"
315
+ min_height="420px", width="100%", max_width="500px", overflow="hidden"
312
316
  )
313
317
  )
314
318
  try:
@@ -1689,7 +1693,8 @@ def _render_vib_mode_py3dmol(
1689
1693
  from quantui.viz_assets import make_view
1690
1694
 
1691
1695
  interval_ms = max(1, int(round(1000.0 / fps)))
1692
- view = make_view(width=460, height=420)
1696
+ vib_width = 460
1697
+ view = make_view(width=vib_width, height=420)
1693
1698
  view.addModelsAsFrames(xyz_string, "xyz")
1694
1699
  view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
1695
1700
  bg = "white" if app.theme_btn.value == "Light" else "#1e1e1e"
@@ -1700,7 +1705,9 @@ def _render_vib_mode_py3dmol(
1700
1705
  # pan/rotate state survives mode switches. The hook is idempotent
1701
1706
  # (guarded by ``_quantuiVibCameraHookInstalled``) and ships inside
1702
1707
  # the cached HTML too — so disk-cache hits also persist the camera.
1703
- html_str = _VIB_CAMERA_PERSISTENCE_JS + view._make_html()
1708
+ html_str = _theme.frame_viewer_html(
1709
+ _VIB_CAMERA_PERSISTENCE_JS + view._make_html(), width=vib_width
1710
+ )
1704
1711
  except Exception as exc:
1705
1712
  if not _is_vib_stale(app, render_token):
1706
1713
  _vib_err(app, f"Vibrational animation render failed: {exc}")
@@ -1798,7 +1805,11 @@ def _render_vib_mode_plotlymol(
1798
1805
  mode="ball+stick",
1799
1806
  resolution=12,
1800
1807
  )
1801
- anim_fig.update_layout(height=420)
1808
+ # Explicit width, not just height: the frame is sized in pixels, and a
1809
+ # responsive-width figure would otherwise render this mode at a
1810
+ # different size than the py3Dmol path. 460 matches the viewer in
1811
+ # _render_vib_mode_py3dmol so the two backends look the same.
1812
+ anim_fig.update_layout(width=460, height=420)
1802
1813
  except Exception as exc:
1803
1814
  if not _is_vib_stale(app, render_token):
1804
1815
  _vib_err(app, f"Animation generation failed: {exc}")
@@ -1814,7 +1825,7 @@ def _render_vib_mode_plotlymol(
1814
1825
  )
1815
1826
  if _is_vib_stale(app, render_token):
1816
1827
  return
1817
- _swap_vib_output(app, anim_html)
1828
+ _swap_vib_output(app, _theme.frame_viewer_html(anim_html, width=460))
1818
1829
 
1819
1830
 
1820
1831
  def render_vib_mode(
@@ -1919,7 +1930,7 @@ def on_vib_mode_changed(app: Any, change: dict[str, Any]) -> None:
1919
1930
 
1920
1931
 
1921
1932
  _STEPPER_BTN_STYLE = (
1922
- "padding:2px 9px;border:1px solid #cbd5e1;border-radius:4px;"
1933
+ f"padding:2px 9px;border:1px solid {_theme.BORDER};border-radius:4px;"
1923
1934
  "background:#f8fafc;color:#334155;cursor:pointer;font-size:13px;line-height:1.4;"
1924
1935
  )
1925
1936
 
@@ -2099,7 +2110,8 @@ def build_preopt_preview_html(
2099
2110
  lines.append(f"{sym} {xyz[0]:.6f} {xyz[1]:.6f} {xyz[2]:.6f}")
2100
2111
  xyz_string = "\n".join(lines) + "\n"
2101
2112
 
2102
- view = make_view(width=460, height=290)
2113
+ width = 460
2114
+ view = make_view(width=width, height=290)
2103
2115
  view.addModelsAsFrames(xyz_string, "xyz")
2104
2116
  view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
2105
2117
  view.setBackgroundColor(bgcolor)
@@ -2109,7 +2121,7 @@ def build_preopt_preview_html(
2109
2121
  n_frames = len(frames)
2110
2122
  # Single frame (FF no-op / RDKit absent): nothing to step through.
2111
2123
  if n_frames <= 1:
2112
- return view_html
2124
+ return _theme.frame_viewer_html(view_html, width=width)
2113
2125
 
2114
2126
  m = re.search(r"3dmolviewer_(\w+)", view_html)
2115
2127
  if m is None:
@@ -2117,11 +2129,11 @@ def build_preopt_preview_html(
2117
2129
  # plain auto-loop animation so the relaxation is still visible.
2118
2130
  interval_ms = max(1, int(round(1000.0 / max(1, fps))))
2119
2131
  view.animate({"loop": "forward", "interval": interval_ms, "reps": 0})
2120
- return view._make_html()
2132
+ return _theme.frame_viewer_html(view._make_html(), width=width)
2121
2133
 
2122
2134
  interval_ms = max(1, int(round(1000.0 / max(1, fps))))
2123
2135
  controls = _preopt_controls_html(m.group(1), n_frames, interval_ms)
2124
- return f'<div style="max-width:480px">{view_html}{controls}</div>'
2136
+ return _theme.frame_viewer_html(view_html, width=width, controls=controls)
2125
2137
 
2126
2138
 
2127
2139
  def build_trajectory_viewer_html(
@@ -2162,11 +2174,12 @@ def build_trajectory_viewer_html(
2162
2174
  view_html = view._make_html()
2163
2175
 
2164
2176
  if n <= 1:
2165
- return view_html
2177
+ return _theme.frame_viewer_html(view_html, width=width)
2166
2178
 
2167
2179
  m = re.search(r"3dmolviewer_(\w+)", view_html)
2168
2180
  if m is None:
2169
- return view_html # can't wire controls without the viewer id
2181
+ # can't wire controls without the viewer id
2182
+ return _theme.frame_viewer_html(view_html, width=width)
2170
2183
 
2171
2184
  interval_ms = max(1, int(round(1000.0 / max(1, fps))))
2172
2185
  eabs = json.dumps([float(e) for e in energies]) if energies else "null"
@@ -2192,7 +2205,7 @@ def build_trajectory_viewer_html(
2192
2205
  scrub_title="Scrub the optimization steps",
2193
2206
  extra_decls=f"var EABS={eabs}; var EREL={erel};",
2194
2207
  )
2195
- return f'<div style="max-width:{width + 20}px">{view_html}{controls}</div>'
2208
+ return _theme.frame_viewer_html(view_html, width=width, controls=controls)
2196
2209
 
2197
2210
 
2198
2211
  # Single-viewer vibrational animation. ONE py3Dmol viewer holds every mode; the
@@ -2324,9 +2337,7 @@ def build_vib_viewer_html(
2324
2337
  .replace("__BG__", json.dumps(bgcolor))
2325
2338
  .replace("__INIT__", str(int(initial_mode)))
2326
2339
  )
2327
- return (
2328
- f'<div style="max-width:{width + 20}px">{view_html}<script>{js}</script></div>'
2329
- )
2340
+ return _theme.frame_viewer_html(f"{view_html}<script>{js}</script>", width=width)
2330
2341
 
2331
2342
 
2332
2343
  def _vib_single_viewer_supported(app: Any, freq_result: Any) -> bool:
@@ -73,6 +73,8 @@ from typing import Any, Optional
73
73
  import ipywidgets as widgets
74
74
  from IPython.display import Javascript, display
75
75
 
76
+ from quantui import theme as _theme
77
+
76
78
  _LOG = logging.getLogger(__name__)
77
79
 
78
80
  # Coalescing window for appends. PySCF emits many lines per second; batching
@@ -88,7 +90,7 @@ _MAIL_CLASS = "quantui-live-mail"
88
90
  # ASCII header exactly as .jp-OutputArea-output did (see GOTCHAS).
89
91
  _CONTAINER_STYLE = (
90
92
  "height:300px;overflow-y:auto;overflow-anchor:auto;"
91
- "border:1px solid #c0ccd8;border-radius:2px;padding:8px;"
93
+ f"border:1px solid {_theme.BORDER};border-radius:2px;padding:8px;"
92
94
  "font-family:ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,"
93
95
  "'Liberation Mono','Courier New',monospace;"
94
96
  "font-size:12.5px;line-height:1.35;white-space:pre-wrap;"
@@ -0,0 +1,125 @@
1
+ """Theme colour tokens (M-THEME).
2
+
3
+ Why this module exists
4
+ ----------------------
5
+ QuantUI's dark mode is **not a palette swap** — it is a whole-page CSS filter::
6
+
7
+ html { filter: invert(1) hue-rotate(180deg) !important; }
8
+ canvas, img, iframe, video { filter: invert(1) hue-rotate(180deg) !important; }
9
+
10
+ (see ``app.QuantUIApp._theme_css``). Every colour in the UI is written once, for
11
+ light mode, and dark mode is that same colour mathematically inverted. The
12
+ counter-filter on ``canvas/img/iframe/video`` double-inverts embedded renderers
13
+ (3-D viewers, plots) back to their intended appearance.
14
+
15
+ **The consequence that matters, and the reason this module exists:** you cannot
16
+ tune light and dark independently. There is exactly one source value per colour,
17
+ and dark mode is whatever that value inverts to.
18
+
19
+ The mid-tone rule
20
+ -----------------
21
+ That constraint has a non-obvious implication for anything whose job is
22
+ *separation* rather than *legibility*:
23
+
24
+ - A **light** grey border (``#e2e8f0``) looks correct on a white page, but
25
+ inverts to a near-black border (``#121820``) on a near-black panel — invisible.
26
+ - A **dark** border has the mirror-image problem: fine in dark mode, invisible
27
+ in light.
28
+ - A **mid-tone** border stays mid-tone under inversion, so it is visible in
29
+ *both*. Mid-tones are the only values that survive.
30
+
31
+ Measured, not assumed (2026-07-31)
32
+ ----------------------------------
33
+ Contrast ratios computed through the actual filter chain (invert, then the W3C
34
+ ``hue-rotate`` matrix), against the panel background ``#f8fafc``:
35
+
36
+ =========== ============== ============= ==================================
37
+ candidate light vs panel dark vs panel verdict
38
+ =========== ============== ============= ==================================
39
+ ``#e2e8f0`` 1.18:1 1.14:1 the old value — fails both
40
+ ``#c0ccd8`` 1.56:1 1.65:1 fails both
41
+ ``#94a3b8`` 2.45:1 3.12:1 passes dark only
42
+ ``#7d8ea3`` 3.20:1 4.30:1 **passes both** — chosen
43
+ ``#64748b`` 4.55:1 6.17:1 passes both, but heavy in light
44
+ =========== ============== ============= ==================================
45
+
46
+ ``BORDER`` is the *lightest* value clearing WCAG 1.4.11's **3:1** bar for
47
+ non-text UI components in both modes. ``#64748b`` clears it more comfortably but
48
+ reads as a heavy rule in light mode, which is a real cost for a subtle panel
49
+ divider.
50
+
51
+ What was NOT the problem
52
+ ------------------------
53
+ Text contrast was measured too, and it was never broken — body text on a panel
54
+ is 7.24:1 in light and *improves* to 8.91:1 inverted. The user-reported "fix
55
+ dark mode contrasts" was, on measurement, entirely about **structural
56
+ separation**: panels and their borders were invisible against the page, which is
57
+ why the same request also asked to "add borders". Text colours are deliberately
58
+ left alone here.
59
+
60
+ Scope of this module today
61
+ --------------------------
62
+ Only the tokens needed for the THEME.5 fix. It is intentionally not a
63
+ full-palette migration: the codebase has ~390 hardcoded hex literals across 17
64
+ files, most of them *semantic* accents (error red, success green, link blue)
65
+ whose hue survives ``hue-rotate(180)`` and which look correct in both modes
66
+ already. Migrating those wholesale would be a large, visually-unverifiable
67
+ change for no user-visible gain.
68
+
69
+ When THEME.6 (customisable palettes) lands, the invert filter has to go — a
70
+ palette system cannot work when dark mode is a derived inversion rather than an
71
+ independent set of values. At that point these tokens become the seam: they
72
+ grow light/dark variants and the ``_theme_css`` filter is replaced. Keeping them
73
+ named here means that change edits this file plus the CSS, not 19 call sites
74
+ again.
75
+ """
76
+
77
+ from __future__ import annotations
78
+
79
+ # ── Structural / chrome ──────────────────────────────────────────────────────
80
+
81
+ #: Panel, card, and viewer borders. Mid-tone so it survives inversion — see the
82
+ #: module docstring's table. Replaced ``#e2e8f0`` / ``#e5e7eb`` / ``#cbd5e1`` /
83
+ #: ``#c0ccd8``, all of which were invisible in dark mode (1.14-1.65:1).
84
+ BORDER = "#7d8ea3"
85
+
86
+ #: Emphasised border for elements that should read as framed even at a glance
87
+ #: (the 3-D viewer frame, which sits on its own rather than in a card stack).
88
+ BORDER_STRONG = "#64748b"
89
+
90
+
91
+ def frame_viewer_html(view_html: str, *, width: int, controls: str = "") -> str:
92
+ """Wrap a 3-D viewer fragment in the standard frame, sized to the viewer.
93
+
94
+ Every 3-D viewer in the app — static molecule, optimization trajectory,
95
+ vibrational mode, classical pre-opt preview — goes through here so they all
96
+ frame identically and a token change lands everywhere at once.
97
+
98
+ ``width`` must be the viewer's own pixel width. The frame is built at that
99
+ width **here**, in the code that knows it, rather than as a CSS class on the
100
+ hosting ``widgets.Output``. Measured in the browser 2026-08-03: an Output's
101
+ children are Lumino widgets that JupyterLab sizes with explicit pixel widths
102
+ tracking the window, so a class-borne ``fit-content`` always resolves to the
103
+ full page width. The border then enclosed a wide strip of dead space beside
104
+ the plot, which is actively misleading — it implies the whole strip is
105
+ interactive and hides where the page can be scrolled without dragging the
106
+ 3-D view.
107
+
108
+ ``controls`` is optional stepper/player markup rendered below the viewer.
109
+ It is inset so the buttons don't sit flush against the frame; the viewer
110
+ itself stays flush, since its canvas is its own edge.
111
+
112
+ Note for callers: the hosting Output must not pin a fixed ``height``, or the
113
+ frame's bottom edge is clipped off by ``overflow: hidden``. Use
114
+ ``min_height`` — see ``app_builders.build_shared_widgets``.
115
+ """
116
+ if controls:
117
+ controls = f'<div style="padding:0 8px 6px">{controls}</div>'
118
+ return (
119
+ f'<div style="width:{width}px;max-width:100%;'
120
+ f"border:1px solid {BORDER_STRONG};border-radius:6px;"
121
+ f'overflow:hidden">{view_html}{controls}</div>'
122
+ )
123
+
124
+
125
+ __all__ = ["BORDER", "BORDER_STRONG", "frame_viewer_html"]
@@ -14,6 +14,8 @@ import os
14
14
  import tempfile
15
15
  from typing import Literal, cast
16
16
 
17
+ from quantui import theme as _theme
18
+
17
19
  logger = logging.getLogger(__name__)
18
20
 
19
21
  Py3DmolStyle = Literal["ball+stick", "stick", "sphere", "line", "cartoon"]
@@ -469,7 +471,10 @@ def render_molecule_html(
469
471
  '<div style="color:#b91c1c;padding:8px;">'
470
472
  f"❌ Visualization failed: {e}</div>"
471
473
  )
472
- return "\n".join(parts)
474
+ # Frame the fragment at the viewer's own width — see theme.frame_viewer_html
475
+ # for why the border cannot live on the hosting Output widget's CSS class.
476
+ # The info box is INSIDE the frame so it aligns with the canvas.
477
+ return _theme.frame_viewer_html("\n".join(parts), width=width)
473
478
 
474
479
 
475
480
  def display_molecule(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantui
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: An open-source frontend for DFT and post-HF quantum chemistry with PySCF
5
5
  Author-email: Jonathan Schultz <nccu-schultz-lab@users.noreply.github.com>
6
6
  License: MIT License
@@ -91,7 +91,7 @@ Dynamic: license-file
91
91
 
92
92
  # QuantUI
93
93
 
94
- [![PyPI](https://img.shields.io/pypi/v/quantui)](https://pypi.org/project/quantui/)
94
+ [![PyPI](https://img.shields.io/pypi/v/quantui?v=2)](https://pypi.org/project/quantui/)
95
95
  [![CI](https://github.com/The-Schultz-Lab/QuantUI/actions/workflows/ci.yml/badge.svg)](https://github.com/The-Schultz-Lab/QuantUI/actions/workflows/ci.yml)
96
96
  [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://the-schultz-lab.github.io/QuantUI/)
97
97
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/The-Schultz-Lab/QuantUI/blob/main/LICENSE)
@@ -48,6 +48,7 @@ quantui/security.py
48
48
  quantui/session_calc.py
49
49
  quantui/structure_providers.py
50
50
  quantui/tddft_calc.py
51
+ quantui/theme.py
51
52
  quantui/user_settings.py
52
53
  quantui/utils.py
53
54
  quantui/vib_cache.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes