diff --git a/quantui/app.py b/quantui/app.py index fa12d9e..75747c0 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -204,6 +204,9 @@ from quantui.app_history import ( on_view_log as _hist_on_view_log, ) +from quantui.app_runflow import ( + calc_type_key as _run_calc_type_key, +) from quantui.app_runflow import ( do_calibration as _run_do_calibration, ) @@ -596,7 +599,8 @@ def _load_last_calibration_label() -> str: # ── Module-level constants ──────────────────────────────────────────────────── _THEME_HUE: dict = {"Dark": 180} -_APP_CSS: str = """""".replace( - # Sentinel substitution rather than an f-string: this block is dense with - # CSS braces, every one of which would need doubling. If a second sentinel - # is ever added, substitute the LONGER name first — "__Q_BORDER__" matches - # inside "__Q_BORDER_STRONG__" and would leave a dangling "_STRONG__". - "__Q_BORDER__", - _theme.BORDER, + # Sentinel substitution rather than an f-string: this block is dense with + # CSS braces, every one of which would need doubling. Substitute longer + # names before any name they're a prefix of — "__Q_BORDER__" matches + # inside "__Q_BORDER_STRONG__" and would leave a dangling "_STRONG__". None + # of the sentinels below are prefixes of one another, so order is free. + "__Q_BORDER__", + _theme.BORDER, + ) + .replace( + "__Q_TEXT_STRONG__", + _theme.TEXT_STRONG, + ) + .replace( + "__Q_TEXT_SLATE__", + _theme.TEXT_SLATE, + ) + .replace( + "__Q_ACCENT_INFO__", + _theme.ACCENT_INFO, + ) ) _LAYOUT_TRAITS: frozenset[str] = frozenset(widgets.Layout.class_trait_names()) @@ -2054,8 +2072,8 @@ def _wire_callbacks(self) -> None: self._resume_list_dd.observe( self._safe_cb(self._on_resume_entry_changed), names="value" ) - self._resume_restore_btn.on_click(self._on_resume_restore) - self._resume_discard_btn.on_click(self._on_resume_discard) + self._resume_restore_btn.on_click(self._safe_cb(self._on_resume_restore)) + self._resume_discard_btn.on_click(self._safe_cb(self._on_resume_discard)) # Help buttons self.method_help_btn.on_click(self._on_method_help) self.basis_help_btn.on_click(self._on_basis_help) @@ -2284,7 +2302,7 @@ def _format_file_size(self, size_bytes: int) -> str: return f"{size_bytes / 1024:.1f} KB" return f"{size_bytes / (1024 * 1024):.1f} MB" - def _set_files_status(self, message: str, color: str = "#64748b") -> None: + def _set_files_status(self, message: str, color: str = _theme.TEXT_SLATE) -> None: """Update Files tab status text.""" self._files_status_html.value = ( f'' @@ -2344,12 +2362,12 @@ def _refresh_file_browser(self) -> None: self._files_current_dir = None self._files_selected_path = None self._files_path_html.value = ( - '' + f'' "Current folder: unavailable" ) self._files_open_btn.disabled = True self._files_up_btn.disabled = True - self._set_files_status("No readable roots available.", "#b91c1c") + self._set_files_status("No readable roots available.", _theme.ACCENT_ERROR) self._files_preview_output.clear_output(wait=True) return @@ -2401,7 +2419,7 @@ def _update_files_entries(self) -> None: self._files_current_dir = current self._files_path_html.value = ( - 'Current folder: ' + f'Current folder: ' f"{_html.escape(str(current))}" ) @@ -2414,7 +2432,7 @@ def _update_files_entries(self) -> None: self._files_open_btn.disabled = True self._files_up_btn.disabled = True self._files_preview_output.clear_output(wait=True) - self._set_files_status(f"Cannot list folder: {exc}", "#b91c1c") + self._set_files_status(f"Cannot list folder: {exc}", _theme.ACCENT_ERROR) return children.sort(key=lambda p: (not p.is_dir(), p.name.lower())) @@ -2459,10 +2477,14 @@ def _preview_file_path(self, path: Path) -> None: """Render a safe preview for a selected file path.""" roots = self._files_allowed_roots() if not self._is_path_in_allowed_roots(path, roots): - self._set_files_status("Selected path is outside allowed roots.", "#b91c1c") + self._set_files_status( + "Selected path is outside allowed roots.", _theme.ACCENT_ERROR + ) return if not path.exists() or not path.is_file(): - self._set_files_status("Selected file no longer exists.", "#b91c1c") + self._set_files_status( + "Selected file no longer exists.", _theme.ACCENT_ERROR + ) return self._files_preview_output.clear_output(wait=True) @@ -2596,7 +2618,7 @@ def _preview_file_path(self, path: Path) -> None: with self._files_preview_output: display( HTML( - "

" + f"

" f"{_html.escape(note)}

" "
"
@@ -2625,14 +2647,14 @@ def _preview_file_path(self, path: Path) -> None:
                     head_html = "".join(
                         f'{_html.escape(str(c))}'
+                        f'color:{_theme.TEXT_STRONG}">{_html.escape(str(c))}'
                         for c in header
                     )
                     body_html = "".join(
                         ""
                         + "".join(
                             f'{_html.escape(str(c))}'
                             for c in r
                         )
@@ -2640,7 +2662,7 @@ def _preview_file_path(self, path: Path) -> None:
                         for r in body
                     )
                     note = (
-                        f'

' + f'

' f"First {len(rows)} rows shown.

" if len(rows) >= 50 else "" @@ -2693,17 +2715,17 @@ def _preview_file_path(self, path: Path) -> None: header_text = "\n".join(head_lines) size_mb = stat.st_size / (1024 * 1024) msg_html = ( - f'

' + f'

' f"Cube file: {_html.escape(path.name)} " f"· {size_mb:.2f} MB

" - '

' + f'

' "Use the Analysis tab's Orbital Isosurface panel to " "render volumetric data; the raw file is too large to " "preview inline.

" - '

' + f'

' "Header (first 6 lines):

" '
'
                     f"{_html.escape(header_text)}
" ) @@ -2719,7 +2741,7 @@ def _preview_file_path(self, path: Path) -> None: try: sample = path.read_bytes()[:512] except OSError as exc: - self._set_files_status(f"Cannot read file: {exc}", "#b91c1c") + self._set_files_status(f"Cannot read file: {exc}", _theme.ACCENT_ERROR) return is_text = b"\x00" not in sample @@ -2727,7 +2749,7 @@ def _preview_file_path(self, path: Path) -> None: with self._files_preview_output: display( HTML( - "

" + f"

" "Binary preview is not available for this file type." "

" ) @@ -2739,7 +2761,7 @@ def _preview_file_path(self, path: Path) -> None: try: raw = path.read_bytes() except OSError as exc: - self._set_files_status(f"Cannot read file: {exc}", "#b91c1c") + self._set_files_status(f"Cannot read file: {exc}", _theme.ACCENT_ERROR) return truncated = len(raw) > max_bytes @@ -2769,7 +2791,7 @@ def _on_files_root_changed(self, change) -> None: new_root = Path(new_value) roots = self._files_allowed_roots() if not self._is_path_in_allowed_roots(new_root, roots): - self._set_files_status("Selected root is not allowed.", "#b91c1c") + self._set_files_status("Selected root is not allowed.", _theme.ACCENT_ERROR) return self._files_current_dir = new_root @@ -2814,7 +2836,9 @@ def _on_files_up(self, _btn) -> None: self._activity_begin("Moving to parent folder...") try: if self._files_current_dir is None: - self._set_files_status("No current folder selected.", "#b91c1c") + self._set_files_status( + "No current folder selected.", _theme.ACCENT_ERROR + ) return parent = self._files_current_dir.parent @@ -3163,7 +3187,7 @@ def _update_analysis_backend_label(self, chosen: VizBackend) -> None: return display_name = "py3Dmol" if chosen == VizBackend.PY3DMOL else "plotlymol3d" label.value = ( - f'' + f'' f"Rendering with: {display_name}" ) @@ -3305,7 +3329,7 @@ def _refresh_lib_results(self) -> None: finally: self._lib_refreshing = False self.lib_count_lbl.value = ( - f'{note}' + f'{note}' ) def _on_lib_filter_changed(self, change) -> None: @@ -3704,7 +3728,7 @@ def _on_vib_export_animation(self, _btn) -> None: or getattr(self, "_last_vib_molecule", None) is None ): status.value = ( - '' + f'' "No vibrational mode loaded — run a Frequency calculation first." "" ) @@ -3714,7 +3738,7 @@ def _on_vib_export_animation(self, _btn) -> None: mode_number = int(self.vib_mode_dd.value) except (TypeError, ValueError): status.value = ( - '' + f'' "No vibrational mode selected." ) return @@ -3723,7 +3747,7 @@ def _on_vib_export_animation(self, _btn) -> None: backend, html_str = _viz_build_vib_export_html(self, mode_number) except Exception as exc: status.value = ( - '' + f'' f"Export failed: {exc}" ) try: @@ -3751,13 +3775,13 @@ def _on_vib_export_animation(self, _btn) -> None: dest.write_text(html_str, encoding="utf-8") except Exception as exc: status.value = ( - '' + f'' f"Write failed: {exc}" ) return status.value = ( - '' + f'' f"Saved ({backend}): {dest}" ) try: @@ -3787,7 +3811,7 @@ def _export_plot_figure( """Export a plotly figure to HTML or PNG in the current result folder.""" if fig is None: status_widget.value = ( - '' + f'' "No plot available to export yet." ) return @@ -3823,7 +3847,8 @@ def _export_plot_figure( dest.write_text(html_str, encoding="utf-8") status_widget.value = ( - '' f"Saved: {dest}" + f'' + f"Saved: {dest}" ) except Exception as exc: msg = str(exc) @@ -3832,7 +3857,7 @@ def _export_plot_figure( "PNG export requires kaleido. " "Install with: pip install kaleido" ) status_widget.value = ( - '' + f'' f"Export failed: {msg}" ) @@ -3897,7 +3922,7 @@ def _copy_plot_data( """ if fig is None: status_widget.value = ( - '' + f'' "No plot data to copy yet." ) return @@ -3905,7 +3930,7 @@ def _copy_plot_data( csv_text = self._fig_to_csv(fig, title=title) if not csv_text: status_widget.value = ( - '' + f'' "Figure had no extractable (x, y) traces." ) return @@ -3929,7 +3954,7 @@ def _copy_plot_data( dest.write_text(csv_text, encoding="utf-8") except Exception as exc: status_widget.value = ( - '' + f'' f"Write failed: {exc}" ) return @@ -3951,7 +3976,7 @@ def _copy_plot_data( pass # Clipboard is best-effort; the file is the canonical artifact. status_widget.value = ( - '' + f'' f"Saved CSV: {dest} — copied to clipboard" "" ) @@ -4259,10 +4284,14 @@ def _set_molecule(self, mol: Molecule, label: str = "") -> None: except Exception: e_str = "" - _lbl = f'
{label}' if label else "" + _lbl = ( + f'
{label}' + if label + else "" + ) _summary = ( f'{mol.get_formula()}' - f' ' + f' ' f"{len(mol.atoms)} atoms" + (f" • {e_str}" if e_str else "") + f" • charge {mol.charge} • mult {mol.multiplicity}" @@ -4398,7 +4427,7 @@ def _show_result_log(self, saved_dir: Path, log_text: str) -> None: """ # Path label self._result_dir_label.value = ( - f'' + f'' f"Saved to: {saved_dir}" ) self._result_dir_label.layout.display = "" @@ -4614,15 +4643,7 @@ def _do_run(self) -> None: _predicted_run_s: Optional[float] = None _predicted_run_confidence: str = "unknown" try: - _ct_for_est = { - "Single Point": "single_point", - "Geometry Opt": "geometry_opt", - "Frequency": "frequency", - "UV-Vis (TD-DFT)": "tddft", - "NMR Shielding": "nmr", - "PES Scan": "pes_scan", - "Reorganization Energy": "reorganization_energy", - }.get(self.calc_type_dd.value, "single_point") + _ct_for_est = _run_calc_type_key(self) _nb_for_est = _calc_log.count_basis_functions( mol.atoms, self.basis_dd.value ) @@ -5202,7 +5223,7 @@ def _run_required_final_single_point(target_mol, reason: str): if _n_heavy > 20: self.result_output.append_display_data( HTML( - '
' f"⚠️ MP2 scales as O(N⁵) — this molecule has {_n_heavy} heavy atoms " "and may be slow. Consider using DFT instead.
" @@ -5239,13 +5260,13 @@ def _run_required_final_single_point(target_mol, reason: str): ) if ct == "Geometry Opt": self._viz_label.value = ( - '

Optimized geometry

' ) self._viz_label.layout.display = "" elif ct == "Reorganization Energy": self._viz_label.value = ( - '

Optimized neutral geometry

' ) self._viz_label.layout.display = "" @@ -5275,7 +5296,7 @@ def _run_required_final_single_point(target_mol, reason: str): # Update completion banner _mol_label = _ana_ctx.label self._completion_mol_lbl.value = ( - f'' + f'' f"{_mol_label}" ) self._completion_banner.layout.display = "" @@ -5540,7 +5561,7 @@ def _run_required_final_single_point(target_mol, reason: str): _err_html = ( '
' - '⚠ Dependency Not Available
' + f'⚠ Dependency Not Available
' f'{_err_detail}

' 'On Windows, use the Apptainer container: ' "apptainer run quantui.sif. " @@ -5637,7 +5658,7 @@ def _run_required_final_single_point(target_mol, reason: str): _err_html = ( '
' - '⚠ Calculation Failed
' + f'⚠ Calculation Failed
' f'{exc}

' '' "Tips: try a smaller basis set (STO-3G), use a geometry-optimized " @@ -5724,7 +5745,7 @@ def _format_elapsed_chip(self, elapsed: float) -> str: if frac is not None and frac >= 0.03 and elapsed > 0: remaining = elapsed * (1.0 - frac) / frac return ( - '' + f'' f"{base} · ~{format_elapsed(remaining)} left" ) @@ -5741,7 +5762,7 @@ def _format_elapsed_chip(self, elapsed: float) -> str: base = f"{base} · ~{format_elapsed(remaining)} left{rough}" else: base = f"{base} · longer than estimated" - return f'{base}' + return f'{base}' def _stop_elapsed_ticker(self) -> None: """Stop the elapsed ticker and clear the chip.""" @@ -5990,9 +6011,9 @@ def _render_log(self, text: str, source_label: str = "") -> None: elif "QuantUI — Quantum Chemistry Interface" in line: style = "color:#6d28d9;font-weight:700" elif line.startswith(" ── "): - style = "color:#334155;font-weight:700" + style = f"color:{_theme.TEXT_BODY};font-weight:700" elif line.startswith(" ✓"): - style = "color:#16a34a;font-weight:700" + style = f"color:{_theme.ACCENT_SUCCESS};font-weight:700" elif line.startswith(" ✗"): style = "color:#dc2626;font-weight:700" elif ( @@ -6000,7 +6021,7 @@ def _render_log(self, text: str, source_label: str = "") -> None: or line.startswith(" GPU:") or line.startswith(" Threads:") ): - style = "color:#475569" + style = f"color:{_theme.TEXT_SLATE_DARK}" elif ( line.startswith(" Molecule:") or line.startswith(" Method/Basis:") @@ -6015,7 +6036,7 @@ def _render_log(self, text: str, source_label: str = "") -> None: ): style = "color:#0f766e;font-weight:600" elif line.startswith(" Wall time:"): - style = "color:#64748b" + style = f"color:{_theme.TEXT_SLATE}" elif line.startswith(" ✔") or line.startswith(" ⚠"): style = "color:#d97706" # ── Geometry optimisation (ASE BFGS) ────────────────────────────── @@ -6025,14 +6046,14 @@ def _render_log(self, text: str, source_label: str = "") -> None: fmax = float(m.group(3)) # Colour by convergence: green when nearly converged, teal otherwise style = ( - "color:#16a34a;font-weight:600" + f"color:{_theme.ACCENT_SUCCESS};font-weight:600" if fmax < 0.1 - else "color:#0d9488" + else f"color:{_theme.ACCENT_TEAL}" ) else: - style = "color:#0d9488" + style = f"color:{_theme.ACCENT_TEAL}" elif line.strip() == "Step Time Energy fmax": - style = "color:#334155;font-weight:700" + style = f"color:{_theme.TEXT_BODY};font-weight:700" # ── Post-optimisation summary ────────────────────────────────────── elif line.startswith("── Final SCF"): style = "color:#6d28d9;font-weight:600" @@ -6040,25 +6061,25 @@ def _render_log(self, text: str, source_label: str = "") -> None: style = "color:#6d28d9;font-weight:600" # ── SCF convergence ──────────────────────────────────────────────── elif "converged SCF energy" in line or "SCF converged" in line: - style = "color:#16a34a;font-weight:600" + style = f"color:{_theme.ACCENT_SUCCESS};font-weight:600" elif line.lstrip().startswith("cycle=") and "E=" in line: - style = "color:#64748b" + style = f"color:{_theme.TEXT_SLATE}" # ── MO / orbital info (verbose=4) ────────────────────────────────── elif "MO energies" in line or "** MO" in line: style = "color:#1d4ed8;font-weight:600" elif "HOMO" in line or "LUMO" in line or "All MO energies" in line: - style = "color:#2563eb" + style = f"color:{_theme.ACCENT_INFO}" elif line.lstrip().startswith("occupied:") or line.lstrip().startswith( "virtual:" ): style = "color:#3b82f6" # ── Thermo / properties ──────────────────────────────────────────── elif "Mulliken" in line or "mulliken" in line: - style = "color:#7c3aed" + style = f"color:{_theme.ACCENT_PURPLE}" elif "dipole" in line.lower() or "Dipole" in line: - style = "color:#7c3aed" + style = f"color:{_theme.ACCENT_PURPLE}" elif "nuclear repulsion" in line.lower() or "Nuclear repulsion" in line: - style = "color:#94a3b8" + style = f"color:{_theme.TEXT_SUBTLE}" elif "E(MP2)" in line or "MP2 correlation" in line: style = "color:#0891b2" # ── Warnings / errors ────────────────────────────────────────────── @@ -6067,17 +6088,17 @@ def _render_log(self, text: str, source_label: str = "") -> None: elif "Error" in line or "error" in line or "failed" in line: style = "color:#dc2626" else: - style = "color:#1e293b" + style = f"color:{_theme.TEXT_STRONG}" rows.append(f'
{esc}
') self._log_output_html.value = ( '
' + "".join(rows) + "
" ) self._log_source_lbl.value = ( - f'Source: {source_label}' + f'Source: {source_label}' if source_label else "" ) @@ -6088,10 +6109,10 @@ def _render_help_topic(self, change=None) -> None: entry = HELP_TOPICS[key] self.help_content_html.value = ( f'
' - f'

' + f'padding:14px 18px;margin:8px 0;background:{_theme.BG_PANEL};max-width:700px">' + f'

' f'{entry["title"]}

' - f'
' + f'
' f'{entry["body"]}
' f"
" ) @@ -6106,7 +6127,7 @@ def _build_perf_stats_html(self) -> str: records = get_perf_history() if not records: return ( - '' + f'' "No performance data recorded yet." ) groups: dict = {} @@ -6131,12 +6152,12 @@ def _build_perf_stats_html(self) -> str: ) header = ( "" - 'Method' - 'Basis' - 'Runs' - 'Avg' - 'Min' - 'Max' + f'Method' + f'Basis' + f'Runs' + f'Avg' + f'Min' + f'Max' "" ) return ( @@ -6150,7 +6171,7 @@ def _build_events_html(self) -> str: events = get_recent_events(20) if not events: return ( - '' + f'' "No events recorded yet." ) rows = "" @@ -6160,9 +6181,9 @@ def _build_events_html(self) -> str: msg = e.get("message", "") rows += ( "" - f'{ts}' - f'{evt}' - f'{msg}' + f'{ts}' + f'{evt}' + f'{msg}' "" ) return ( diff --git a/quantui/app_analysis.py b/quantui/app_analysis.py index 1fca82e..ffba2c6 100644 --- a/quantui/app_analysis.py +++ b/quantui/app_analysis.py @@ -8,6 +8,8 @@ import ipywidgets as widgets +from . import theme as _theme + _PANEL_UNAVAILABLE_STYLE = ( "padding:12px 16px;color:#6b7280;font-size:13px;font-style:italic" ) @@ -230,7 +232,7 @@ def apply_analysis_context(app: Any, ctx: Any) -> None: source_suffix = " (from History)" if ctx.source == "history" else "" heading = _analysis_heading_label(ctx) app._analysis_context_lbl.value = ( - f'

' + f'

' f"Analysing: {_html_mod.escape(heading)}{source_suffix}

" ) has_any = bool(app._ana_available) @@ -353,7 +355,7 @@ def render_reorg_geometries(app: Any) -> None: except Exception as exc: # noqa: BLE001 app._set_html_output( app._reorg_geom_output, - f'

Geometry view failed: {exc}

', + f'

Geometry view failed: {exc}

', ) @@ -647,28 +649,28 @@ def _shift_table(label: str, shifts: list, sym: str) -> str: if not shifts: return "" rows = "".join( - f'{sym}-{n}' - f'{d:.2f} ppm' + f'{sym}-{n}' + f'{d:.2f} ppm' for n, (_i, d) in enumerate(sorted(shifts, key=lambda x: x[0]), 1) ) return ( f'' f"{label} shifts (vs. {ref}):" - f'Atom' - f'δ (ppm)' + f'Atom' + f'δ (ppm)' + rows ) shielding_rows = "".join( - f'{sym}{i + 1}' - f'{s:.2f}' + f'{sym}{i + 1}' + f'{s:.2f}' for i, (sym, s) in enumerate(zip(atom_symbols, shielding)) ) html = ( f'
' f'' - f'' - f'' + f'' + f'' f"{shielding_rows}
Atomσ (ppm)
Atomσ (ppm)
" f'' f"{_shift_table('¹H', h_shifts, 'H')}" diff --git a/quantui/app_builders.py b/quantui/app_builders.py index 9ed988d..f7ad668 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -107,15 +107,15 @@ def _ok(flag: bool, extra: str = "") -> str: env_badge = ( f'  {env}' + f'padding:1px 5px;border-radius:3px;color:{_theme.TEXT_BODY}">{env}' if env and env not in ("base", "") else "" ) cal_line = ( - f'
' + f'
' f"Timing calibration: {cal_label}
" if cal_label - else '
' + else f'
' "Timing calibration: not yet run — use the Calibrate panel in History
" ) @@ -128,7 +128,7 @@ def _ok(flag: bool, extra: str = "") -> str: # actual behavior. def _gpu_cell(gpu_state: Any) -> str: if gpu_state is None: - return '⌛ checking…' + return f'⌛ checking…' # Accepts the 2-tuple from is_gpu_available() or the 3-tuple from # probe_gpu() — the app passes the latter so the real reason can be # shown instead of a guess. @@ -144,7 +144,7 @@ def _gpu_cell(gpu_state: Any) -> str: # so offload can be slower than the CPU. Warn in place rather # than let it look like free speed. cell += ( - '
' + f'
' "⚠ consumer-class GPU — weak double precision; " "may run slower than CPU. Benchmark before relying " "on it.
" @@ -169,17 +169,17 @@ def _render_status(gpu_state: Any) -> str: ("CPU cores / Memory", f"{cores} cores / {mem}"), ] rows = "".join( - f'
' for k, v in items ) return ( - f'
' - '
' + f'
' f"QuantUI {quantui.__version__}" - 'Python {py_ver}{env_badge}
' f'
' + f'
' f'{k}{v}
{rows}
' f"{cal_line}
" @@ -209,13 +209,13 @@ def _render_status(gpu_state: Any) -> str: ], ) settings_html = widgets.HTML( - f'
' - '
Settings
' - '
' + f'
Settings
' + f'
' "Default 3D backend " - '' + f'' "(persists across launches)
" "
" ) @@ -237,9 +237,9 @@ def _render_status(gpu_state: Any) -> str: ), ) vib_fps_label = widgets.HTML( - '
Vibrational animation framerate ' - '' + f'' "(persists across launches)
" ) @@ -247,9 +247,9 @@ def _render_status(gpu_state: Any) -> str: # on consumer cards, where FP64 offload can be slower than the CPU — see # gpu_offload.is_low_fp64_device. QUANTUI_DISABLE_GPU=1 overrides this. gpu_toggle_label = widgets.HTML( - '
GPU offload ' - '' + f'' "(persists across launches; only applies when a CUDA device is " "detected)
" ) @@ -266,9 +266,9 @@ def _render_status(gpu_state: Any) -> str: # teaching tool should not silently approximate integrals. See the # "density_fitting" help topic for the measured trade-off. df_toggle_label = widgets.HTML( - '
Density fitting (RI) ' - '' + f'' "(persists across launches; faster TD-DFT / large systems, " "~0.008 kcal/mol accuracy cost)
" ) @@ -447,7 +447,7 @@ def build_history_section( [ app._perf_stats_html, widgets.HTML( - '

' + f'

' "Recent events (last 20)

" ), app._perf_events_html, @@ -465,7 +465,7 @@ def build_history_section( cal_last = load_last_calibration_label_fn() cal_note = ( - f'

' + f'

' f"Last run: {cal_last}

" if cal_last else "" @@ -484,7 +484,7 @@ def build_history_section( cal_panel = widgets.VBox( [ widgets.HTML( - f'

' + f'

' f"Benchmark this machine so the time estimator uses basis-function " f"scaling (Nβ) rather than generic defaults. " f"Tier 1 ({len(benchmark_suite)} calcs, ~15 s) is a quick " @@ -528,7 +528,7 @@ def build_history_section( layout=layout_fn(width="40px"), ) app.history_count_lbl = widgets.HTML( - '' + f'' ) app._history_calc_chips = { key: widgets.ToggleButton( @@ -574,7 +574,7 @@ def build_history_section( def _facet_label(text: str, width: str = "60px") -> widgets.HTML: return widgets.HTML( - f'{text}' ) @@ -615,7 +615,7 @@ def _facet_label(text: str, width: str = "60px") -> widgets.HTML: app.history_panel = widgets.VBox( [ widgets.HTML( - '

' + f'

' "Calculations are saved automatically. Filter below, then select " "one to view its results.

" ), @@ -677,7 +677,7 @@ def build_shared_widgets( ) -> None: """Build shared widgets used across tabs and callbacks.""" app.mol_info_html = widgets.HTML( - value='No molecule loaded yet.' + value=f'No molecule loaded yet.' ) app.mol_summary_compact = widgets.HTML(value="") # Fixed heights reserve space so swapping content (backend/palette toggle) @@ -892,7 +892,7 @@ def build_shared_widgets( widgets.VBox( [ widgets.HTML( - 'Suggests a ' + f'Suggests a ' "spin multiplicity for a transition-metal centre from its " "oxidation state and geometry. It never sets anything on " "its own — review the note, then click Apply. Charge is " @@ -920,9 +920,9 @@ def build_shared_widgets( # invisibly. (Distinct from the QM "Geometry optimization before # calculation" checkbox below, which is a full DFT/HF opt.) app.preopt_preview_label = widgets.HTML( - '' + f'' "Classical pre-optimize geometry" - ' — fast MMFF/UFF ' + f' — fast MMFF/UFF ' "cleanup of a rough structure" ) # Interactive pre-opt: run the bonded-FF pre-opt on @@ -1153,7 +1153,7 @@ def build_shared_widgets( layout=layout_fn(width="120px"), ) app._scan_unit_lbl = widgets.HTML( - 'Å' + f'Å' ) # Reorganization energy (Marcus 4-point). The mode selector chooses which @@ -1172,7 +1172,7 @@ def build_shared_widgets( tooltip="Which reorganization energy channel(s) to compute", ) app._reorg_note = widgets.HTML( - '' + f'' "4-point Marcus scheme: optimizes the neutral and ion geometries, then " "evaluates the four single-point energies to obtain λ. Runs 2–3 geometry " "optimizations, so it is slower than a single calculation." @@ -1392,7 +1392,7 @@ def build_welcome_header(app: Any, *, layout_fn: Any = None) -> None: '' '' + ' fill=_theme.ACCENT_INFO filter="url(#q-glow)"/>' '' '' "" @@ -1416,9 +1416,9 @@ def build_welcome_header(app: Any, *, layout_fn: Any = None) -> None: "
" '
QuantUI
' - '
' + f'
' "Free, open, and interactive quantum chemistry
" - '
' + f'
' f"v{quantui.__version__}  ·  " "Help tab for instructions  ·  " "System Settings tab for environment + calibration
" @@ -1476,7 +1476,7 @@ def build_molecule_section( layout=layout_fn(width="420px"), ) app.lib_count_lbl = widgets.HTML( - f'{init_note}' + f'{init_note}' ) app.xyz_area = widgets.Textarea( @@ -1521,7 +1521,7 @@ def build_molecule_section( ) app.pubchem_candidates_dd.layout.display = "none" - hint = '

' + hint = f'

' tab_preset = widgets.VBox( [ widgets.HTML( @@ -1646,7 +1646,7 @@ def build_run_section(app: Any, *, layout_fn: Any) -> None: [ widgets.HTML( '

Run Calculation

' - '

PySCF runs in this ' + f'

PySCF runs in this ' "kernel. Output appears live below. Large molecules or high-accuracy basis " "sets may take several minutes on a laptop.

" ), @@ -1670,7 +1670,7 @@ def build_run_section(app: Any, *, layout_fn: Any) -> None: widgets.HBox( [ widgets.HTML( - '' + f'' "Calculation Output" ), app.log_clear_btn, @@ -1927,12 +1927,12 @@ def _plot_export_row(prefix: str) -> widgets.HBox: orb_controls_row = widgets.HBox( [ widgets.HTML( - 'Y range:' + f'Y range:' ), app._orb_ymin_input, app._orb_ymax_input, widgets.HTML( - '' + f'' "Levels shown:" ), app._orb_n_orb_input, @@ -1977,7 +1977,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox: app._orb_iso_controls = widgets.VBox( [ widgets.HTML( - '' + f'' "Orbital isosurface:" ), app._orb_toggle, @@ -2161,7 +2161,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox: iso_body = widgets.VBox( [ widgets.HTML( - '

' + f'

' "Visualise a molecular orbital as a 3D isosurface (Linux / WSL only — " "requires PySCF and RDKit). Run or load a Single Point or Geometry " "Optimization first, then click Generate.

" @@ -2186,7 +2186,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox: app._iso_opacity_slider, app._iso_colors_dd, widgets.HTML( - '

' + f'

' "PNG export — the Save PNG button under the viewer " "captures the view exactly as you have rotated it.

" ), @@ -2255,7 +2255,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox: app._reorg_geom_body = widgets.VBox( [ widgets.HTML( - '

' + f'

' "The Marcus 4-point scheme evaluates four energies on two " "geometries per channel — the optimized neutral and the " "optimized ion. λ is how far the molecule relaxed between " @@ -2429,7 +2429,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox: # backends than the toggle suggests). app.viz_backend_label_ana = widgets.HTML( value=( - '' + f'' "Rendering with: —" ), layout=layout_fn(margin="0 0 8px 0"), @@ -2439,7 +2439,7 @@ def _plot_export_row(prefix: str) -> widgets.HBox: widgets.HBox( [ widgets.HTML( - 'Backend:' ), app.viz_backend_toggle_ana, @@ -2456,13 +2456,13 @@ def _plot_export_row(prefix: str) -> widgets.HBox: app._analysis_context_lbl = widgets.HTML( value=( - '

' + f'

' "No result loaded yet. Run a calculation or load one from History.

" ) ) app._analysis_empty_html = widgets.HTML( value=( - '

' + f'

' "No interactive analysis is available for this calculation type.
" "Run a Single Point, Geo Opt, or Frequency calculation to see " "energy-level diagrams, trajectory animations, and spectra here.

" @@ -2537,7 +2537,7 @@ def build_compare_section(app: Any, *, layout_fn: Any, rdkit_available: bool) -> [ widgets.HTML( '

Compare Calculations

' - '

' + f'

' "Select two or more saved calculations to compare side-by-side. " "Hold Ctrl (or ⌘) to select multiple entries.

" ), @@ -2555,19 +2555,19 @@ def build_compare_section(app: Any, *, layout_fn: Any, rdkit_available: bool) -> rdkit_note = ( "" if rdkit_available - else '

MOL/PDB export requires RDKit ' + else f'

MOL/PDB export requires RDKit ' "(conda install -c conda-forge rdkit).

" ) export_content = widgets.VBox( [ widgets.HTML( - '

' + f'

' "Download a self-contained PySCF script you can study or run outside the notebook.

" ), widgets.HBox([app.export_btn, app.export_status]), widgets.HTML('
'), widgets.HTML( - '

' + f'

' "Download the molecular structure in a standard chemistry file format.

" + rdkit_note ), @@ -2578,7 +2578,7 @@ def build_compare_section(app: Any, *, layout_fn: Any, rdkit_available: bool) -> app.struct_export_status, widgets.HTML('
'), widgets.HTML( - '

' + f'

' "Bundle every file in this result folder into a single zip " "for sharing.

" ), @@ -2600,7 +2600,7 @@ def build_compare_section(app: Any, *, layout_fn: Any, rdkit_available: bool) -> def build_output_tab(app: Any, *, layout_fn: Any) -> None: """Build the Output tab panel widgets.""" app._log_output_html = widgets.HTML( - '' + f'' "No log yet — run a calculation first, or use " "View log in the History tab." ) @@ -2632,7 +2632,7 @@ def build_output_tab(app: Any, *, layout_fn: Any) -> None: app.log_tab_panel = widgets.VBox( [ widgets.HTML( - '

' + f'

' "Raw PySCF output for the most recent calculation or the " "currently-selected history result. " "Energy-level diagrams, trajectories, and spectra are in the " @@ -2647,7 +2647,7 @@ def build_output_tab(app: Any, *, layout_fn: Any) -> None: app._result_log_accordion, widgets.HTML( f'


' - '

' + f'

' "Session event log — records molecule loads, calculations, " "and issue reports across this session.

" ), @@ -2699,7 +2699,7 @@ def build_output_tab(app: Any, *, layout_fn: Any) -> None: [ widgets.HTML( '

Unfinished calculations

' - '

Runs that ' + f'

Runs that ' "stopped before finishing. Load one to put its settings back on " "the Calculate tab, then press Run to continue it.

" ), @@ -2731,7 +2731,7 @@ def build_files_tab(app: Any, *, layout_fn: Any) -> None: ) app._files_path_html = widgets.HTML( value=( - '' + f'' "Current folder: (not set)" ) ) @@ -2762,7 +2762,7 @@ def build_files_tab(app: Any, *, layout_fn: Any) -> None: ) app._files_status_html = widgets.HTML( value=( - '' + f'' "Select a file to preview it; use Open to enter a folder." ) ) @@ -2779,7 +2779,7 @@ def build_files_tab(app: Any, *, layout_fn: Any) -> None: app.files_tab_panel = widgets.VBox( [ widgets.HTML( - '

' + f'

' "Read-only file browser for result artifacts and logs. " "Browsing is limited to approved roots.

" ), @@ -2846,7 +2846,7 @@ def build_help_section(app: Any, *, layout_fn: Any) -> None: app.help_tab_panel = widgets.VBox( [ widgets.HTML( - '

' + f'

' "Browse help topics below. Click ? next to the Method or Basis Set " "dropdown in the Calculate tab to jump directly to a relevant topic.

" ), @@ -2909,7 +2909,7 @@ def build_issue_widgets(app: Any, *, layout_fn: Any) -> None: ], layout=layout_fn( display="none", - border="1px solid #f59e0b", + border=f"1px solid {_theme.ACCENT_WARNING_LIGHT}", border_radius="6px", padding="12px 14px", margin="0 0 6px", diff --git a/quantui/app_formatters.py b/quantui/app_formatters.py index 02d03aa..9511424 100644 --- a/quantui/app_formatters.py +++ b/quantui/app_formatters.py @@ -22,8 +22,8 @@ def _result_extra_rows(get: Any) -> str: def _num(label: str, value: str) -> str: return ( - f'{label}' - f'{value}' + f'{label}' + f'{value}' ) rows = "" @@ -52,15 +52,15 @@ def _num(label: str, value: str) -> str: if bool(get("gpu_used", False)): _name = get("gpu_name") _device = ( - f'🚀 GPU' + f'🚀 GPU' f' — {_name}' if _name - else '🚀 GPU' + else f'🚀 GPU' ) else: - _device = 'CPU' + _device = f'CPU' rows += ( - f'Compute device' + f'Compute device' f"{_device}" ) @@ -69,9 +69,9 @@ def _num(label: str, value: str) -> str: # result types without the field safely omit it. if bool(get("density_fit", False)): rows += ( - 'Density fitting' + f'Density fitting' '⚡ RI ' - '' + f'' "(approximate 2-electron integrals)" ) @@ -84,9 +84,9 @@ def _num(label: str, value: str) -> str: if _chg is not None and _syms is not None: _charge_str = " ".join(f"{sym}:{c:+.3f}" for sym, c in zip(_syms, _chg)) rows += ( - f'' + f'' f"Mulliken charges" - f'{_charge_str}' ) return rows @@ -95,20 +95,20 @@ def _num(label: str, value: str) -> str: def format_result(r: Any) -> str: """Format a single-point-style result card.""" _conv = "Yes" if r.converged else "No (treat results with caution)" - _cc = "green" if r.converged else "#c00" + _cc = "green" if r.converged else _theme.ACCENT_ERROR_ALT _gap = f"{r.homo_lumo_gap_ev:.4f} eV" if r.homo_lumo_gap_ev is not None else "N/A" _rows = "".join( f"" - f'{k}' + f'{k}' f'{v}' f"" for k, v, vc in [ ( "Total energy", f"{r.energy_hartree:.8f} Ha  ({r.energy_ev:.4f} eV)", - "#000", + _theme.TEXT_HEADING, ), - ("HOMO-LUMO gap", _gap, "#000"), + ("HOMO-LUMO gap", _gap, _theme.TEXT_HEADING), ("SCF converged", _conv, _cc), ( "SCF iterations", @@ -117,13 +117,13 @@ def format_result(r: Any) -> str: if getattr(r, "n_iterations", None) in (None, -1) else str(r.n_iterations) ), - "#000", + _theme.TEXT_HEADING, ), ] ) _extra = _result_extra_rows(lambda k, d=None: getattr(r, k, d)) return ( - f'
' f"{r.formula} — {r.method}/{r.basis}" f'' @@ -134,22 +134,26 @@ def format_result(r: Any) -> str: def format_opt_result(r: Any) -> str: """Format a geometry-optimization result card.""" _conv = "Yes" if r.converged else "No (max steps reached)" - _cc = "green" if r.converged else "#c00" + _cc = "green" if r.converged else _theme.ACCENT_ERROR_ALT _rows = "".join( f"" - f'' + f'' f'' f"" for k, v, vc in [ - ("Final energy", f"{r.energy_hartree:.8f} Ha", "#000"), - ("Energy change", f"{r.energy_change_hartree:+.6f} Ha", "#000"), + ("Final energy", f"{r.energy_hartree:.8f} Ha", _theme.TEXT_HEADING), + ( + "Energy change", + f"{r.energy_change_hartree:+.6f} Ha", + _theme.TEXT_HEADING, + ), ("Opt converged", _conv, _cc), - ("Steps taken", str(r.n_steps), "#000"), - ("Geometry RMSD", f"{r.rmsd_angstrom:.4f} Å", "#000"), + ("Steps taken", str(r.n_steps), _theme.TEXT_HEADING), + ("Geometry RMSD", f"{r.rmsd_angstrom:.4f} Å", _theme.TEXT_HEADING), ] ) return ( - f'
' f"Geometry Optimisation — {r.formula} ({r.method}/{r.basis})" f'
{k}{k}{v}
' @@ -160,7 +164,7 @@ def format_opt_result(r: Any) -> str: def format_freq_result(r: Any) -> str: """Format a frequency-analysis result card.""" _conv = "Yes" if r.converged else "No (treat with caution)" - _cc = "green" if r.converged else "#c00" + _cc = "green" if r.converged else _theme.ACCENT_ERROR_ALT n_real = r.n_real_modes() n_imag = r.n_imaginary_modes() real_freqs = sorted(f for f in r.frequencies_cm1 if f > 0)[:6] @@ -170,25 +174,25 @@ def format_freq_result(r: Any) -> str: imag_note = "" if n_imag > 0: imag_note = ( - f'' - f'' + f'' + f'' ) _rows = ( - f'' - f'' - f'' + f'' + f'' + f'' f'' - f'' - f'' + f'' + f'' + imag_note + ( - f'' - f'' + f'' + f'' if real_freqs else "" ) - + f'' - f'' + f'" ) _thermo_rows = "" @@ -196,20 +200,20 @@ def format_freq_result(r: Any) -> str: if _thermo is not None: _kj = 2625.5 # kJ/mol per Hartree _thermo_rows = ( - f'" - f'' - f'' - f'' - f'' - f'' - f'' + f'' + f'' + f'' + f'' + f'" ) return ( - f'
' f"Frequency Analysis — {r.formula} ({r.method}/{r.basis})" f'
Imaginary modes{n_imag} — geometry may not be a minimum
Imaginary modes{n_imag} — geometry may not be a minimum
SCF energy{r.energy_hartree:.8f} Ha
SCF converged
SCF energy{r.energy_hartree:.8f} Ha
SCF converged{_conv}
Real modes{n_real}
Real modes{n_real}
Frequencies (cm⁻¹){freq_str or "none"}
Frequencies (cm⁻¹){freq_str or "none"}
ZPVE{r.zpve_hartree:.6f} Ha ' + + f'
ZPVE{r.zpve_hartree:.6f} Ha ' f"({r.zpve_hartree * 27.211386245988:.4f} eV)
' f"— Thermochemistry at {_thermo.temperature_k:.0f} K / 1 atm —" f"
H (298 K){_thermo.H_hartree:.6f} Ha
S (298 K){_thermo.S_jmol:.2f} J/(mol·K)
G (298 K){_thermo.G_hartree:.6f} Ha' + f'
H (298 K){_thermo.H_hartree:.6f} Ha
S (298 K){_thermo.S_jmol:.2f} J/(mol·K)
G (298 K){_thermo.G_hartree:.6f} Ha' f" ({_thermo.G_hartree * _kj:.2f} kJ/mol)
' @@ -220,14 +224,14 @@ def format_freq_result(r: Any) -> str: def format_tddft_result(r: Any) -> str: """Format a TD-DFT / UV-Vis result card.""" _conv = "Yes" if r.converged else "No (treat with caution)" - _cc = "green" if r.converged else "#c00" + _cc = "green" if r.converged else _theme.ACCENT_ERROR_ALT header_rows = ( - f'' - f'' - f'' + f'' + f'' + f'' f'' - f'' - f'' + f'' + f'' ) exc_table = "" if r.excitation_energies_ev: @@ -239,29 +243,29 @@ def format_tddft_result(r: Any) -> str: bold = "font-weight:bold" if f_osc > 0.05 else "" exc_rows.append( f'' - f'' - f'' - f'' - f'' + f'' + f'' + f'' + f'' f"" ) if len(r.excitation_energies_ev) > 8: exc_rows.append( - f'" ) exc_table = ( - '" "" - '' - '' - '' - '' + f'' + f'' + f'' + f'' + "".join(exc_rows) ) return ( - f'
' f"TD-DFT / UV-Vis — {r.formula} ({r.method}/{r.basis})" f'
Ground-state energy{r.energy_hartree:.8f} Ha
SCF converged
Ground-state energy{r.energy_hartree:.8f} Ha
SCF converged{_conv}
States computed{len(r.excitation_energies_ev)}
States computed{len(r.excitation_energies_ev)}
S{i}{e_ev:.3f} eV{wl[i - 1]:.1f} nmf = {f_osc:.4f}S{i}{e_ev:.3f} eV{wl[i - 1]:.1f} nmf = {f_osc:.4f}
… ' + f'
… ' f"and {len(r.excitation_energies_ev) - 8} more states
' + f'
' "Vertical excitations:
StateEnergyλOsc. str.
StateEnergyλOsc. str.
' @@ -272,12 +276,12 @@ def format_tddft_result(r: Any) -> str: def format_nmr_result(r: Any) -> str: """Format an NMR shielding result card.""" _conv = "Yes" if r.converged else "No (treat with caution)" - _cc = "green" if r.converged else "#c00" + _cc = "green" if r.converged else _theme.ACCENT_ERROR_ALT header_rows = ( - f'' + f'' f'' - f'' - f'' + f'' + f'' ) def _nmr_table(label: str, shifts: list, sym: str) -> str: @@ -285,17 +289,17 @@ def _nmr_table(label: str, shifts: list, sym: str) -> str: return "" rows = "".join( f"" - f'' - f'' + f'' + f'' f"" for n, (_i, d) in enumerate(shifts, 1) ) return ( - f'" f"" - f'' - f'' + f'' + f'' + rows ) @@ -306,7 +310,7 @@ def _nmr_table(label: str, shifts: list, sym: str) -> str: if r.basis.upper() in ("STO-3G", "3-21G"): _basis_warn = ( '" ) @@ -320,7 +324,7 @@ def _nmr_table(label: str, shifts: list, sym: str) -> str: if getattr(r, "is_fallback_reference", False): _ref_warn = ( '" ) return ( - f'
' f"NMR Shielding — {r.formula} ({r.method}/{r.basis})" f'
SCF converged
SCF converged{_conv}
Reference{r.reference_compound} ({r.method}/{r.basis})
Reference{r.reference_compound} ({r.method}/{r.basis})
{sym}-{n}{d:.2f} ppm{sym}-{n}{d:.2f} ppm
' + f'
' f"{label} shifts (vs. TMS):
Atomδ (ppm)
Atomδ (ppm)
' - '' + f'' f"⚠ {r.basis} gives qualitative NMR only — use 6-31G* or better." "
' - '' + f'' f"⚠ No calibrated TMS reference for {r.method}/{r.basis} — using " f"{getattr(r, 'reference_key', 'B3LYP/6-31G*')} constants instead. " "Shifts may be off by a few ppm." @@ -330,12 +334,12 @@ def _nmr_table(label: str, shifts: list, sym: str) -> str: _empty = "" if not r.h_shifts() and not r.c_shifts(): _empty = ( - '
' + f'
' "No ¹H or ¹³C atoms found in this molecule.
' @@ -346,33 +350,33 @@ def _nmr_table(label: str, shifts: list, sym: str) -> str: def format_pes_scan_result(r: Any) -> str: """Format a PESScanResult as an HTML result card.""" _conv = "Yes" if r.converged_all else "No (some points did not converge)" - _cc = "green" if r.converged_all else "#c00" + _cc = "green" if r.converged_all else _theme.ACCENT_ERROR_ALT if r.energies_hartree: e_min = min(r.energies_hartree) e_max = max(r.energies_hartree) barrier_kcal = (e_max - e_min) * 627.509474 _e_row = ( - f'' - f'' - f'' - f'' + f'' + f'' + f'' + f'' ) else: _e_row = "" _idx_str = "–".join(str(i + 1) for i in r.atom_indices) return ( - f'
' f"PES Scan — {r.formula} ({r.method}/{r.basis})" f'
Min energy{e_min:.8f} Ha
Energy range{barrier_kcal:.2f} kcal/mol
Min energy{e_min:.8f} Ha
Energy range{barrier_kcal:.2f} kcal/mol
' - f'' - f'' - f'' - f'' + f'' + f'' + f'" f"{_e_row}" - f'' + f'' f'' f"
Scan type{r.scan_type.capitalize()} ({_idx_str})
Range{r.scan_parameter_values[0]:.3f} → ' + f'
Scan type{r.scan_type.capitalize()} ({_idx_str})
Range{r.scan_parameter_values[0]:.3f} → ' f"{r.scan_parameter_values[-1]:.3f} {r.scan_unit} " f"({r.n_steps} points)
All converged
All converged{_conv}
" ) @@ -522,7 +526,7 @@ def reorg_comparison_html(entries: list[tuple[str, dict]]) -> str: return ( '
' '

Reorganization energy

' - '

' + f'

' "Lower λ means less geometric reorganization on charging — generally " "favourable for charge transport. Relaxation is the largest per-channel " "RMSD between the neutral and ion geometries.

" @@ -581,8 +585,8 @@ def _label(kind: str) -> str: idx, dist = relax["max_atom"] rows.append(("Largest atom shift", f"{dist:.4f} Å (atom {idx + 1})")) body = "".join( - f'{k}' - f'{v}' + f'{k}' + f'{v}' for k, v in rows ) blocks.append( @@ -611,7 +615,7 @@ def reorg_missing_data_notice() -> str: """ return ( '
' + f'background:#fef3c7;border:1px solid {_theme.ACCENT_WARNING_LIGHT};font-size:13px;color:#78350f">' "⚠ Reorganization-energy details were not saved for this result.
" "Results produced before QuantUI gained λ persistence did not store the " "per-channel energies or geometries, and they cannot be recovered from " @@ -624,7 +628,7 @@ def reorg_missing_data_notice() -> str: def format_reorg_result(r: Any) -> str: """Format a reorganization-energy (Marcus 4-point) result card.""" _conv = "Yes" if r.converged else "No (some steps did not converge)" - _cc = "green" if r.converged else "#c00" + _cc = "green" if r.converged else _theme.ACCENT_ERROR_ALT # Same renderer, same shape as the saved payload — see reorg_channels_html. from quantui.results_storage import _reorg_channels_payload @@ -633,16 +637,16 @@ def format_reorg_result(r: Any) -> str: _attach_relaxation(_payload, r) _channels_html = reorg_channels_html(_payload) return ( - f'
' f"Reorganization Energy (Marcus 4-point) — " f"{r.formula} ({r.method}/{r.basis})" f'' - f'' - f'' - f'' - f'' - f'' + f'' + f'' + f'' + f'' + f'' f'' f"
Neutral energy{r.neutral_energy_hartree:.8f} Ha
Total opt steps{r.n_total_opt_steps}
All converged
Neutral energy{r.neutral_energy_hartree:.8f} Ha
Total opt steps{r.n_total_opt_steps}
All converged{_conv}
{_channels_html}
" ) @@ -653,16 +657,16 @@ def format_past_result(data: dict[str, Any], result_dir: Optional[Path] = None) import base64 as _b64 _ct_labels = { - "single_point": ("Single Point", "#2563eb", "#dbeafe"), - "geometry_opt": ("Geometry Optimization", "#7c3aed", "#ede9fe"), + "single_point": ("Single Point", _theme.ACCENT_INFO, "#dbeafe"), + "geometry_opt": ("Geometry Optimization", _theme.ACCENT_PURPLE, "#ede9fe"), "frequency": ("Frequency Analysis", "#15803d", "#dcfce7"), - "tddft": ("TD-DFT", "#b45309", "#fef3c7"), - "nmr": ("NMR", "#0d9488", "#ccfbf1"), + "tddft": ("TD-DFT", _theme.ACCENT_WARNING, "#fef3c7"), + "nmr": ("NMR", _theme.ACCENT_TEAL, "#ccfbf1"), "pes_scan": ("PES Scan", "#c2410c", "#ffedd5"), } ct = data.get("calc_type", "") _ct_label, _ct_fg, _ct_bg = _ct_labels.get( - ct, (ct.replace("_", " ").title(), "#555", "#f3f4f6") + ct, (ct.replace("_", " ").title(), _theme.TEXT_SECONDARY, "#f3f4f6") ) _ct_badge = ( f'{_ct_label}' ) _conv = "Yes" if data.get("converged") else "No (treat results with caution)" - _cc = "green" if data.get("converged") else "#c00" + _cc = "green" if data.get("converged") else _theme.ACCENT_ERROR_ALT _gap = ( f"{data['homo_lumo_gap_ev']:.4f} eV" if data.get("homo_lumo_gap_ev") is not None @@ -678,16 +682,16 @@ def format_past_result(data: dict[str, Any], result_dir: Optional[Path] = None) ) _rows = "".join( f"" - f'{k}' + f'{k}' f'{v}' f"" for k, v, vc in [ ( "Total energy", f"{data['energy_hartree']:.8f} Ha  ({data['energy_ev']:.4f} eV)", - "#000", + _theme.TEXT_HEADING, ), - ("HOMO-LUMO gap", _gap, "#000"), + ("HOMO-LUMO gap", _gap, _theme.TEXT_HEADING), ("SCF converged", _conv, _cc), ( "SCF iterations", @@ -696,7 +700,7 @@ def format_past_result(data: dict[str, Any], result_dir: Optional[Path] = None) if data.get("n_iterations") in (None, -1) else str(data.get("n_iterations")) ), - "#000", + _theme.TEXT_HEADING, ), ] ) @@ -732,12 +736,12 @@ def format_past_result(data: dict[str, Any], result_dir: Optional[Path] = None) _reorg_html = reorg_missing_data_notice() return ( - f'
' f"{_thumb_html}" f"{_ct_badge}
" f'{data["formula"]} — {data["method"]}/{data["basis"]}' - f' {ts}' + f' {ts}' f'' f"{_rows}{_extra}
{_reorg_html}
" ) diff --git a/quantui/app_history.py b/quantui/app_history.py index 2ae006c..556f613 100644 --- a/quantui/app_history.py +++ b/quantui/app_history.py @@ -12,6 +12,8 @@ import ipywidgets as widgets from IPython.display import HTML, display +from . import theme as _theme + # ══ HISTORY SEARCH / FACETED FILTER (HIST.7) ══════════════════════════════ # # The History browser caches the parsed ``result.json`` of every saved calc as @@ -223,7 +225,7 @@ def apply_history_filter(app: Any) -> None: count_lbl = getattr(app, "history_count_lbl", None) if count_lbl is not None: count_lbl.value = ( - '' + f'' f"{len(matches)} of {len(entries)} shown" ) diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py index 490819d..121d2e7 100644 --- a/quantui/app_runflow.py +++ b/quantui/app_runflow.py @@ -9,6 +9,8 @@ import ipywidgets as widgets from IPython.display import HTML, Javascript, display +from quantui import theme as _theme + def _calc_type_badge(calc_type: str) -> str: return { @@ -22,17 +24,6 @@ def _calc_type_badge(calc_type: str) -> str: }.get(calc_type, calc_type or "Unknown") -# Calc-type dropdown label → canonical schema key (used for the header banner). -_CALC_TYPE_CANON = { - "Geometry Opt": "geometry_opt", - "Frequency": "frequency", - "UV-Vis (TD-DFT)": "tddft", - "NMR Shielding": "nmr", - "PES Scan": "pes_scan", - "Reorganization Energy": "reorganization_energy", -} - - def _write_run_header(app: Any) -> None: """Write the full run header to the live log — synchronously, atomically. @@ -59,7 +50,7 @@ def _write_run_header(app: Any) -> None: try: from quantui.log_utils import format_log_header - calc_type = _CALC_TYPE_CANON.get(app.calc_type_dd.value, "single_point") + calc_type = _CALC_TYPE_KEYS.get(app.calc_type_dd.value, "single_point") try: _n_atoms = len(mol.atoms) except Exception: @@ -218,7 +209,7 @@ def _update_basis_fix_button(app: Any, mol: Any) -> None: _hide_basis_fix_button(app) -def _spin_small(text: str, color: str = "#444") -> str: +def _spin_small(text: str, color: str = _theme.TEXT_LABEL) -> str: return f'{text}' @@ -242,12 +233,12 @@ def on_spin_suggest(app: Any, btn: Any = None) -> None: app.spin_geom_dd.value, ) except ValueError as exc: - app.spin_helper_output.value = _spin_small(f"⚠ {exc}", "#b45309") + app.spin_helper_output.value = _spin_small(f"⚠ {exc}", _theme.ACCENT_WARNING) return lines = [_spin_small(s.explanation)] for c in s.caveats: - lines.append(_spin_small(f"⚠ {c}", "#b45309")) + lines.append(_spin_small(f"⚠ {c}", _theme.ACCENT_WARNING)) app.spin_helper_output.value = "
".join(lines) # Arm one Apply button per candidate spin state, labelled with the state. @@ -358,7 +349,7 @@ def on_calc_type_changed(app: Any, change: Any, *, layout_fn: Any) -> None: ), app._tddft_seed_note, widgets.HTML( - '⚠ Requires a DFT ' + f'⚠ Requires a DFT ' "functional (e.g. B3LYP, PBE0). RHF/UHF will run TDHF (CIS) " "instead." ), @@ -366,7 +357,7 @@ def on_calc_type_changed(app: Any, change: Any, *, layout_fn: Any) -> None: elif ct == "NMR Shielding": app.calc_extra_opts.children = [ widgets.HTML( - '' + f'' "⚠ Recommended: B3LYP/6-31G* or better. " "STO-3G and 3-21G give qualitative results only. " "Start from an optimised geometry for best accuracy." @@ -420,17 +411,23 @@ def update_scan_widgets(app: Any, _change: Any = None) -> None: st = app._scan_type_dd.value if st == "Bond": app._scan_atom34_box.layout.display = "none" - app._scan_unit_lbl.value = 'Å' + app._scan_unit_lbl.value = ( + f'Å' + ) elif st == "Angle": app._scan_atom4.layout.display = "none" app._scan_atom3.layout.display = "" app._scan_atom34_box.layout.display = "" - app._scan_unit_lbl.value = '°' + app._scan_unit_lbl.value = ( + f'°' + ) else: # Dihedral app._scan_atom3.layout.display = "" app._scan_atom4.layout.display = "" app._scan_atom34_box.layout.display = "" - app._scan_unit_lbl.value = '°' + app._scan_unit_lbl.value = ( + f'°' + ) # Default RMSD tolerance for the seed-geometry "same molecule" check. @@ -648,7 +645,7 @@ def on_seed_changed(app: Any, change: Any) -> None: app._freq_preopt_cb.disabled = False if path_str: app._seed_note.value = ( - '' + f'' "✓ The run will start from the selected result's final geometry " "instead of the current molecule." "" @@ -676,7 +673,7 @@ def on_clear_log(app: Any, btn: Any) -> None: app.run_output.clear_output() -def _preopt_small(text: str, color: str = "#555") -> str: +def _preopt_small(text: str, color: str = _theme.TEXT_SECONDARY) -> str: return f'{text}' @@ -773,14 +770,14 @@ def _preopt_preview_done(app: Any, relaxed: Any, rmsd: float, frames: Any) -> No f"{unsupported}. Make sure the starting geometry is sensible first " "(a bundled inorganic example or an XYZ paste are good starting " "points).", - "#b45309", + _theme.ACCENT_WARNING, ) else: app.preopt_preview_status.value = _preopt_small( f"Pre-optimization ({engine}) found no meaningful change — " f"RMSD {rmsd:.3f} Å. Your geometry is already reasonable, so there " "is nothing to keep or revert; the calculation will use it as-is.", - "#444", + _theme.TEXT_LABEL, ) try: app._activity_end(kind="ui") @@ -803,7 +800,11 @@ def _preopt_preview_done(app: Any, relaxed: Any, rmsd: float, frames: Any) -> No except Exception as exc: # noqa: BLE001 app.preopt_preview_output.clear_output() with app.preopt_preview_output: - display(HTML(_preopt_small(f"Preview render failed: {exc}", "#b91c1c"))) + display( + HTML( + _preopt_small(f"Preview render failed: {exc}", _theme.ACCENT_ERROR) + ) + ) from quantui.preopt import preopt_engine_label @@ -812,7 +813,7 @@ def _preopt_preview_done(app: Any, relaxed: Any, rmsd: float, frames: Any) -> No f"Relaxed ({engine}): moved {rmsd:.3f} Å (RMSD) from your " "input. Use ⇄ or the slider below to compare input vs relaxed, then " "Keep it or revert.", - "#444", + _theme.TEXT_LABEL, ) app.preopt_accept_btn.disabled = False app.preopt_reset_btn.disabled = False @@ -823,7 +824,9 @@ def _preopt_preview_done(app: Any, relaxed: Any, rmsd: float, frames: Any) -> No def _preopt_preview_failed(app: Any, msg: str) -> None: - app.preopt_preview_status.value = _preopt_small(f"Preview failed: {msg}", "#b91c1c") + app.preopt_preview_status.value = _preopt_small( + f"Preview failed: {msg}", _theme.ACCENT_ERROR + ) app.preopt_preview_btn.disabled = False try: app._activity_end(kind="ui") @@ -953,7 +956,7 @@ def on_compare(app: Any, btn: Any, *, layout_fn: Any) -> None: btns.append(button) display( widgets.HTML( - '

Analyse a result:

' ) ) @@ -984,7 +987,7 @@ def on_copy_results_path(app: Any, btn: Any) -> None: def _reset() -> None: time.sleep(3) app.results_path_lbl.value = ( - f'{p}' + f'{p}' ) threading.Thread(target=_reset, daemon=True).start() @@ -1010,7 +1013,7 @@ def on_confirm_no(app: Any, btn: Any) -> None: def on_log_clear(app: Any, btn: Any) -> None: """Clear rendered event-log output widgets in the Log tab.""" app._log_output_html.value = ( - 'Log cleared.' + f'Log cleared.' ) app._log_source_lbl.value = "" @@ -1065,7 +1068,7 @@ def on_issue_submit(app: Any, *, issue_tracker_mod: Any) -> None: text = app._issue_textarea.value.strip() if not text: app._issue_status_html.value = ( - '' + f'' "Please describe the issue before submitting." ) return @@ -1077,14 +1080,12 @@ def on_issue_submit(app: Any, *, issue_tracker_mod: Any) -> None: session_id=app._session_id, ) app._issue_status_html.value = ( - f'' + f'' f"✓ Issue #{issue_id} saved. Thank you!" ) app._issue_overlay.layout.display = "none" except Exception as exc: - app._issue_status_html.value = ( - f'Save failed: {exc}' - ) + app._issue_status_html.value = f'Save failed: {exc}' finally: app._issue_submit_btn.disabled = False @@ -1147,7 +1148,7 @@ def _arm_exit(app: Any) -> None: app._exit_btn.tooltip = "Click again to stop the server and end this session" app._exit_btn.layout.width = "150px" app._exit_warn_html.value = ( - 'This stops the server and ends your session.' ) app._exit_warn_html.layout.display = "" @@ -1208,7 +1209,7 @@ def _perform_exit(app: Any) -> None: app._welcome_html.value = ( '
' - '
' + f'
' "QuantUI has shut down. You may close this tab.
" "
" ) @@ -1257,7 +1258,7 @@ def on_cal_run( app._cal_progress.layout.display = "" app._cal_step_label.layout.display = "" app._cal_step_label.value = ( - 'Starting…' + f'Starting…' # Reserve a second invisible line so the live-message ticker # doesn't jump the accordion height. '
.' @@ -1337,7 +1338,7 @@ def _err_detail(s) -> str: if len(msg) > 140: msg = msg[:137] + "…" return ( - '
' + f'
' f"{_html_mod.escape(msg)}" ) @@ -1456,9 +1457,9 @@ def _progress( # flip-flop. Empty live-message becomes a transparent dot to # preserve the line-height. live_line_text = live_message if live_message else "." - live_line_color = "#64748b" if live_message else "transparent" + live_line_color = _theme.TEXT_SLATE if live_message else "transparent" app._cal_step_label.value = ( - f'' + f'' f"Step {step_n} / {total} — {label} " f"[{icon} {elapsed:.1f} s]" f'
' @@ -1496,7 +1497,7 @@ def _progress( app._activity_end(kind="compute") app._cal_step_label.value = ( - 'Calibration complete. ' + f'Calibration complete. ' "Time estimates are now active." '
.' if result.n_completed > 0 @@ -1545,7 +1546,7 @@ def _update_open_shell_hint(app: Any) -> None: if app.method_dd.value.upper() == "RHF": # Actionable: RHF is the one method that will misbehave for open-shell. app._open_shell_hint.value = ( - '' + f'' f"⚠ Open-shell: {n_unpaired} unpaired electron{plural} " f"(multiplicity {mult}). RHF assumes all electrons are paired — " "switch to UHF (or a DFT method) for this system." @@ -1553,7 +1554,7 @@ def _update_open_shell_hint(app: Any) -> None: else: # Informational: UHF / DFT already handle open-shell correctly. app._open_shell_hint.value = ( - '' + f'' f"Open-shell: {n_unpaired} unpaired electron{plural} " f"(multiplicity {mult}) — running unrestricted." ) @@ -1696,7 +1697,7 @@ def _hide() -> None: state = ckpt.resumable_state() or {} detail = _resume_detail(ckpt, state) notice.value = ( - '' + f'' "♻ An interrupted run of this exact calculation was found" f"{detail}." ) @@ -1742,16 +1743,11 @@ def _resume_entry_label(state: dict) -> str: def _formula_from_symbols(symbols: Any) -> str: - """Hill-notation formula from a list of element symbols.""" - from collections import Counter - - counts = Counter(str(s) for s in symbols) - if not counts: - return "?" - order = [s for s in ("C", "H") if s in counts] + sorted( - s for s in counts if s not in ("C", "H") - ) - return "".join(f"{s}{counts[s] if counts[s] > 1 else ''}" for s in order) + """Hill-notation formula from a list of element symbols, ``"?"`` if empty.""" + from .connectivity import hill_formula + + symbols = [str(s) for s in symbols] + return hill_formula(symbols) if symbols else "?" def refresh_resume_list(app: Any) -> None: @@ -1837,7 +1833,7 @@ def describe_resume_entry(app: Any, _change: Any = None) -> None: restore_btn.disabled = False html.value = ( - f'{done}' + f'{done}' f"; charge {state.get('charge', 0)}, multiplicity " f"{state.get('multiplicity', 1)}.{note}" ) @@ -1971,7 +1967,7 @@ def refresh_results_browser(app: Any) -> None: ) app.results_path_lbl.value = ( - f'' + f'' f"{app._get_results_dir()}" ) dirs = list_results() diff --git a/quantui/calc_log.py b/quantui/calc_log.py index fa78188..5fe87cc 100644 --- a/quantui/calc_log.py +++ b/quantui/calc_log.py @@ -23,6 +23,8 @@ from pathlib import Path from typing import Optional +from . import theme as _theme + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- @@ -991,6 +993,7 @@ def _eff(r: dict) -> Optional[float]: n_basis=n_basis, n_cores=n_cores, gpu_used=gpu_used, + source=source, ) if cost_est is not None: return cost_est @@ -1060,11 +1063,15 @@ def format_estimate(est: Optional[dict]) -> str: else: time_str = f"~{s / 3600:.1f} hr" - colour = {"high": "#22c55e", "medium": "#f59e0b", "low": "#94a3b8"}[conf] + colour = { + "high": "#22c55e", + "medium": _theme.ACCENT_WARNING_LIGHT, + "low": _theme.TEXT_SUBTLE, + }[conf] return ( - f'' + f'' f'Estimated time: {time_str}' - f' ({conf} confidence, {n} similar ' + f' ({conf} confidence, {n} similar ' f'run{"s" if n != 1 else ""})' ) diff --git a/quantui/connectivity.py b/quantui/connectivity.py index 7139421..09d1cd0 100644 --- a/quantui/connectivity.py +++ b/quantui/connectivity.py @@ -223,8 +223,13 @@ def covalent_components( return components -def _hill_formula(symbols: Sequence[str]) -> str: - """Formula in Hill order (C, H, then alphabetical); '2' subscripts inline.""" +def hill_formula(symbols: Sequence[str]) -> str: + """Formula in Hill order (C, H, then alphabetical); '2' subscripts inline. + + The one shared implementation — :meth:`quantui.molecule.Molecule.get_formula` + and :func:`quantui.app_runflow._formula_from_symbols` both delegate here + (M-ISSUES ISSUE.8) so the convention only has to change in one place. + """ counts: Dict[str, int] = {} for s in symbols: counts[s] = counts.get(s, 0) + 1 @@ -270,7 +275,7 @@ def describe_disconnection( formula_counts: Dict[str, int] = {} order: List[str] = [] for comp in components: - f = _hill_formula([atoms[i] for i in comp]) + f = hill_formula([atoms[i] for i in comp]) if f not in formula_counts: order.append(f) formula_counts[f] = formula_counts.get(f, 0) + 1 diff --git a/quantui/descriptor_cards.py b/quantui/descriptor_cards.py index 3be91c4..b6072c2 100644 --- a/quantui/descriptor_cards.py +++ b/quantui/descriptor_cards.py @@ -21,6 +21,7 @@ from __future__ import annotations from . import config +from . import theme as _theme # ── Icons (24×24 inline SVG, currentColor) ─────────────────────────────────── @@ -79,18 +80,18 @@ # app reads as one system. _METHOD_FAMILY_STYLE = { - "hf": ("#2563eb", "#eff6ff", _ICON_HF), - "dft": ("#7c3aed", "#f5f3ff", _ICON_DFT), - "wavefunction": ("#b45309", "#fffbeb", _ICON_WAVE), + "hf": (_theme.ACCENT_INFO, "#eff6ff", _ICON_HF), + "dft": (_theme.ACCENT_PURPLE, "#f5f3ff", _ICON_DFT), + "wavefunction": (_theme.ACCENT_WARNING, "#fffbeb", _ICON_WAVE), } -_METHOD_FALLBACK_STYLE = ("#475569", "#f8fafc", _ICON_HF) +_METHOD_FALLBACK_STYLE = (_theme.TEXT_SLATE_DARK, _theme.BG_PANEL, _ICON_HF) _BASIS_FAMILY_STYLE = { - "minimal": ("#64748b", "#f8fafc", _ICON_BASIS_MINIMAL), - "pople": ("#0d9488", "#f0fdfa", _ICON_BASIS_POPLE), + "minimal": (_theme.TEXT_SLATE, _theme.BG_PANEL, _ICON_BASIS_MINIMAL), + "pople": (_theme.ACCENT_TEAL, "#f0fdfa", _ICON_BASIS_POPLE), "cc": ("#15803d", "#f0fdf4", _ICON_BASIS_CC), "def2": ("#c2410c", "#fff7ed", _ICON_BASIS_DEF2), - "ecp": ("#7c3aed", "#f5f3ff", _ICON_BASIS_DEF2), + "ecp": (_theme.ACCENT_PURPLE, "#f5f3ff", _ICON_BASIS_DEF2), } # ── Basis family classification + one-line copy ────────────────────────────── @@ -152,7 +153,7 @@ def _card_html(*, fg: str, bg: str, icon: str, title: str, body: str) -> str: f'
' f'
{title}
' - f'
{body}
' f"
" ) @@ -190,5 +191,5 @@ def basis_card_html(basis: str) -> str: # the basis-set help topic instead. alias = config.pople_notation_alias(basis) if alias: - body += f' Also written {alias}.' + body += f' Also written {alias}.' return _card_html(fg=fg, bg=bg, icon=icon, title=title, body=body) diff --git a/quantui/estimator_eval.py b/quantui/estimator_eval.py index a7c0bbd..20be53d 100644 --- a/quantui/estimator_eval.py +++ b/quantui/estimator_eval.py @@ -164,17 +164,19 @@ def replay( for record in ordered: # Every record becomes history for the ones after it, whether or # not it is itself scoreable — the app's estimator sees them all. - past = history - history = history + [record] - + # Appended in place, after being used as "past" below, rather than + # concatenated into a fresh list each iteration — the same effect + # (a prediction never sees its own ground-truth record) in O(n) + # instead of O(n^2) for a large perf_log.jsonl. if not _is_scoreable(record): + history.append(record) continue key = str(record.get(slice_by) or "(unset)") bucket = slices.setdefault(key, ReplayStats(label=key)) predicted = calc_log.estimate_time_from_records( - past, + history, n_atoms=int(record.get("n_atoms") or 0), n_electrons=int(record.get("n_electrons") or 0), method=str(record.get("method") or ""), @@ -185,6 +187,7 @@ def replay( gpu_used=record.get("gpu_used"), source=record.get("source") if use_source else None, ) + history.append(record) if predicted is None or float(predicted["seconds"]) <= 0: bucket.n_no_estimate += 1 overall.n_no_estimate += 1 diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index 4b48f8b..0fe8620 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -106,6 +106,7 @@ class FreqResult: mo_occ: Optional[List] = None pyscf_mol_atom: Optional[List] = None pyscf_mol_basis: Optional[str] = None + density_fit: bool = False @property def energy_ev(self) -> float: @@ -731,4 +732,5 @@ def _tv(v): mo_occ=mo_occ_list, pyscf_mol_atom=pyscf_mol_atom, pyscf_mol_basis=basis, + density_fit=_density_fit_used, ) diff --git a/quantui/help_content.py b/quantui/help_content.py index 8ecc5ce..875ad15 100644 --- a/quantui/help_content.py +++ b/quantui/help_content.py @@ -18,6 +18,8 @@ import ipywidgets as widgets +from . import theme as _theme + # --------------------------------------------------------------------------- # Help text bank — keys used by help_panel() # --------------------------------------------------------------------------- @@ -54,7 +56,7 @@ "

The Calc. Type dropdown chooses what QuantUI computes for " "your molecule at the selected method / basis.

" "" - "" + f"" " " " " " " @@ -99,7 +101,7 @@ "

Both methods approximate the electronic wavefunction using " "Hartree–Fock theory, but they treat electron spin differently.

" "
TypeComputesCost
" - "" + f"" " " " " " " @@ -134,7 +136,7 @@ "

A basis set is the mathematical toolkit used to describe " "electron orbitals. Larger basis sets are more accurate but slower.

" "
MethodFull nameWhen to use
" - "" + f"" " " " " " " @@ -228,7 +230,7 @@ "tab. It is not a blanket win, so QuantUI does not turn it on for " "you — here is why.

" "
Basis setSpeedAccuracy
" - "" + f"" " " " " "
CalculationEffect of DF
TD-DFT (UV-Vis) and larger " @@ -267,7 +269,7 @@ "" "

Typical values:

" "" - "" + f"" " " " " " " @@ -291,7 +293,7 @@ "body": ( "

After a calculation completes, QuantUI reports several key quantities:

" "
MoleculeGap (eV)Character
" - "" + f"" " " " " "" @@ -328,8 +330,8 @@ "message itself tells you the run can be resumed. Leave every " "setting exactly as it was and return to the Calculate tab. " "A line appears just above the Run button:

" - "
" + f"
" "♻ An interrupted run of this exact calculation was found — " "8 of 20 scan points already computed." "
" @@ -354,7 +356,7 @@ "put the setting you changed back as it was.

" "

What gets saved, by calculation type:

" "
QuantityWhat it means
Total energy
" - "" + f"" " " " " "" @@ -392,8 +394,8 @@ "title": "How to cite PySCF", "body": ( "

If you use QuantUI results in a report or publication, cite PySCF:

" - "
" + f"
" "Q. Sun, X. Zhang, S. Banerjee, P. Bao, M. Barbry, N. S. Blunt, " "N. A. Bogdanov, G. H. Booth, J. Chen, Z.-H. Cui, J. J. Eriksen, " "Y. Gao, S. Guo, J. Hermann, M. R. Hermes, K. Koh, P. Koval, " @@ -431,7 +433,7 @@ "

Multiplicity = 2S + 1, where S is the total electron spin. " "It tells the computer how many unpaired electrons the molecule has.

" "
TypeResuming picks up…
Geometry Opt
" - "" + f"" " " " " " " @@ -462,7 +464,7 @@ "compliant files. No screen-scraping — open the right file in " "the right tool.

" "
Unpaired e⁻MultiplicityName
" - "" + f"" " " " " " " diff --git a/quantui/molecule.py b/quantui/molecule.py index f411385..75d7fde 100644 --- a/quantui/molecule.py +++ b/quantui/molecule.py @@ -152,32 +152,9 @@ def get_formula(self) -> str: Returns: str: Molecular formula """ - # Count atoms - atom_counts: dict[str, int] = {} - for atom in self.atoms: - atom_counts[atom] = atom_counts.get(atom, 0) + 1 - - # Build formula (C, H, then alphabetical) - formula_parts = [] - - # Carbon first (if present) - if "C" in atom_counts: - count = atom_counts["C"] - formula_parts.append(f"C{count if count > 1 else ''}") - del atom_counts["C"] - - # Hydrogen second (if present) - if "H" in atom_counts: - count = atom_counts["H"] - formula_parts.append(f"H{count if count > 1 else ''}") - del atom_counts["H"] - - # Rest alphabetically - for atom in sorted(atom_counts.keys()): - count = atom_counts[atom] - formula_parts.append(f"{atom}{count if count > 1 else ''}") - - return "".join(formula_parts) + from .connectivity import hill_formula + + return hill_formula(self.atoms) def to_pyscf_format(self) -> str: """ diff --git a/quantui/nmr_calc.py b/quantui/nmr_calc.py index 3ec26be..b88af94 100644 --- a/quantui/nmr_calc.py +++ b/quantui/nmr_calc.py @@ -47,6 +47,7 @@ class NMRResult: # method/basis. reference_key: str = "" is_fallback_reference: bool = False + density_fit: bool = False def h_shifts(self) -> List[Tuple[int, float]]: """(atom_index, δ ppm) pairs for all H atoms in molecule order.""" @@ -369,7 +370,7 @@ def _run_nmr_calc_body( # ever defaulted on for NMR. from .density_fitting import try_density_fit as _try_density_fit - mf, _ = _try_density_fit(mf) + mf, density_fit_used = _try_density_fit(mf) # Cooperative cancel between SCF cycles. from .cancellation import attach_scf_cancel_callback, cancel_check_from_stream @@ -445,4 +446,5 @@ def _run_nmr_calc_body( converged=converged, reference_key=matched_ref_key, is_fallback_reference=is_fallback_ref, + density_fit=density_fit_used, ) diff --git a/quantui/optimizer.py b/quantui/optimizer.py index c12b9f0..3fc7768 100644 --- a/quantui/optimizer.py +++ b/quantui/optimizer.py @@ -198,7 +198,7 @@ def calculate( # every SCF in the optimization when the user enables it. from .density_fitting import try_density_fit as _try_density_fit - mf, _ = _try_density_fit(mf) + mf, self._density_fit_used = _try_density_fit(mf) mf.verbose = 0 mf.stdout = _sink @@ -280,6 +280,7 @@ class OptimizationResult: mo_coeff: Optional[Any] = None pyscf_mol_atom: Optional[Any] = None # atom list at final geometry (Angstrom) pyscf_mol_basis: Optional[str] = None + density_fit: bool = False @property def energy_hartree(self) -> float: @@ -530,13 +531,16 @@ def optimize_geometry( _stream, "\n⚠ No usable checkpoint to resume — starting from the beginning.\n", ) + _resume_steps_done: Optional[int] = None if _resume_from is not None and checkpoint is not None: try: _done = (checkpoint.load_state() or {}).get("steps_done") + if isinstance(_done, int) and _done > 0: + _resume_steps_done = _done _detail = ( f"continuing from step {_done}; " "geometry and optimizer curvature restored" - if isinstance(_done, int) and _done > 0 + if _resume_steps_done is not None else "continuing from the last saved geometry" ) checkpoint.log_resumed(_detail) @@ -595,6 +599,21 @@ def optimize_geometry( append_trajectory=_resuming, restart=str(restart_path) if restart_path is not None else None, ) + if _resuming and _resume_steps_done is not None: + # A freshly constructed BFGS always starts nsteps at 0, even + # on a resumed run — left unseeded, the per-step + # checkpoint.update(steps_done=dyn.nsteps) callback below + # regresses the reported step count on every resume (a + # resumed-then-interrupted-again run under-reports how much + # work is banked). ISSUE.5. Seeding it from the checkpoint + # makes step counting continue where the previous run left + # off. (ASE's own irun() also uses nsteps==0 to guard the + # pre-loop trajectory write it does for a *fresh* run; the + # installed ASE version already skips that write whenever + # append_trajectory left the file non-empty, so this seeding + # is not needed to avoid a duplicated boundary frame here — + # only to keep the reported step count correct.) + dyn.nsteps = _resume_steps_done # Check cancel after every BFGS step (belt-and-suspenders # with the per-step calculator check above). if _cancel_check is not None: @@ -698,6 +717,7 @@ def _report_opt_fraction() -> None: _opt_mo_coeff: Optional[Any] = None _opt_mol_atom: Optional[Any] = None _opt_mol_basis: Optional[str] = None + _opt_density_fit = bool(getattr(atoms.calc, "_density_fit_used", False)) try: import numpy as _np_mo @@ -792,6 +812,7 @@ def _report_opt_fraction() -> None: mo_coeff=_opt_mo_coeff, pyscf_mol_atom=_opt_mol_atom, pyscf_mol_basis=_opt_mol_basis, + density_fit=_opt_density_fit, ) diff --git a/quantui/results_storage.py b/quantui/results_storage.py index be75c7c..7182536 100644 --- a/quantui/results_storage.py +++ b/quantui/results_storage.py @@ -280,6 +280,7 @@ def save_result( "solvent": getattr(result, "solvent", None), "gpu_used": bool(getattr(result, "gpu_used", False)), "gpu_name": getattr(result, "gpu_name", None), + "density_fit": bool(getattr(result, "density_fit", False)), "dipole_moment_debye": _opt_float(getattr(result, "dipole_moment_debye", None)), "mulliken_charges": _opt_float_list(getattr(result, "mulliken_charges", None)), "atom_symbols": _opt_str_list(getattr(result, "atom_symbols", None)), diff --git a/quantui/session_calc.py b/quantui/session_calc.py index 8f7e5cd..3114d8a 100644 --- a/quantui/session_calc.py +++ b/quantui/session_calc.py @@ -542,13 +542,30 @@ def _run_session_calc_body( # --- Run SCF --- emit_status(stream, "Running SCF…") - try: - energy_hartree = float(mf.kernel(dm0=_dm0) if _dm0 is not None else mf.kernel()) - except Exception as exc: - raise RuntimeError( - f"PySCF calculation failed for {molecule.get_formula()} " - f"({method}/{basis}): {exc}" - ) from exc + if _dm0 is not None: + try: + energy_hartree = float(mf.kernel(dm0=_dm0)) + except Exception as warm_exc: + # A warm start is an optimisation, not a requirement (M-CHECKPOINT + # CHK.1) — a warm-start-specific failure (a GPU-migrated mean-field + # rejecting a host/numpy density, or a loadable-but-incompatible + # chkfile density) must degrade to a scratch guess, not hard-fail + # an otherwise-fine calculation. + logger.warning( + "Warm-start dm0 rejected by SCF kernel, retrying from a scratch " + "guess: %s", + warm_exc, + ) + emit_status(stream, "Warm start failed — retrying from scratch guess…") + _dm0 = None + if _dm0 is None: + try: + energy_hartree = float(mf.kernel()) + except Exception as exc: + raise RuntimeError( + f"PySCF calculation failed for {molecule.get_formula()} " + f"({method}/{basis}): {exc}" + ) from exc # --- MP2 correlation energy (post-HF) --- mp2_correlation_hartree: Optional[float] = None diff --git a/quantui/tddft_calc.py b/quantui/tddft_calc.py index c1a518e..cd621b7 100644 --- a/quantui/tddft_calc.py +++ b/quantui/tddft_calc.py @@ -77,6 +77,7 @@ class TDDFTResult: excitation_energies_ev: List[float] = field(default_factory=list) oscillator_strengths: List[float] = field(default_factory=list) nstates: int = 10 + density_fit: bool = False @property def energy_ev(self) -> float: @@ -225,7 +226,7 @@ def _run_tddft_calc_body( # measured win is largest (~1.6x on aspirin), so this is the primary target. from .density_fitting import try_density_fit as _try_density_fit - mf, _ = _try_density_fit(mf) + mf, density_fit_used = _try_density_fit(mf) if using_hf and progress_stream is not None: try: @@ -314,4 +315,5 @@ def _run_tddft_calc_body( excitation_energies_ev=excitation_energies_ev, oscillator_strengths=oscillator_strengths, nstates=nstates, + density_fit=density_fit_used, ) diff --git a/quantui/theme.py b/quantui/theme.py index 979aafa..158f3df 100644 --- a/quantui/theme.py +++ b/quantui/theme.py @@ -59,19 +59,30 @@ Scope of this module today -------------------------- -Only the tokens needed for the THEME.5 fix. It is intentionally not a -full-palette migration: the codebase has ~390 hardcoded hex literals across 17 -files, most of them *semantic* accents (error red, success green, link blue) -whose hue survives ``hue-rotate(180)`` and which look correct in both modes -already. Migrating those wholesale would be a large, visually-unverifiable -change for no user-visible gain. +Originally just the THEME.5 border fix. As of 2026-08-21, also a text-tier +greyscale set and a status-accent set (see below) extracted from the 9 +widget-building "chrome" modules (``app.py``, ``app_builders.py``, +``app_formatters.py``, ``app_runflow.py``, and others) — 379 of that file +set's ~436 hardcoded-hex occurrences, covering the 22 highest-frequency +distinct values. Still not a full-palette migration: the plotting/3-D-viewer +modules (``analytics.py``, ``orbital_visualization.py``, +``app_visualization.py``, ``visualization_py3dmol.py``, ``ir_plot.py``) are +untouched — a wrong substitution there risks an actual rendering regression +a cloud session with no browser can't catch — and a long tail of ~70 +low-frequency chrome values remains too. Most of what's *left* is still +*semantic* accents (error red, success green, link blue) whose hue survives +``hue-rotate(180)`` and looks correct in both modes already; the text/accent +tokens added here are a maintainability move (one name instead of ~380 +copy-pasted literals), not a correctness fix — unlike ``BORDER``/ +``BORDER_STRONG`` above, which changed values and needed real contrast +measurement. When THEME.6 (customisable palettes) lands, the invert filter has to go — a palette system cannot work when dark mode is a derived inversion rather than an independent set of values. At that point these tokens become the seam: they grow light/dark variants and the ``_theme_css`` filter is replaced. Keeping them -named here means that change edits this file plus the CSS, not 19 call sites -again. +named here means that change edits this file plus the CSS, not hundreds of call +sites again. """ from __future__ import annotations @@ -87,6 +98,75 @@ #: (the 3-D viewer frame, which sits on its own rather than in a card stack). BORDER_STRONG = "#64748b" +#: Light legacy border/rule colour (tables, dividers). Kept distinct from +#: ``BORDER`` rather than merged into it — this module's own docstring warns +#: that a light border like this one is exactly the shape of value that +#: disappears under the dark-mode invert filter; flagged here, not yet +#: re-measured or replaced, so a later contrast pass has one name to fix +#: instead of the original scattered literal. +BORDER_LEGACY = "#ccc" + +#: Background used for panel/card chrome (result cards, descriptor cards). +#: Also the reference panel background this module's own WCAG measurements +#: (see the docstring table) were computed against. +BG_PANEL = "#f8fafc" + +# ── Text (greyscale tiers) ──────────────────────────────────────────────────── +# Extracted 2026-08-21 (M-THEME Execution Sequence step 1) from ~300 scattered +# literal occurrences across app_formatters.py, app_builders.py, app.py, +# app_runflow.py, descriptor_cards.py, and other widget-building modules — +# named so they're one greppable set instead of loose hex strings, and so a +# future *harmonization* pass (there are more of these than there should be; +# see below) only has to touch this file plus whatever it introduces, not 300 +# call sites again. +# +# Deliberately NOT harmonized to fewer distinct shades in this pass: each +# token keeps its call sites' exact original value, so migrating call sites to +# reference these is a pure extract-to-constant refactor with zero rendered- +# pixel change — safe to do without a browser, unlike the border fix (THEME.5) +# above, which needed real contrast measurement because it *changed* values. +# This module's own docstring already argues these greys were not the +# reported defect ("text contrast was never broken") — the value here is +# maintainability (one name per shade, no more copy-pasted hex) and getting +# migrated code ready for THEME.6, not a correctness fix. A later, visually +# verified pass can still collapse TEXT_MUTED/_MUTED_LIGHT/_FAINT into fewer +# WCAG-measured values the way BORDER/BORDER_STRONG already were. +TEXT_HEADING = "#000" +TEXT_LABEL = "#444" +TEXT_SECONDARY = "#555" +TEXT_MUTED = "#666" +TEXT_MUTED_LIGHT = "#777" +TEXT_FAINT = "#888" +TEXT_SUBTLE = "#94a3b8" +TEXT_BODY = "#334155" +TEXT_STRONG = "#1e293b" +#: Same numeric value as ``BORDER_STRONG`` today, coincidentally — kept as a +#: separate name because the ~25 call sites using it are text colour, not +#: borders. Decoupled on purpose: a later border-only or text-only retune +#: must not silently move the other. +TEXT_SLATE = "#64748b" +TEXT_SLATE_DARK = "#475569" + +# ── Status accents ──────────────────────────────────────────────────────────── +# This module's docstring notes semantic accents (error/success/warning hues) +# were not the reported defect — they survive ``hue-rotate(180)`` and read +# correctly in both modes already. Named here anyway for the same +# maintainability reason as the text tier: one call site can't drift from +# another when they share a name instead of a copy-pasted literal. The "_ALT" +# / "_LIGHT" siblings are distinct pre-existing values (a second red, a +# lighter amber, …), not renamed — see the text-tier note above on why this +# pass doesn't consolidate them. +ACCENT_ERROR = "#b91c1c" +ACCENT_ERROR_ALT = "#c00" +ACCENT_SUCCESS = "#16a34a" +ACCENT_SUCCESS_BG = "#f0fff0" +ACCENT_SUCCESS_ALT = "#4caf50" +ACCENT_WARNING = "#b45309" +ACCENT_WARNING_LIGHT = "#f59e0b" +ACCENT_INFO = "#2563eb" +ACCENT_PURPLE = "#7c3aed" +ACCENT_TEAL = "#0d9488" + def frame_viewer_html(view_html: str, *, width: int, controls: str = "") -> str: """Wrap a 3-D viewer fragment in the standard frame, sized to the viewer. @@ -122,4 +202,31 @@ def frame_viewer_html(view_html: str, *, width: int, controls: str = "") -> str: ) -__all__ = ["BORDER", "BORDER_STRONG", "frame_viewer_html"] +__all__ = [ + "BORDER", + "BORDER_STRONG", + "BORDER_LEGACY", + "BG_PANEL", + "TEXT_HEADING", + "TEXT_LABEL", + "TEXT_SECONDARY", + "TEXT_MUTED", + "TEXT_MUTED_LIGHT", + "TEXT_FAINT", + "TEXT_SUBTLE", + "TEXT_BODY", + "TEXT_STRONG", + "TEXT_SLATE", + "TEXT_SLATE_DARK", + "ACCENT_ERROR", + "ACCENT_ERROR_ALT", + "ACCENT_SUCCESS", + "ACCENT_SUCCESS_BG", + "ACCENT_SUCCESS_ALT", + "ACCENT_WARNING", + "ACCENT_WARNING_LIGHT", + "ACCENT_INFO", + "ACCENT_PURPLE", + "ACCENT_TEAL", + "frame_viewer_html", +] diff --git a/tests/test_optimizer.py b/tests/test_optimizer.py index 2c0429e..04e2e2f 100644 --- a/tests/test_optimizer.py +++ b/tests/test_optimizer.py @@ -469,6 +469,77 @@ def test_pyscf_output_suppressed_in_stream(self): assert "converge" not in output.lower() or "BFGS" in output +class TestOptimizeGeometryResume: + """M-ISSUES ISSUE.5 regression: resuming from a checkpoint must not + regress the reported step count. + + A fresh ``BFGS`` instance always has ``nsteps == 0``. Left unseeded on a + resumed run, the per-step ``checkpoint.update(steps_done=dyn.nsteps)`` + call reports a count that restarted at 0 instead of continuing from what + the interrupted run had already banked — a resumed-then-interrupted-again + optimization under-reports how much work is saved. Seeding ``nsteps`` + from the checkpoint on resume (``optimizer.py``) fixes it. + + The installed ASE version (>= 3.26, via ``Dynamics.irun``'s + ``_traj_is_empty()`` guard) already skips the pre-loop observer call that + an older ASE would fire unconditionally at ``nsteps == 0`` — the + duplicate-trajectory-frame half of ISSUE.5 as originally filed. The + trajectory-length assertion below is kept as a live invariant check, not + because this test demonstrates a fix for it. + """ + + @pyscf_only + @pytest.mark.slow + def test_resume_continues_the_step_count_and_does_not_duplicate_frames( + self, tmp_path, monkeypatch + ): + monkeypatch.setenv("QUANTUI_CHECKPOINT_DIR", str(tmp_path / "ckpt")) + from quantui.checkpoint import CalcIdentity, Checkpoint + from quantui.optimizer import optimize_geometry + + # Compressed bond length so the first step never converges within + # steps=1 — every call below is guaranteed to take exactly one step. + mol = _h2(0.60) + identity = CalcIdentity.from_molecule( + mol, calc_type="geometry_opt", method="RHF", basis="STO-3G" + ) + checkpoint = Checkpoint(identity) + checkpoint.begin() + + result1 = optimize_geometry( + mol, + method="RHF", + basis="STO-3G", + fmax=1e-6, + steps=1, + checkpoint=checkpoint, + resume=False, + ) + assert result1.converged is False + len_after_first_run = len(result1.trajectory) + steps_done_after_first_run = checkpoint.load_state()["steps_done"] + assert steps_done_after_first_run == 1 + + result2 = optimize_geometry( + mol, + method="RHF", + basis="STO-3G", + fmax=1e-6, + steps=1, + checkpoint=checkpoint, + resume=True, + ) + + # Exactly one new frame — not a duplicated boundary frame plus one. + assert len(result2.trajectory) == len_after_first_run + 1 + + # Step count continues from what the first run already banked, + # rather than resetting because the resumed BFGS instance's own + # nsteps starts at 0. + steps_done_after_resume = checkpoint.load_state()["steps_done"] + assert steps_done_after_resume == steps_done_after_first_run + 1 + + # ============================================================================ # Public API surface # ============================================================================ diff --git a/tests/test_theme_contrast.py b/tests/test_theme_contrast.py index 9fa7cf5..e0e5be0 100644 --- a/tests/test_theme_contrast.py +++ b/tests/test_theme_contrast.py @@ -154,12 +154,22 @@ def test_app_css_has_no_unreplaced_sentinels(self): # Order-of-replacement bug: replacing the short sentinel first leaves # a dangling "_STRONG__" behind. assert "_STRONG__" not in _APP_CSS + # M-THEME text-tier tokens added 2026-08-21 (h1/h3 colour, spinner + # border-top-color) — same class of bug, same guard. + assert "__Q_" not in _APP_CSS def test_app_css_carries_the_border_token(self): from quantui.app import _APP_CSS assert theme.BORDER in _APP_CSS + def test_app_css_carries_the_text_and_accent_tokens(self): + from quantui.app import _APP_CSS + + assert theme.TEXT_STRONG in _APP_CSS + assert theme.TEXT_SLATE in _APP_CSS + assert theme.ACCENT_INFO in _APP_CSS + def test_no_viewer_border_is_drawn_from_a_css_class(self): # Measured in the browser 2026-08-03: an Output widget cannot # shrink-wrap. Its children are Lumino widgets that JupyterLab's layout @@ -371,3 +381,89 @@ def test_retired_border_greys_are_gone_from_in_app_chrome(self): and retired.search(f.read_text(encoding="utf-8")) ] assert offenders == [], f"retired border greys still used in: {offenders}" + + +class TestNoRawHexReintroducedInMigratedChrome: + """M-THEME Execution Sequence step 1 (2026-08-21): the text/border/accent + tokens in ``theme.py`` were extracted from raw hex literals scattered + across these widget-building modules. This guards the migration from + quietly eroding — a future edit pasting ``color:#555`` back in instead of + ``{_theme.TEXT_SECONDARY}`` should fail here, not resurface as a silent + duplicate literal for the next audit to rediscover. + + Excludes ``theme.py`` itself (the source of truth) and the plotting/3-D + modules (``analytics.py``, ``orbital_visualization.py``, + ``app_visualization.py``, ``visualization_py3dmol.py``, ``ir_plot.py``) — + those were deliberately left out of this pass (M-THEME roadmap 14) since a + wrong substitution there risks a real rendering regression this suite + can't see; migrating them is future work, not yet a regression to catch. + ``results_storage.py`` is also excluded — its calc-type badge colours are + already one small, well-factored dict, not the scattered-literal problem + this migration targets. + """ + + MIGRATED_FILES = ( + "app_formatters.py", + "app_builders.py", + "app.py", + "app_runflow.py", + "descriptor_cards.py", + "help_content.py", + "app_analysis.py", + "app_history.py", + "calc_log.py", + ) + + #: theme.py attributes whose values were extracted from these files. + #: Kept as an explicit list (not "every theme.py string attribute") so a + #: token added later for a *new* use doesn't retroactively demand every + #: historical file be clean of a value it never used to begin with. + MIGRATED_TOKENS = ( + "TEXT_HEADING", + "TEXT_LABEL", + "TEXT_SECONDARY", + "TEXT_MUTED", + "TEXT_MUTED_LIGHT", + "TEXT_FAINT", + "TEXT_SUBTLE", + "TEXT_BODY", + "TEXT_STRONG", + "TEXT_SLATE", + "TEXT_SLATE_DARK", + "BG_PANEL", + "BORDER_LEGACY", + "ACCENT_ERROR", + "ACCENT_ERROR_ALT", + "ACCENT_SUCCESS", + "ACCENT_SUCCESS_BG", + "ACCENT_SUCCESS_ALT", + "ACCENT_WARNING", + "ACCENT_WARNING_LIGHT", + "ACCENT_INFO", + "ACCENT_PURPLE", + "ACCENT_TEAL", + ) + + def test_no_migrated_value_appears_as_a_raw_literal(self): + import pathlib + import re + + pkg = pathlib.Path(theme.__file__).parent + values = [getattr(theme, name) for name in self.MIGRATED_TOKENS] + # Longest first so e.g. "#94a3b8" isn't shadowed by a shorter value + # that happens to prefix-match earlier in an unordered scan. + values.sort(key=len, reverse=True) + alternation = "|".join(re.escape(v) for v in values) + pattern = re.compile(f"(?:{alternation})\\b", re.IGNORECASE) + entity_pattern = re.compile(r"&#[0-9a-fA-F]{3,8};") + + offenders = {} + for fname in self.MIGRATED_FILES: + text = (pkg / fname).read_text(encoding="utf-8") + text = entity_pattern.sub("", text) # HTML entities, not colours + found = sorted(set(pattern.findall(text))) + if found: + offenders[fname] = found + assert ( + offenders == {} + ), f"raw hex reintroduced where a theme.py token exists: {offenders}"
What you want to doQuantUI fileExternal tool