" + 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'Atom '
- f'σ (ppm) '
+ f'Atom '
+ f'σ (ppm) '
f"{shielding_rows}
"
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''
+ f' '
f'{k} {v} '
for k, v in items
)
return (
- f''
- ''
+ f''
f"QuantUI {quantui.__version__}"
- 'Python {py_ver}{env_badge}'
f'{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'{k} '
+ f'{k} '
f'{v} '
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''
@@ -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'Imaginary modes '
- f'{n_imag} — geometry may not be a minimum '
+ f'Imaginary modes '
+ f'{n_imag} — geometry may not be a minimum '
)
_rows = (
- f'SCF energy '
- f'{r.energy_hartree:.8f} Ha '
- f'SCF converged '
+ f'SCF energy '
+ f'{r.energy_hartree:.8f} Ha '
+ f'SCF converged '
f'{_conv} '
- f'Real modes '
- f'{n_real} '
+ f'Real modes '
+ f'{n_real} '
+ imag_note
+ (
- f'Frequencies (cm⁻¹) '
- f'{freq_str or "none"} '
+ f'Frequencies (cm⁻¹) '
+ f'{freq_str or "none"} '
if real_freqs
else ""
)
- + f'ZPVE '
- f'{r.zpve_hartree:.6f} Ha '
+ + f' ZPVE '
+ f'{r.zpve_hartree:.6f} Ha '
f"({r.zpve_hartree * 27.211386245988:.4f} eV) "
)
_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"— Thermochemistry at {_thermo.temperature_k:.0f} K / 1 atm —"
f" "
- f'H (298 K) '
- f'{_thermo.H_hartree:.6f} Ha '
- f'S (298 K) '
- f'{_thermo.S_jmol:.2f} J/(mol·K) '
- f'G (298 K) '
- f'{_thermo.G_hartree:.6f} Ha'
+ f' H (298 K) '
+ f'{_thermo.H_hartree:.6f} Ha '
+ f'S (298 K) '
+ f'{_thermo.S_jmol:.2f} J/(mol·K) '
+ f'G (298 K) '
+ f'{_thermo.G_hartree:.6f} Ha'
f" ({_thermo.G_hartree * _kj:.2f} kJ/mol) "
)
return (
- f''
f"Frequency Analysis — {r.formula} ({r.method}/{r.basis})"
f''
@@ -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'Ground-state energy '
- f'{r.energy_hartree:.8f} Ha '
- f'SCF converged '
+ f'Ground-state energy '
+ f'{r.energy_hartree:.8f} Ha '
+ f'SCF converged '
f'{_conv} '
- f'States computed '
- f'{len(r.excitation_energies_ev)} '
+ f'States computed '
+ f'{len(r.excitation_energies_ev)} '
)
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'S{i} '
- f'{e_ev:.3f} eV '
- f'{wl[i - 1]:.1f} nm '
- f'f = {f_osc:.4f} '
+ f'S{i} '
+ f'{e_ev:.3f} eV '
+ f'{wl[i - 1]:.1f} nm '
+ f'f = {f_osc:.4f} '
f" "
)
if len(r.excitation_energies_ev) > 8:
exc_rows.append(
- f'… '
+ f' … '
f"and {len(r.excitation_energies_ev) - 8} more states "
)
exc_table = (
- ''
+ f' '
"Vertical excitations: "
""
- 'State '
- 'Energy '
- 'λ '
- 'Osc. str. '
+ f'State '
+ f'Energy '
+ f'λ '
+ f'Osc. str. '
+ "".join(exc_rows)
)
return (
- f''
f"TD-DFT / UV-Vis — {r.formula} ({r.method}/{r.basis})"
f''
@@ -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'SCF converged '
+ f'SCF converged '
f'{_conv} '
- f'Reference '
- f'{r.reference_compound} ({r.method}/{r.basis}) '
+ f'Reference '
+ f'{r.reference_compound} ({r.method}/{r.basis}) '
)
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'{sym}-{n} '
- f'{d:.2f} ppm '
+ f'{sym}-{n} '
+ f'{d:.2f} ppm '
f" "
for n, (_i, d) in enumerate(shifts, 1)
)
return (
- f''
+ f' '
f"{label} shifts (vs. TMS): "
f""
- f'Atom '
- f'δ (ppm) '
+ f'Atom '
+ f'δ (ppm) '
+ 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 = (
''
- ''
+ f''
f"⚠ {r.basis} gives qualitative NMR only — use 6-31G* or better."
" "
)
@@ -320,7 +324,7 @@ def _nmr_table(label: str, shifts: list, sym: str) -> str:
if getattr(r, "is_fallback_reference", False):
_ref_warn = (
''
- ''
+ 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. "
)
return (
- f''
f"NMR Shielding — {r.formula} ({r.method}/{r.basis})"
f''
@@ -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'Min energy '
- f'{e_min:.8f} Ha '
- f'Energy range '
- f'{barrier_kcal:.2f} kcal/mol '
+ f'Min energy '
+ f'{e_min:.8f} Ha '
+ f'Energy range '
+ f'{barrier_kcal:.2f} kcal/mol '
)
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''
- f'Scan type '
- f'{r.scan_type.capitalize()} ({_idx_str}) '
- f'Range '
- f'{r.scan_parameter_values[0]:.3f} → '
+ f' Scan type '
+ f'{r.scan_type.capitalize()} ({_idx_str}) '
+ f'Range '
+ f'{r.scan_parameter_values[0]:.3f} → '
f"{r.scan_parameter_values[-1]:.3f} {r.scan_unit} "
f"({r.n_steps} points) "
f"{_e_row}"
- f'All converged '
+ f'All converged '
f'{_conv} '
f"
"
)
@@ -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'Neutral energy '
- f'{r.neutral_energy_hartree:.8f} Ha '
- f'Total opt steps '
- f'{r.n_total_opt_steps} '
- f'All converged '
+ f'Neutral energy '
+ f'{r.neutral_energy_hartree:.8f} Ha '
+ f'Total opt steps '
+ f'{r.n_total_opt_steps} '
+ f'All converged '
f'{_conv} '
f"
{_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" "
" Type "
" Computes "
" Cost "
@@ -99,7 +101,7 @@
"Both methods approximate the electronic wavefunction using "
"Hartree–Fock theory, but they treat electron spin differently.
"
""
- ""
+ f" "
" Method "
" Full name "
" When to use "
@@ -134,7 +136,7 @@
"A basis set is the mathematical toolkit used to describe "
"electron orbitals. Larger basis sets are more accurate but slower.
"
""
- ""
+ f" "
" Basis set "
" Speed "
" Accuracy "
@@ -228,7 +230,7 @@
"tab. It is not a blanket win, so QuantUI does not turn it on for "
"you — here is why."
""
- ""
+ f" "
" Calculation "
" Effect of DF "
"TD-DFT (UV-Vis) and larger "
@@ -267,7 +269,7 @@
""
"Typical values:
"
""
- ""
+ f" "
" Molecule "
" Gap (eV) "
" Character "
@@ -291,7 +293,7 @@
"body": (
"After a calculation completes, QuantUI reports several key quantities:
"
""
- ""
+ f" "
" Quantity "
" What it means "
"Total energy "
@@ -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:
"
""
- ""
+ f" "
" Type "
" Resuming picks up… "
"Geometry Opt "
@@ -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.
"
""
- ""
+ f" "
" Unpaired e⁻ "
" Multiplicity "
" Name "
@@ -462,7 +464,7 @@
"compliant files. No screen-scraping — open the right file in "
"the right tool."
""
- ""
+ f" "
" What you want to do "
" QuantUI file "
" External tool "
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}"