From ad9a894a90dda8ba077d8a145c53777c07199fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 12:55:10 +0300 Subject: [PATCH 01/17] Harden stable app surface by gating preview modules --- .env.example | 1 + README.md | 3 +++ app.py | 23 ++++++++++++++++------- tests/test_runtime_flags.py | 18 ++++++++++++++++++ utils/i18n.py | 4 ++++ utils/runtime_flags.py | 21 +++++++++++++++++++++ 6 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 tests/test_runtime_flags.py create mode 100644 utils/runtime_flags.py diff --git a/.env.example b/.env.example index 485dc535..0c3f0244 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ THERMOANALYZER_LIBRARY_CLOUD_URL=http://127.0.0.1:8000 THERMOANALYZER_LIBRARY_CLOUD_ENABLED=true THERMOANALYZER_LIBRARY_DEV_CLOUD_AUTH=true THERMOANALYZER_LIBRARY_ALLOW_FULL_PROVIDER_SYNC=false +MATERIALSCOPE_ENABLE_PREVIEW_MODULES=false MATERIALSCOPE_OPENALEX_EMAIL= MATERIALSCOPE_OPENALEX_API_KEY= diff --git a/README.md b/README.md index 616743e4..6efea21d 100644 --- a/README.md +++ b/README.md @@ -166,10 +166,13 @@ Recommended runtime environment variables: THERMOANALYZER_LIBRARY_CLOUD_URL=http://127.0.0.1:8000 THERMOANALYZER_LIBRARY_CLOUD_ENABLED=true THERMOANALYZER_LIBRARY_ALLOW_FULL_PROVIDER_SYNC=false +MATERIALSCOPE_ENABLE_PREVIEW_MODULES=false MATERIALSCOPE_OPENALEX_EMAIL= MATERIALSCOPE_OPENALEX_API_KEY= ``` +Set `MATERIALSCOPE_ENABLE_PREVIEW_MODULES=true` only in builds where kinetics and deconvolution should be exposed. + --- ## Local Cloud-Library Development diff --git a/app.py b/app.py index 97cb1bb5..832eb7a5 100644 --- a/app.py +++ b/app.py @@ -16,6 +16,7 @@ from utils.diagnostics import configure_diagnostics_logger, record_exception from utils.i18n import SUPPORTED_LANGUAGES, t, tx from utils.license_manager import APP_VERSION, commercial_mode_enabled, license_allows_write, load_license_state +from utils.runtime_flags import preview_modules_enabled from utils.session_state import clear_project_state, ensure_session_state, replace_project_state load_dotenv(dotenv_path=Path(__file__).resolve().with_name(".env"), override=False) @@ -396,11 +397,17 @@ def _render_project_sidebar(): from ui.export_page import render as export_render # --- Navigation --- -show_preview_tools = st.sidebar.toggle( - t("app.preview_toggle"), - value=False, - help=t("app.preview_toggle_help"), -) +preview_modules_available = preview_modules_enabled(default=False) +if preview_modules_available: + show_preview_tools = st.sidebar.toggle( + t("app.preview_toggle"), + value=False, + help=t("app.preview_toggle_help"), + ) +else: + show_preview_tools = False + st.sidebar.caption(t("app.preview_disabled")) + pages = { tx("Ana Akış", "Primary"): [ st.Page(home_render, title=t("nav.import"), icon="📂", default=True, url_path="import"), @@ -444,7 +451,8 @@ def _render_project_sidebar(): "- CSV/TXT/XLSX DSC, TGA, DTA, FTIR, RAMAN ve XRD koşularını içe aktar\n" "- DSC, TGA, DTA, FTIR, RAMAN ve XRD analiz akışlarını çalıştır\n" "- Çoklu koşuları Karşılaştırma Alanı ve Toplu Şablon Uygulayıcı ile yönet\n" - "- Kararlı sonuçları proje durumu, rapor ve export akışıyla sakla\n\n" + "- Kararlı sonuçları proje durumu, rapor ve export akışıyla sakla\n" + "- Laboratuvar önizleme modülleri yalnızca özel olarak etkinleştirilen buildlerde görünür\n\n" "**Laboratuvar önizleme modülleri**\n" "- Kinetik ve dekonvolüsyon modülleri önizleme anahtarı arkasında kalır ve ticari stabilite sözüne dahil değildir.\n\n" "**Referans standartlar**\n" @@ -465,7 +473,8 @@ def _render_project_sidebar(): "- Import DSC, TGA, DTA, FTIR, RAMAN, and XRD runs from CSV/TXT/XLSX exports\n" "- Execute stable DSC, TGA, DTA, FTIR, RAMAN, and XRD analysis workflows\n" "- Manage multiple runs through Compare Workspace and the Batch Template Runner\n" - "- Save stable results through the current project, report, and export flows\n\n" + "- Save stable results through the current project, report, and export flows\n" + "- Lab preview modules appear only in explicitly enabled builds\n\n" "**Lab Preview modules**\n" "- Kinetics and deconvolution stay available behind the preview toggle and are excluded from the commercial stability promise.\n\n" "**Reference standards**\n" diff --git a/tests/test_runtime_flags.py b/tests/test_runtime_flags.py new file mode 100644 index 00000000..710deb92 --- /dev/null +++ b/tests/test_runtime_flags.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from utils.runtime_flags import preview_modules_enabled + + +def test_preview_modules_default_to_disabled(monkeypatch): + monkeypatch.delenv("MATERIALSCOPE_ENABLE_PREVIEW_MODULES", raising=False) + assert preview_modules_enabled() is False + + +def test_preview_modules_can_be_explicitly_enabled(monkeypatch): + monkeypatch.setenv("MATERIALSCOPE_ENABLE_PREVIEW_MODULES", "true") + assert preview_modules_enabled() is True + + +def test_preview_modules_can_be_explicitly_disabled(monkeypatch): + monkeypatch.setenv("MATERIALSCOPE_ENABLE_PREVIEW_MODULES", "false") + assert preview_modules_enabled(default=True) is False diff --git a/utils/i18n.py b/utils/i18n.py index 3ec60085..9ce082f0 100644 --- a/utils/i18n.py +++ b/utils/i18n.py @@ -27,6 +27,10 @@ "tr": "Kinetik ve dekonvolüsyon sayfalarını pilot değerlendirme için açar.", "en": "Expose kinetics and deconvolution pages used for pilot evaluations.", }, + "app.preview_disabled": { + "tr": "Laboratuvar önizleme modülleri bu dağıtım profilinde kapalı.", + "en": "Lab preview modules are disabled in this deployment profile.", + }, "app.language": { "tr": "Dil", "en": "Language", diff --git a/utils/runtime_flags.py b/utils/runtime_flags.py new file mode 100644 index 00000000..fa84e966 --- /dev/null +++ b/utils/runtime_flags.py @@ -0,0 +1,21 @@ +"""Runtime feature flags for shaping the app surface by deployment profile.""" + +from __future__ import annotations + +import os + + +PREVIEW_MODULES_ENV = "MATERIALSCOPE_ENABLE_PREVIEW_MODULES" + + +def _truthy(value: str | None) -> bool: + token = str(value or "").strip().lower() + return token in {"1", "true", "yes", "on"} + + +def preview_modules_enabled(*, default: bool = False) -> bool: + """Return whether experimental preview modules should be exposed in navigation.""" + raw = os.getenv(PREVIEW_MODULES_ENV) + if raw is None: + return bool(default) + return _truthy(raw) From 3a3090234d3a4f7de4db9f8d4a5fb31e3547b07b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 12:59:50 +0300 Subject: [PATCH 02/17] Add stable workflow smoke regression test --- tests/test_backend_workflow.py | 129 +++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/tests/test_backend_workflow.py b/tests/test_backend_workflow.py index b32897fc..ffedb904 100644 --- a/tests/test_backend_workflow.py +++ b/tests/test_backend_workflow.py @@ -2,6 +2,7 @@ import base64 import io +import zipfile from fastapi.testclient import TestClient @@ -310,3 +311,131 @@ def test_workspace_compare_xrd_lane_filters_incompatible_dataset_selection(therm payload = xrd_workspace.json()["compare_workspace"] assert payload["analysis_type"] == "XRD" assert payload["selected_datasets"] == [xrd_key] + + +def test_stable_compare_batch_report_and_project_roundtrip_smoke(thermal_dataset): + app = create_app(api_token="workflow-token") + client = TestClient(app) + + create_response = client.post("/workspace/new", headers=_headers()) + assert create_response.status_code == 200 + project_id = create_response.json()["project_id"] + + first_csv = thermal_dataset.data.to_csv(index=False).encode("utf-8") + first_import = client.post( + "/dataset/import", + headers=_headers(), + json={ + "project_id": project_id, + "file_name": "stable_smoke_a.csv", + "file_base64": _to_base64(first_csv), + "data_type": "DSC", + }, + ) + assert first_import.status_code == 200 + first_key = first_import.json()["dataset"]["key"] + + second_dataset = thermal_dataset.copy() + second_dataset.data["signal"] = second_dataset.data["signal"] * 1.015 + second_csv = second_dataset.data.to_csv(index=False).encode("utf-8") + second_import = client.post( + "/dataset/import", + headers=_headers(), + json={ + "project_id": project_id, + "file_name": "stable_smoke_b.csv", + "file_base64": _to_base64(second_csv), + "data_type": "DSC", + }, + ) + assert second_import.status_code == 200 + second_key = second_import.json()["dataset"]["key"] + + replace_selection = client.post( + f"/workspace/{project_id}/compare/selection", + headers=_headers(), + json={"operation": "replace", "dataset_keys": [first_key, second_key]}, + ) + assert replace_selection.status_code == 200 + assert replace_selection.json()["compare_workspace"]["selected_datasets"] == [first_key, second_key] + + compare_config = client.put( + f"/workspace/{project_id}/compare", + headers=_headers(), + json={ + "analysis_type": "DSC", + "selected_datasets": [first_key, second_key], + "notes": "Stable smoke workflow", + }, + ) + assert compare_config.status_code == 200 + compare_payload = compare_config.json()["compare_workspace"] + assert compare_payload["analysis_type"] == "DSC" + assert compare_payload["notes"] == "Stable smoke workflow" + + batch_response = client.post( + f"/workspace/{project_id}/batch/run", + headers=_headers(), + json={"analysis_type": "DSC", "workflow_template_id": "dsc.general"}, + ) + assert batch_response.status_code == 200 + batch_payload = batch_response.json() + assert batch_payload["outcomes"] == {"total": 2, "saved": 2, "blocked": 0, "failed": 0} + result_ids = list(batch_payload["saved_result_ids"]) + assert len(result_ids) == 2 + assert batch_payload["batch_run_id"] + + export_prep_response = client.get(f"/workspace/{project_id}/exports/preparation", headers=_headers()) + assert export_prep_response.status_code == 200 + export_prep = export_prep_response.json() + assert export_prep["compare_workspace"]["analysis_type"] == "DSC" + assert export_prep["compare_workspace"]["selected_datasets"] == [first_key, second_key] + assert export_prep["compare_workspace"]["notes"] == "Stable smoke workflow" + assert {item["id"] for item in export_prep["exportable_results"]} == set(result_ids) + + report_response = client.post( + f"/workspace/{project_id}/exports/report-docx", + headers=_headers(), + json={"selected_result_ids": result_ids}, + ) + assert report_response.status_code == 200 + report_payload = report_response.json() + assert set(report_payload["included_result_ids"]) == set(result_ids) + report_docx = base64.b64decode(report_payload["artifact_base64"].encode("ascii")) + with zipfile.ZipFile(io.BytesIO(report_docx), "r") as archive: + xml = archive.read("word/document.xml").decode("utf-8") + assert "Stable Analyses" in xml + assert "Experimental Analyses" not in xml + assert "stable_smoke_a" in xml + assert "stable_smoke_b" in xml + + save_response = client.post( + "/project/save", + headers=_headers(), + json={"project_id": project_id}, + ) + assert save_response.status_code == 200 + archive_base64 = save_response.json()["archive_base64"] + + load_response = client.post( + "/project/load", + headers=_headers(), + json={"archive_base64": archive_base64}, + ) + assert load_response.status_code == 200 + loaded_project_id = load_response.json()["project_id"] + + loaded_compare_response = client.get(f"/workspace/{loaded_project_id}/compare", headers=_headers()) + assert loaded_compare_response.status_code == 200 + loaded_compare = loaded_compare_response.json()["compare_workspace"] + assert loaded_compare["analysis_type"] == "DSC" + assert loaded_compare["selected_datasets"] == [first_key, second_key] + assert loaded_compare["notes"] == "Stable smoke workflow" + assert loaded_compare["batch_run_id"] == batch_payload["batch_run_id"] + assert set(loaded_compare["batch_result_ids"]) == set(result_ids) + assert len(loaded_compare["batch_summary"]) == 2 + + loaded_results_response = client.get(f"/workspace/{loaded_project_id}/results", headers=_headers()) + assert loaded_results_response.status_code == 200 + loaded_results = loaded_results_response.json()["results"] + assert {item["id"] for item in loaded_results} == set(result_ids) From d88f76e108aaa5f416ecc66deeaae0438776c268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 13:08:51 +0300 Subject: [PATCH 03/17] Wait for backend readiness before starting UI --- README.md | 7 +++++++ docker/start.sh | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/README.md b/README.md index 6efea21d..500bfe28 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ This repo includes a production `Dockerfile` for Coolify-style deployments. The container starts: - the FastAPI backend on `127.0.0.1:8000` - the Streamlit UI on `0.0.0.0:8501` +- Streamlit waits for backend health before the UI process starts For web deployment: - deploy with `Dockerfile` @@ -173,6 +174,12 @@ MATERIALSCOPE_OPENALEX_API_KEY= Set `MATERIALSCOPE_ENABLE_PREVIEW_MODULES=true` only in builds where kinetics and deconvolution should be exposed. +Optional runtime tuning: + +```dotenv +BACKEND_STARTUP_TIMEOUT_SECONDS=30 +``` + --- ## Local Cloud-Library Development diff --git a/docker/start.sh b/docker/start.sh index 127c8017..202b57ef 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -1,6 +1,20 @@ #!/bin/sh set -eu +wait_for_backend() { + timeout_seconds="${BACKEND_STARTUP_TIMEOUT_SECONDS:-30}" + elapsed=0 + while [ "${elapsed}" -lt "${timeout_seconds}" ]; do + if curl --silent --fail http://127.0.0.1:8000/health >/dev/null 2>&1; then + return 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + echo "MaterialScope backend did not become healthy within ${timeout_seconds}s." >&2 + return 1 +} + cleanup() { for pid in ${BACKEND_PID:-} ${STREAMLIT_PID:-}; do if [ -n "${pid:-}" ] && kill -0 "${pid}" 2>/dev/null; then @@ -18,6 +32,8 @@ export THERMOANALYZER_LIBRARY_CLOUD_URL python -m backend.main --host 127.0.0.1 --port 8000 & BACKEND_PID=$! +wait_for_backend + streamlit run app.py --server.address=0.0.0.0 --server.port=8501 & STREAMLIT_PID=$! From 2354523a07f2fa86fcca41a3d12bedf27ad9cee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 13:49:01 +0300 Subject: [PATCH 04/17] Allow import metadata overrides for backend workflows --- backend/app.py | 2 +- backend/models.py | 1 + tests/test_backend_batch.py | 10 ++++++++++ tests/test_backend_workflow.py | 19 +++++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/backend/app.py b/backend/app.py index 2c4b8aae..89aec4be 100644 --- a/backend/app.py +++ b/backend/app.py @@ -761,7 +761,7 @@ def dataset_import( source.name = request.file_name try: - dataset = read_thermal_data(source, data_type=request.data_type) + dataset = read_thermal_data(source, data_type=request.data_type, metadata=request.metadata) except Exception as exc: raise HTTPException(status_code=400, detail=f"Dataset import failed: {exc}") from exc diff --git a/backend/models.py b/backend/models.py index f4661e7f..e057d483 100644 --- a/backend/models.py +++ b/backend/models.py @@ -264,6 +264,7 @@ class DatasetImportRequest(BaseModel): file_name: str = Field(..., min_length=1) file_base64: str = Field(..., min_length=1) data_type: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) class ValidationSummary(BaseModel): diff --git a/tests/test_backend_batch.py b/tests/test_backend_batch.py index 27a65379..e11a29f6 100644 --- a/tests/test_backend_batch.py +++ b/tests/test_backend_batch.py @@ -17,6 +17,15 @@ def _as_b64(raw: bytes) -> str: return base64.b64encode(raw).decode("ascii") +def _import_metadata(thermal_dataset) -> dict[str, object]: + return { + "sample_name": thermal_dataset.metadata.get("sample_name"), + "sample_mass": thermal_dataset.metadata.get("sample_mass"), + "heating_rate": thermal_dataset.metadata.get("heating_rate"), + "instrument": thermal_dataset.metadata.get("instrument"), + } + + def _import_dataset(client: TestClient, project_id: str, thermal_dataset, file_name: str, data_type: str) -> str: csv_bytes = thermal_dataset.data.to_csv(index=False).encode("utf-8") imported = client.post( @@ -27,6 +36,7 @@ def _import_dataset(client: TestClient, project_id: str, thermal_dataset, file_n "file_name": file_name, "file_base64": _as_b64(csv_bytes), "data_type": data_type, + "metadata": _import_metadata(thermal_dataset), }, ) assert imported.status_code == 200 diff --git a/tests/test_backend_workflow.py b/tests/test_backend_workflow.py index ffedb904..4c4e9728 100644 --- a/tests/test_backend_workflow.py +++ b/tests/test_backend_workflow.py @@ -18,6 +18,15 @@ def _to_base64(raw: bytes) -> str: return base64.b64encode(raw).decode("ascii") +def _import_metadata(thermal_dataset) -> dict[str, object]: + return { + "sample_name": thermal_dataset.metadata.get("sample_name"), + "sample_mass": thermal_dataset.metadata.get("sample_mass"), + "heating_rate": thermal_dataset.metadata.get("heating_rate"), + "instrument": thermal_dataset.metadata.get("instrument"), + } + + def test_workspace_import_run_analysis_and_save_roundtrip(thermal_dataset): app = create_app(api_token="workflow-token") client = TestClient(app) @@ -35,6 +44,7 @@ def test_workspace_import_run_analysis_and_save_roundtrip(thermal_dataset): "file_name": "synthetic_dsc.csv", "file_base64": _to_base64(csv_bytes), "data_type": "DSC", + "metadata": _import_metadata(thermal_dataset), }, ) assert import_response.status_code == 200 @@ -126,6 +136,7 @@ def test_workspace_import_run_dta_analysis_roundtrip(thermal_dataset): "file_name": "synthetic_dta.csv", "file_base64": _to_base64(csv_bytes), "data_type": "DTA", + "metadata": _import_metadata(thermal_dataset), }, ) assert import_response.status_code == 200 @@ -171,6 +182,7 @@ def test_workspace_compare_batch_run_with_dta_stable_template(thermal_dataset): "file_name": "batch_dta_a.csv", "file_base64": _to_base64(csv_bytes), "data_type": "DTA", + "metadata": _import_metadata(thermal_dataset), }, ) imported_b = client.post( @@ -181,6 +193,7 @@ def test_workspace_compare_batch_run_with_dta_stable_template(thermal_dataset): "file_name": "batch_dta_b.csv", "file_base64": _to_base64(csv_bytes), "data_type": "DTA", + "metadata": _import_metadata(thermal_dataset), }, ) assert imported_a.status_code == 200 @@ -226,6 +239,7 @@ def test_workspace_compare_spectral_lane_filters_incompatible_dataset_selection( "file_name": "spectral_ftir.csv", "file_base64": _to_base64(csv_bytes), "data_type": "FTIR", + "metadata": _import_metadata(thermal_dataset), }, ) imported_raman = client.post( @@ -236,6 +250,7 @@ def test_workspace_compare_spectral_lane_filters_incompatible_dataset_selection( "file_name": "spectral_raman.csv", "file_base64": _to_base64(csv_bytes), "data_type": "RAMAN", + "metadata": _import_metadata(thermal_dataset), }, ) assert imported_ftir.status_code == 200 @@ -282,6 +297,7 @@ def test_workspace_compare_xrd_lane_filters_incompatible_dataset_selection(therm "file_name": "xrd_compare.csv", "file_base64": _to_base64(csv_bytes), "data_type": "XRD", + "metadata": _import_metadata(thermal_dataset), }, ) imported_ftir = client.post( @@ -292,6 +308,7 @@ def test_workspace_compare_xrd_lane_filters_incompatible_dataset_selection(therm "file_name": "ftir_compare.csv", "file_base64": _to_base64(csv_bytes), "data_type": "FTIR", + "metadata": _import_metadata(thermal_dataset), }, ) assert imported_xrd.status_code == 200 @@ -330,6 +347,7 @@ def test_stable_compare_batch_report_and_project_roundtrip_smoke(thermal_dataset "file_name": "stable_smoke_a.csv", "file_base64": _to_base64(first_csv), "data_type": "DSC", + "metadata": _import_metadata(thermal_dataset), }, ) assert first_import.status_code == 200 @@ -346,6 +364,7 @@ def test_stable_compare_batch_report_and_project_roundtrip_smoke(thermal_dataset "file_name": "stable_smoke_b.csv", "file_base64": _to_base64(second_csv), "data_type": "DSC", + "metadata": _import_metadata(second_dataset), }, ) assert second_import.status_code == 200 From 4f44407e291af7dc4b7a72eda15f7b5105376373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 13:59:21 +0300 Subject: [PATCH 05/17] Lock deployment runtime contract with file tests --- tests/test_deployment_contract.py | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/test_deployment_contract.py diff --git a/tests/test_deployment_contract.py b/tests/test_deployment_contract.py new file mode 100644 index 00000000..56b34aa5 --- /dev/null +++ b/tests/test_deployment_contract.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _repo_text(path: str) -> str: + return (REPO_ROOT / path).read_text(encoding="utf-8") + + +def test_dockerfile_keeps_streamlit_runtime_contract(): + dockerfile = _repo_text("Dockerfile") + + assert "FROM python:3.12-slim" in dockerfile + assert "chromium" in dockerfile + assert "curl" in dockerfile + assert "BROWSER_PATH=/usr/bin/chromium" in dockerfile + assert "CHROME_BIN=/usr/bin/chromium" in dockerfile + assert "EXPOSE 8501" in dockerfile + assert "HEALTHCHECK" in dockerfile + assert "http://127.0.0.1:8501/_stcore/health" in dockerfile + assert 'CMD ["/app/docker/start.sh"]' in dockerfile + + +def test_container_entrypoint_waits_for_backend_before_ui(): + start_script = _repo_text("docker/start.sh") + + assert 'timeout_seconds="${BACKEND_STARTUP_TIMEOUT_SECONDS:-30}"' in start_script + assert 'curl --silent --fail http://127.0.0.1:8000/health' in start_script + assert ': "${THERMOANALYZER_LIBRARY_CLOUD_URL:=http://127.0.0.1:8000}"' in start_script + assert "python -m backend.main --host 127.0.0.1 --port 8000 &" in start_script + assert "wait_for_backend" in start_script + assert "streamlit run app.py --server.address=0.0.0.0 --server.port=8501 &" in start_script + + +def test_env_example_documents_runtime_surface_flags(): + env_example = _repo_text(".env.example") + + assert "THERMOANALYZER_LIBRARY_CLOUD_URL=http://127.0.0.1:8000" in env_example + assert "THERMOANALYZER_LIBRARY_CLOUD_ENABLED=true" in env_example + assert "THERMOANALYZER_LIBRARY_DEV_CLOUD_AUTH=true" in env_example + assert "MATERIALSCOPE_ENABLE_PREVIEW_MODULES=false" in env_example + + +def test_readme_documents_preview_and_backend_startup_runtime_flags(): + readme = _repo_text("README.md") + + assert "Streamlit waits for backend health before the UI process starts" in readme + assert "MATERIALSCOPE_ENABLE_PREVIEW_MODULES=false" in readme + assert "Set `MATERIALSCOPE_ENABLE_PREVIEW_MODULES=true` only in builds" in readme + assert "BACKEND_STARTUP_TIMEOUT_SECONDS=30" in readme From 9d97dc5766de31d3974d8ecc96ca5458b4af9438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 14:40:24 +0300 Subject: [PATCH 06/17] Clean up stable UI copy and header consistency --- tests/test_ui_consistency.py | 35 +++++++++++++++++++++++++++++++++++ ui/dta_page.py | 7 ++++--- ui/home.py | 8 +++----- ui/project_page.py | 6 +----- utils/i18n.py | 16 ++++++++++++++++ 5 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 tests/test_ui_consistency.py diff --git a/tests/test_ui_consistency.py b/tests/test_ui_consistency.py new file mode 100644 index 00000000..570ef6ca --- /dev/null +++ b/tests/test_ui_consistency.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _repo_text(path: str) -> str: + return (REPO_ROOT / path).read_text(encoding="utf-8") + + +def test_home_page_avoids_duplicate_title_after_shared_header(): + home_page = _repo_text("ui/home.py") + + assert 'render_page_header(t("home.title"), t("home.caption"), badge=t("home.hero_badge"))' in home_page + assert 'st.header(t("home.title"))' not in home_page + assert "Kararlı ürün yüzeyi şu zincire odaklanır" in home_page + + +def test_dta_page_uses_shared_header_chrome(): + dta_page = _repo_text("ui/dta_page.py") + + assert 'render_page_header(t("dta.title"), t("dta.caption"), badge=t("dta.hero_badge"))' in dta_page + assert 'st.title(tx("DTA Analizi", "DTA Analysis"))' not in dta_page + + +def test_project_page_sidebar_hint_matches_sidebar_actions(): + project_page = _repo_text("ui/project_page.py") + i18n = _repo_text("utils/i18n.py") + + assert 'st.info(t("project.sidebar_hint"))' in project_page + assert '"project.sidebar_hint"' in i18n + assert "Proje Dosyasını Hazırla" in i18n + assert "Load Selected Project" in i18n diff --git a/ui/dta_page.py b/ui/dta_page.py index 83adf30a..3dbbf860 100644 --- a/ui/dta_page.py +++ b/ui/dta_page.py @@ -24,13 +24,14 @@ ) from core.result_serialization import serialize_dta_result from core.validation import validate_thermal_dataset +from ui.components.chrome import render_page_header from ui.components.literature_compare_panel import render_literature_compare_panel from ui.components.plot_builder import create_dta_plot, create_thermal_plot, fig_to_bytes, PLOTLY_CONFIG from ui.components.history_tracker import _log_event from ui.components.preset_manager import render_processing_preset_panel, seed_pending_workflow_template from ui.components.quality_dashboard import render_quality_dashboard from utils.reference_data import render_reference_comparison -from utils.i18n import tx +from utils.i18n import t, tx from utils.session_state import ( advance_analysis_render_revision, init_analysis_state_history, @@ -135,8 +136,8 @@ def _store_dta_result(selected_key, dataset, temperature, signal, state): # --------------------------------------------------------------------------- def render(): - st.title(tx("DTA Analizi", "DTA Analysis")) - st.info( + render_page_header(t("dta.title"), t("dta.caption"), badge=t("dta.hero_badge")) + st.caption( tx( "Kararlı DTA iş akışı: DTA sonuçları proje kalıcılığı, raporlama ve export akışıyla aynı kararlı kapsamda yönetilir.", "Stable DTA workflow: DTA results are handled in the same stable scope for project persistence, reporting, and export flows.", diff --git a/ui/home.py b/ui/home.py index b3b74e06..f1263a61 100644 --- a/ui/home.py +++ b/ui/home.py @@ -94,10 +94,10 @@ def render(): render_page_header(t("home.title"), t("home.caption"), badge=t("home.hero_badge")) render_home_workflow_guide() - st.info( + st.caption( tx( - "Bu beta build'de kararlı akış Veri Alma -> Karşılaştırma Alanı -> DSC/TGA/DTA/FTIR/RAMAN/XRD Analizi -> Toplu Şablon Uygulayıcı -> Rapor/Proje Kaydı zinciridir. Kinetik ve dekonvolüsyon modülleri önizleme kapsamındadır.", - "In this beta build, the stable workflow is Import -> Compare Workspace -> DSC/TGA/DTA/FTIR/RAMAN/XRD Analysis -> Batch Template Runner -> Report/Project Save. Kinetics and deconvolution remain preview modules.", + "Kararlı ürün yüzeyi şu zincire odaklanır: Veri Al -> Karşılaştırma Alanı -> DSC/TGA/DTA/FTIR/RAMAN/XRD Analizi -> Rapor Merkezi / Proje Alanı.", + "The stable product surface focuses on this chain: Import Runs -> Compare Workspace -> DSC/TGA/DTA/FTIR/RAMAN/XRD Analysis -> Report Center / Project Workspace.", ) ) @@ -115,8 +115,6 @@ def render(): m2.metric("D / T / DTA / F / R / X", f"{dsc_count} / {tga_count} / {dta_count} / {ftir_count} / {raman_count} / {xrd_count}") m3.metric(tx("Vendor Sayısı", "Vendors"), str(len(vendors))) - st.header(t("home.title")) - upload_tab, sample_tab = st.tabs( [ tx("Dosya Yükle", "Upload File"), diff --git a/ui/project_page.py b/ui/project_page.py index e67f6ac7..39c0c3cb 100644 --- a/ui/project_page.py +++ b/ui/project_page.py @@ -71,9 +71,5 @@ def render(): for issue in issues: st.caption(f"- {issue}") - st.info( - "Sidebar’daki `Proje` panelinden yeni proje açabilir, dosyaya kaydedebilir ve proje yükleyebilirsin." - if lang == "tr" - else "Use the sidebar `Project` panel for `New Project`, `Save Project to File`, and `Load Project`." - ) + st.info(t("project.sidebar_hint")) diff --git a/utils/i18n.py b/utils/i18n.py index 9ce082f0..96db715b 100644 --- a/utils/i18n.py +++ b/utils/i18n.py @@ -183,6 +183,10 @@ "tr": "Oturum Yönetimi", "en": "Session Management", }, + "project.sidebar_hint": { + "tr": "Sidebar içindeki `Proje` panelinden `Yeni Proje`, `Proje Dosyasını Hazırla`, `Projeyi Yükle` ve `Seçili Projeyi Aç` eylemlerini kullan.", + "en": "Use the sidebar `Project` panel for `New Project`, `Prepare Project File`, `Load Project`, and `Load Selected Project` actions.", + }, "license.title": { "tr": "Lisans ve Marka", "en": "License & Branding", @@ -211,6 +215,18 @@ "tr": "Kütle kaybı adımlarını, DTG eğrisini ve kalıntı yüzdesini tek iş akışında çıkar.", "en": "Extract mass-loss steps, DTG curves, and residue metrics in one workflow.", }, + "dta.title": { + "tr": "DTA Analizi", + "en": "DTA Analysis", + }, + "dta.caption": { + "tr": "DTA sinyalini yumuşatma, baseline düzeltme ve pik yorumu ile aynı kararlı rapor akışında işle.", + "en": "Process DTA signals with smoothing, baseline correction, and peak interpretation inside the same stable reporting flow.", + }, + "dta.hero_badge": { + "tr": "Termal Fark İş Akışı", + "en": "Thermal Difference Workflow", + }, "xrd.title": { "tr": "XRD Analizi", "en": "XRD Analysis", From e43a48da94a9ba7c395b69a6aeff26c9642f816f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 15:16:07 +0300 Subject: [PATCH 07/17] Unify analysis heroes and workflow guides --- tests/test_ui_consistency.py | 28 +++ ui/components/workflow_guide.py | 394 ++++++++++++++++++++++++++++---- ui/dsc_page.py | 4 +- ui/dta_page.py | 8 +- ui/spectral_page.py | 9 +- ui/tga_page.py | 2 +- ui/xrd_page.py | 8 +- utils/i18n.py | 48 ++-- 8 files changed, 413 insertions(+), 88 deletions(-) diff --git a/tests/test_ui_consistency.py b/tests/test_ui_consistency.py index 570ef6ca..ed675818 100644 --- a/tests/test_ui_consistency.py +++ b/tests/test_ui_consistency.py @@ -25,6 +25,34 @@ def test_dta_page_uses_shared_header_chrome(): assert 'st.title(tx("DTA Analizi", "DTA Analysis"))' not in dta_page +def test_stable_analysis_pages_render_workflow_guides(): + dsc_page = _repo_text("ui/dsc_page.py") + dta_page = _repo_text("ui/dta_page.py") + tga_page = _repo_text("ui/tga_page.py") + spectral_page = _repo_text("ui/spectral_page.py") + xrd_page = _repo_text("ui/xrd_page.py") + guides = _repo_text("ui/components/workflow_guide.py") + + assert 'render_analysis_workflow_guide("DSC")' in dsc_page + assert 'render_analysis_workflow_guide("DTA")' in dta_page + assert 'render_tga_workflow_guide()' in tga_page + assert "render_analysis_workflow_guide(token)" in spectral_page + assert 'render_analysis_workflow_guide("XRD")' in xrd_page + for token in ['"DSC"', '"DTA"', '"TGA"', '"FTIR"', '"RAMAN"', '"XRD"']: + assert token in guides + + +def test_analysis_hero_copy_uses_full_modality_expansions(): + i18n = _repo_text("utils/i18n.py") + + assert "Differential Scanning Calorimetry Workflow" in i18n + assert "Thermogravimetric Analysis Workflow" in i18n + assert "Differential Thermal Analysis Workflow" in i18n + assert "Fourier Transform Infrared Workflow" in i18n + assert "Raman Spectroscopy Workflow" in i18n + assert "X-Ray Diffraction Workflow" in i18n + + def test_project_page_sidebar_hint_matches_sidebar_actions(): project_page = _repo_text("ui/project_page.py") i18n = _repo_text("utils/i18n.py") diff --git a/ui/components/workflow_guide.py b/ui/components/workflow_guide.py index 4fd458a1..d72168ff 100644 --- a/ui/components/workflow_guide.py +++ b/ui/components/workflow_guide.py @@ -13,6 +13,336 @@ def _render_list(title: str, items: list[str]) -> None: st.markdown(f"- {item}") +_ANALYSIS_GUIDES = { + "DSC": { + "title": ("DSC Sayfası Rehberi", "DSC Page Guide"), + "what": [ + ( + "Ham DSC eğrisini ısı akışı ve sıcaklık ekseninde gösterir.", + "Shows the raw DSC curve on heat-flow versus temperature axes.", + ), + ( + "Yumuşatma, baz çizgisi düzeltmesi, Tg tespiti ve pik karakterizasyonunu aynı oturum state'i üzerinde yürütür.", + "Runs smoothing, baseline correction, Tg detection, and peak characterization on the same in-session state.", + ), + ( + "Kaydedilen sonuçları rapor, proje arşivi ve literatür karşılaştırması akışına hazırlar.", + "Prepares saved results for report, project archive, and literature comparison flows.", + ), + ], + "order": [ + ( + "1. Ham Veri sekmesinde sıcaklık aralığını, sinyal yönünü ve metadata bilgisini doğrulayın.", + "1. Use Raw Data to confirm the temperature range, signal direction, and metadata.", + ), + ( + "2. Gürültü Tg veya pik yorumunu bozuyorsa Yumuşatma sekmesinde kontrollü smoothing uygulayın.", + "2. Apply controlled smoothing only when noise hides Tg or peak behavior.", + ), + ( + "3. Baz çizgisi kayıyorsa Baseline Correction ile çalışma sinyalini stabilize edin.", + "3. Stabilize the working signal with Baseline Correction when the baseline drifts.", + ), + ( + "4. Amorf geçiş arıyorsanız Tg sekmesini, termal olay arıyorsanız Pik Analizi sekmesini izleyin.", + "4. Use Glass Transition for amorphous transitions and Peak Analysis for thermal events.", + ), + ( + "5. Sonuç Özeti sekmesinde doğrulayıp sonucu oturuma kaydedin.", + "5. Validate the output in Results Summary, then save it to the session.", + ), + ], + "notes": [ + ( + "Aşırı yumuşatma Tg orta noktasını ve pik alanını kaydırabilir; minimum gerekli smoothing ile ilerleyin.", + "Over-smoothing can shift Tg midpoints and peak areas; use the minimum smoothing needed.", + ), + ( + "Baz çizgisi seçimi entalpi ve onset/endset değerlerini doğrudan etkiler.", + "Baseline selection directly affects enthalpy and onset/endset values.", + ), + ( + "Numune kütlesi metadata'da varsa normalize ve rapor metrikleri daha tutarlı olur.", + "Metadata sample mass improves normalization and report consistency when it is available.", + ), + ], + "footer": ( + "Bu iş akışı ham veriyi değiştirmez; yalnızca oturum içi analiz katmanları ve kaydedilebilir sonuçlar üretir.", + "This workflow does not modify raw data; it only creates in-session analysis layers and saveable results.", + ), + }, + "DTA": { + "title": ("DTA Sayfası Rehberi", "DTA Page Guide"), + "what": [ + ( + "Ham DTA eğrisini sıcaklığa karşı termal fark sinyali olarak gösterir.", + "Shows the raw DTA curve as a thermal-difference signal versus temperature.", + ), + ( + "Yumuşatma, baz çizgisi düzeltmesi ve ekzotermik/endotermik olay tespitini aynı analiz akışında toplar.", + "Combines smoothing, baseline correction, and exothermic/endothermic event detection in one analysis flow.", + ), + ( + "Pik sonuçlarını proje kalıcılığı ve rapor çıktısı için kaydedilebilir hale getirir.", + "Makes peak results saveable for project persistence and report outputs.", + ), + ], + "order": [ + ( + "1. Ham Veri sekmesinde sinyal yönünü, aralığı ve numune metadata bilgisini kontrol edin.", + "1. Inspect signal direction, range, and sample metadata in Raw Data.", + ), + ( + "2. Gürültü baskınsa Yumuşatma sekmesinde kontrollü smoothing uygulayın.", + "2. Apply controlled smoothing in Smoothing when noise dominates the curve.", + ), + ( + "3. Baz çizgisi kayıyorsa Baseline Correction ile çalışma sinyalini düzeltin.", + "3. Use Baseline Correction when the working signal drifts.", + ), + ( + "4. Pik Analizi sekmesinde endotermik/ekzotermik aramayı ve eşikleri ayarlayın.", + "4. Tune endothermic/exothermic search and thresholds in Peak Analysis.", + ), + ( + "5. Sonuç Özeti sekmesinde doğrulayıp kaydı oturuma alın.", + "5. Validate the output in Results Summary, then save it to the session.", + ), + ], + "notes": [ + ( + "Ham sinyalin yönü cihaz ve export formatına göre değişebilir; yorumu pik tipine göre doğrulayın.", + "Raw signal direction can vary by instrument and export format; confirm interpretation from the detected peak type.", + ), + ( + "Aşırı baz çizgisi düzeltmesi küçük termal olayları bastırabilir.", + "Aggressive baseline correction can suppress subtle thermal events.", + ), + ( + "Metadata'daki ısıtma hızı yorum ve karşılaştırma notları için önemlidir.", + "Heating-rate metadata matters for interpretation and comparison notes.", + ), + ], + "footer": ( + "Bu iş akışı ham DTA verisini değiştirmez; yorum katmanları ve kaydedilebilir sonuçlar üretir.", + "This workflow does not modify raw DTA data; it creates interpretation layers and saveable results.", + ), + }, + "TGA": { + "title": ("TGA Sayfası Rehberi", "TGA Page Guide"), + "what": [ + ( + "Ham TGA eğrisini sıcaklığa karşı kalan kütle olarak gösterir.", + "Shows the raw TGA curve as remaining mass versus temperature.", + ), + ( + "DTG türevi ile en hızlı kütle kaybı bölgelerini görünür hale getirir.", + "Computes the DTG derivative so rapid mass-loss regions become easier to inspect.", + ), + ( + "Başlangıç, orta nokta, bitiş, toplam kütle kaybı ve kalıntı yüzdesini otomatik çıkarır.", + "Automatically extracts onset, midpoint, endset, total mass loss, and residue metrics.", + ), + ], + "order": [ + ( + "1. Ham Veri sekmesinde veri aralığını ve metadata bilgisini kontrol edin.", + "1. Use Raw Data to inspect range, quality, and metadata.", + ), + ( + "2. Gürültü varsa Yumuşatma / DTG sekmesinde yumuşatma uygulayın.", + "2. Apply smoothing in Smoothing / DTG only when noise hides the decomposition shape.", + ), + ( + "3. Adım Analizi sekmesinde adım tespitini çalıştırıp prominence veya minimum kütle kaybı eşiğini ayarlayın.", + "3. Run Step Analysis and tune prominence or minimum mass-loss thresholds when needed.", + ), + ( + "4. Sonuç Özeti sekmesinde özet metrikleri doğrulayıp sonucu oturuma kaydedin.", + "4. Review Results Summary, then save the validated result to the session.", + ), + ], + "notes": [ + ( + "DTG eğrisindeki en belirgin negatif bölgeler bozunmanın en hızlı olduğu sıcaklıkları temsil eder.", + "The strongest negative DTG regions correspond to temperatures where decomposition is fastest.", + ), + ( + "Aşırı yumuşatma onset/endset sıcaklıklarını kaydırabilir; gürültüyü bastırırken adım kenarlarını koruyun.", + "Over-smoothing can shift onset/endset temperatures; suppress noise without flattening step edges.", + ), + ( + "Numune kütlesi metadata olarak girilmişse yüzde kayba ek olarak mg cinsinden mutlak kayıp da hesaplanır.", + "If sample mass is present in metadata, the workflow also calculates absolute mass loss in mg.", + ), + ], + "footer": ( + "Bu iş akışı veriyi değiştirmez; yalnızca oturum içi analiz kopyaları ve dışa aktarılabilir sonuçlar üretir.", + "This workflow does not modify the raw data; it creates in-session analysis layers and exportable results only.", + ), + }, + "FTIR": { + "title": ("FTIR Sayfası Rehberi", "FTIR Page Guide"), + "what": [ + ( + "Ham FTIR spektrumunu dalgasayısı ekseninde gösterir.", + "Shows the raw FTIR spectrum on the wavenumber axis.", + ), + ( + "Ön işleme, pik çıkarımı ve nitel benzerlik aday sıralamasını aynı workflow içinde toplar.", + "Combines preprocessing, peak extraction, and qualitative similarity ranking in one workflow.", + ), + ( + "Kaydedilen spektral sonuçları rapor ve proje akışına taşır.", + "Carries saved spectral results into report and project flows.", + ), + ], + "order": [ + ( + "1. Raw Spectrum sekmesinde eksen yönünü, aralığı ve metadata bilgisini kontrol edin.", + "1. Inspect axis direction, range, and metadata in Raw Spectrum.", + ), + ( + "2. Ön işleme sekmesinde smoothing, baseline ve normalization adımlarını sadece gerektiği kadar uygulayın.", + "2. Use preprocessing to apply only the smoothing, baseline, and normalization that the spectrum needs.", + ), + ( + "3. Peak Detection sekmesinde pik listesini ve eşikleri doğrulayın.", + "3. Validate the peak list and thresholds in Peak Detection.", + ), + ( + "4. Similarity / Library sonuçlarını yorumlayıp sonucu oturuma kaydedin.", + "4. Review similarity/library results and save the validated result to the session.", + ), + ], + "notes": [ + ( + "FTIR'da ters eksen gösterimi yorum için önemlidir; dalgasayısı yönünü değiştirmeyin.", + "Reverse-axis display matters in FTIR interpretation; keep the wavenumber direction consistent.", + ), + ( + "Aşırı smoothing dar bantları silebilir ve fonksiyonel grup yorumunu zayıflatabilir.", + "Over-smoothing can erase narrow bands and weaken functional-group interpretation.", + ), + ( + "Kütüphane adayları nitel yönlendiricidir; son yorum spektrum bağlamı ile birlikte verilmelidir.", + "Library candidates are qualitative guides; final interpretation still needs spectral context.", + ), + ], + "footer": ( + "Bu iş akışı spektrumu değiştirmez; oturum içi işlenmiş katmanlar ve kaydedilebilir sonuçlar üretir.", + "This workflow does not modify the raw spectrum; it creates in-session processed layers and saveable results.", + ), + }, + "RAMAN": { + "title": ("Raman Sayfası Rehberi", "Raman Page Guide"), + "what": [ + ( + "Ham Raman spektrumunu Raman kayması ekseninde gösterir.", + "Shows the raw Raman spectrum on the Raman-shift axis.", + ), + ( + "Ön işleme, pik çıkarımı ve nitel benzerlik aday sıralamasını aynı workflow içinde yürütür.", + "Runs preprocessing, peak extraction, and qualitative similarity ranking in one workflow.", + ), + ( + "Kaydedilen sonuçları rapor ve proje çıktısına hazır hale getirir.", + "Prepares saved results for report and project outputs.", + ), + ], + "order": [ + ( + "1. Raw Spectrum sekmesinde aralığı, sinyal kalitesini ve metadata bilgisini inceleyin.", + "1. Review range, signal quality, and metadata in Raw Spectrum.", + ), + ( + "2. Ön işleme sekmesinde fluoresans veya taban etkisini bastıracak kadar smoothing/baseline uygulayın.", + "2. Apply enough smoothing/baseline work to suppress fluorescence or background effects in preprocessing.", + ), + ( + "3. Peak Detection sekmesinde aday pikleri ve eşikleri doğrulayın.", + "3. Validate candidate peaks and thresholds in Peak Detection.", + ), + ( + "4. Similarity / Library sonuçlarını inceleyip sadece doğruladığınız sonucu kaydedin.", + "4. Review similarity/library output and save only the validated result.", + ), + ], + "notes": [ + ( + "Raman'da taban etkisi ve fluoresans yükselmesi benzerlik skorlarını doğrudan etkiler.", + "Background drift and fluorescence strongly affect Raman similarity scores.", + ), + ( + "Aşırı preprocessing zayıf fakat anlamlı pikleri kaybettirebilir.", + "Aggressive preprocessing can remove weak but meaningful peaks.", + ), + ( + "Library eşleşmeleri tek başına kimlik doğrulaması değildir; pik bağlamı ile birlikte yorumlanmalıdır.", + "Library matches alone are not identity proof; interpret them together with peak context.", + ), + ], + "footer": ( + "Bu iş akışı ham Raman verisini değiştirmez; işlenmiş katmanlar ve kaydedilebilir sonuçlar üretir.", + "This workflow does not modify raw Raman data; it creates processed layers and saveable results.", + ), + }, + "XRD": { + "title": ("XRD Sayfası Rehberi", "XRD Page Guide"), + "what": [ + ( + "Ham XRD desenini 2θ ekseninde gösterir ve eksen/metaveri uygunluğunu doğrular.", + "Shows the raw XRD pattern on the 2θ axis and checks axis/metadata suitability.", + ), + ( + "Eksen normalizasyonu, pik çıkarımı ve nitel faz adayı eşlemesini tek workflow içinde yürütür.", + "Runs axis normalization, peak extraction, and qualitative phase-candidate matching in one workflow.", + ), + ( + "Kaydedilen sonucu rapor grafiği, proje kalıcılığı ve library provenance ile ilişkilendirir.", + "Links the saved result to report figures, project persistence, and library provenance.", + ), + ], + "order": [ + ( + "1. Raw Pattern sekmesinde eksen tipini, aralığı ve dalgaboyu metadata'sını kontrol edin.", + "1. Use Raw Pattern to confirm axis type, range, and wavelength metadata.", + ), + ( + "2. Pipeline sekmesinde eksen normalizasyonu, smoothing ve baseline parametrelerini ayarlayın.", + "2. Configure axis normalization, smoothing, and baseline parameters in Pipeline.", + ), + ( + "3. Peaks sekmesinde gözlenen pik listesini ve eşiklerini doğrulayın.", + "3. Validate observed peaks and thresholds in Peaks.", + ), + ( + "4. Matches sekmesinde faz adaylarını yorumlayın; ardından Results sekmesinden kaydı doğrulayın.", + "4. Review phase candidates in Matches, then validate the saved record in Results.", + ), + ], + "notes": [ + ( + "Yanlış 2θ ekseni veya eksik dalgaboyu bilgisi eşleştirme kalitesini düşürür.", + "An incorrect 2θ axis or missing wavelength metadata lowers matching quality.", + ), + ( + "Pik eşiği çok düşük tutulursa gürültü faz adayı puanlarını bozabilir.", + "Very low peak thresholds can let noise distort phase-candidate scores.", + ), + ( + "Library eşleşmeleri nitel faz taraması içindir; kesin faz doğrulaması değildir.", + "Library matches support qualitative phase screening rather than definitive phase confirmation.", + ), + ], + "footer": ( + "Bu iş akışı ham difraksiyon verisini değiştirmez; oturum içi yorum katmanları ve kaydedilebilir eşleşme sonuçları üretir.", + "This workflow does not modify raw diffraction data; it creates in-session interpretation layers and saveable matching results.", + ), + }, +} + + def render_home_workflow_guide() -> None: """Explain the product scope and recommended operator workflow.""" with st.expander(tx("Program Rehberi ve İş Akışı", "Program Guide and Workflow"), expanded=True): @@ -77,69 +407,39 @@ def render_home_workflow_guide() -> None: ) -def render_tga_workflow_guide() -> None: - """Explain the purpose and interpretation of the TGA workflow.""" - with st.expander(tx("TGA Sayfası Rehberi", "TGA Page Guide"), expanded=True): +def render_analysis_workflow_guide(analysis_type: str) -> None: + """Render a three-column workflow guide for a stable analysis page.""" + token = str(analysis_type or "").upper() + guide = _ANALYSIS_GUIDES.get(token) + if guide is None: + return + + with st.expander(tx(*guide["title"]), expanded=True): col1, col2, col3 = st.columns(3) with col1: _render_list( tx("Bu sayfa ne yapar?", "What does this page do?"), - [ - tx( - "Ham TGA eğrisini sıcaklığa karşı kalan kütle olarak gösterir.", - "Shows the raw TGA curve as remaining mass versus temperature.", - ), - tx( - "DTG türevi ile en hızlı kütle kaybı bölgelerini görünür hale getirir.", - "Computes the DTG derivative so rapid mass-loss regions become easier to inspect.", - ), - tx( - "Başlangıç, orta nokta, bitiş, toplam kütle kaybı ve kalıntı yüzdesini otomatik çıkarır.", - "Automatically extracts onset, midpoint, endset, total mass loss, and residue metrics.", - ), - ], + [tx(tr, en) for tr, en in guide["what"]], ) with col2: _render_list( tx("Sekmeleri hangi sırayla kullanmalı?", "Which tab order should you follow?"), - [ - tx("1. Ham Veri sekmesinde veri aralığını ve metadata bilgisini kontrol edin.", "1. Use Raw Data to inspect range, quality, and metadata."), - tx("2. Gürültü varsa Yumuşatma / DTG sekmesinde yumuşatma uygulayın.", "2. Apply smoothing in Smoothing / DTG only when noise hides the decomposition shape."), - tx( - "3. Adım Analizi sekmesinde adım tespitini çalıştırıp prominence veya minimum kütle kaybı eşiğini ayarlayın.", - "3. Run Step Analysis and tune prominence or minimum mass-loss thresholds when needed.", - ), - tx( - "4. Sonuç Özeti sekmesinde özet metrikleri doğrulayıp sonucu oturuma kaydedin.", - "4. Review Results Summary, then save the validated result to the session.", - ), - ], + [tx(tr, en) for tr, en in guide["order"]], ) with col3: _render_list( tx("Yorumlama notları", "Interpretation notes"), - [ - tx( - "DTG eğrisindeki en belirgin negatif bölgeler bozunmanın en hızlı olduğu sıcaklıkları temsil eder.", - "The strongest negative DTG regions correspond to temperatures where decomposition is fastest.", - ), - tx( - "Aşırı yumuşatma onset/endset sıcaklıklarını kaydırabilir; gürültüyü bastırırken adım kenarlarını koruyun.", - "Over-smoothing can shift onset/endset temperatures; suppress noise without flattening step edges.", - ), - tx( - "Numune kütlesi metadata olarak girilmişse yüzde kayba ek olarak mg cinsinden mutlak kayıp da hesaplanır.", - "If sample mass is present in metadata, the workflow also calculates absolute mass loss in mg.", - ), - ], + [tx(tr, en) for tr, en in guide["notes"]], ) st.caption( - tx( - "Bu iş akışı veriyi değiştirmez; yalnızca oturum içi analiz kopyaları ve dışa aktarılabilir sonuçlar üretir.", - "This workflow does not modify the raw data; it creates in-session analysis layers and exportable results only.", - ) + tx(*guide["footer"]) ) + + +def render_tga_workflow_guide() -> None: + """Backward-compatible TGA workflow guide wrapper.""" + render_analysis_workflow_guide("TGA") diff --git a/ui/dsc_page.py b/ui/dsc_page.py index d21ff5bb..f371ada0 100644 --- a/ui/dsc_page.py +++ b/ui/dsc_page.py @@ -28,6 +28,7 @@ from ui.components.history_tracker import _log_event from ui.components.preset_manager import render_processing_preset_panel, seed_pending_workflow_template from ui.components.quality_dashboard import render_quality_dashboard +from ui.components.workflow_guide import render_analysis_workflow_guide from utils.diagnostics import record_exception from utils.i18n import t, tx from utils.license_manager import APP_VERSION @@ -147,7 +148,8 @@ def _store_dsc_result(selected_key, dataset, temperature, signal, state): def render(): - render_page_header(t("dsc.title"), t("dsc.caption")) + render_page_header(t("dsc.title"), t("dsc.caption"), badge=t("dsc.hero_badge")) + render_analysis_workflow_guide("DSC") dsc_datasets = _get_dsc_datasets() if not dsc_datasets: diff --git a/ui/dta_page.py b/ui/dta_page.py index 3dbbf860..1b1d2e99 100644 --- a/ui/dta_page.py +++ b/ui/dta_page.py @@ -30,6 +30,7 @@ from ui.components.history_tracker import _log_event from ui.components.preset_manager import render_processing_preset_panel, seed_pending_workflow_template from ui.components.quality_dashboard import render_quality_dashboard +from ui.components.workflow_guide import render_analysis_workflow_guide from utils.reference_data import render_reference_comparison from utils.i18n import t, tx from utils.session_state import ( @@ -137,12 +138,7 @@ def _store_dta_result(selected_key, dataset, temperature, signal, state): def render(): render_page_header(t("dta.title"), t("dta.caption"), badge=t("dta.hero_badge")) - st.caption( - tx( - "Kararlı DTA iş akışı: DTA sonuçları proje kalıcılığı, raporlama ve export akışıyla aynı kararlı kapsamda yönetilir.", - "Stable DTA workflow: DTA results are handled in the same stable scope for project persistence, reporting, and export flows.", - ) - ) + render_analysis_workflow_guide("DTA") dta_datasets = _get_dta_datasets() if not dta_datasets: diff --git a/ui/spectral_page.py b/ui/spectral_page.py index 6a468204..35bdd975 100644 --- a/ui/spectral_page.py +++ b/ui/spectral_page.py @@ -28,6 +28,7 @@ create_thermal_plot, default_plot_display_settings, ) +from ui.components.workflow_guide import render_analysis_workflow_guide from utils.i18n import t, tx from utils.license_manager import APP_VERSION @@ -422,13 +423,7 @@ def render_spectral_page( token = str(analysis_type or "").upper() page_slug = token.lower() render_page_header(t(title_key), t(caption_key), badge=t(badge_key)) - st.info( - tx( - "{analysis_type} kararlı akışı ön işleme, pik çıkarımı ve benzerlik aday sıralamasını tek workflow içinde üretir.", - "Stable {analysis_type} flow applies preprocessing, peak extraction, and similarity-candidate ranking in one workflow.", - analysis_type=token, - ) - ) + render_analysis_workflow_guide(token) spectral_datasets = _get_spectral_datasets(token) if not spectral_datasets: diff --git a/ui/tga_page.py b/ui/tga_page.py index 38527d06..f00745d8 100644 --- a/ui/tga_page.py +++ b/ui/tga_page.py @@ -180,7 +180,7 @@ def _store_tga_result(selected_key, dataset, temperature, mass_signal, result): def render(): - render_page_header(t("tga.title"), t("tga.caption")) + render_page_header(t("tga.title"), t("tga.caption"), badge=t("tga.hero_badge")) render_tga_workflow_guide() tga_datasets = _get_tga_datasets() diff --git a/ui/xrd_page.py b/ui/xrd_page.py index 5f2d2157..b4c80a74 100644 --- a/ui/xrd_page.py +++ b/ui/xrd_page.py @@ -37,6 +37,7 @@ create_thermal_plot, fig_to_bytes, ) +from ui.components.workflow_guide import render_analysis_workflow_guide from utils.diagnostics import record_exception from utils.i18n import t, tx from utils.license_manager import APP_VERSION @@ -1538,12 +1539,7 @@ def _save_xrd_graph_snapshot_to_session( def render(): lang = st.session_state.get("ui_language", "tr") render_page_header(t("xrd.title"), t("xrd.caption"), badge=t("xrd.hero_badge")) - st.info( - tx( - "Kararlı XRD akışı axis normalizasyonu, pik çıkarımı ve nitel faz adayı eşleşmesini deterministik parametrelerle çalıştırır.", - "Stable XRD flow executes axis normalization, peak extraction, and qualitative phase-candidate matching with deterministic parameters.", - ) - ) + render_analysis_workflow_guide("XRD") xrd_datasets = _get_xrd_datasets() if not xrd_datasets: diff --git a/utils/i18n.py b/utils/i18n.py index 96db715b..c261f2a9 100644 --- a/utils/i18n.py +++ b/utils/i18n.py @@ -204,64 +204,72 @@ "en": "DSC Analysis", }, "dsc.caption": { - "tr": "Ham sinyalden Tg, baseline ve peak karakterizasyonuna kadar DSC akışını yönet.", - "en": "Run the DSC workflow from raw signal through Tg, baseline, and peak characterization.", + "tr": "Diferansiyel Taramalı Kalorimetri (DSC) koşularında ham sinyal, Tg, baseline ve pik karakterizasyonunu tek kararlı iş akışında yönet.", + "en": "Run Differential Scanning Calorimetry (DSC) from raw signal through Tg, baseline, and peak characterization in one stable workflow.", + }, + "dsc.hero_badge": { + "tr": "Diferansiyel Taramalı Kalorimetri İş Akışı", + "en": "Differential Scanning Calorimetry Workflow", }, "tga.title": { "tr": "TGA Analizi", "en": "TGA Analysis", }, "tga.caption": { - "tr": "Kütle kaybı adımlarını, DTG eğrisini ve kalıntı yüzdesini tek iş akışında çıkar.", - "en": "Extract mass-loss steps, DTG curves, and residue metrics in one workflow.", + "tr": "Termogravimetrik Analiz (TGA) koşularında kütle kaybı adımlarını, DTG eğrisini ve kalıntı metriklerini tek kararlı iş akışında çıkar.", + "en": "Extract mass-loss steps, DTG curves, and residue metrics for Thermogravimetric Analysis (TGA) in one stable workflow.", + }, + "tga.hero_badge": { + "tr": "Termogravimetrik Analiz İş Akışı", + "en": "Thermogravimetric Analysis Workflow", }, "dta.title": { "tr": "DTA Analizi", "en": "DTA Analysis", }, "dta.caption": { - "tr": "DTA sinyalini yumuşatma, baseline düzeltme ve pik yorumu ile aynı kararlı rapor akışında işle.", - "en": "Process DTA signals with smoothing, baseline correction, and peak interpretation inside the same stable reporting flow.", + "tr": "Diferansiyel Termal Analiz (DTA) sinyalini yumuşatma, baseline düzeltme ve pik yorumu ile aynı kararlı rapor akışında işle.", + "en": "Process Differential Thermal Analysis (DTA) signals with smoothing, baseline correction, and peak interpretation inside the same stable reporting flow.", }, "dta.hero_badge": { - "tr": "Termal Fark İş Akışı", - "en": "Thermal Difference Workflow", + "tr": "Diferansiyel Termal Analiz İş Akışı", + "en": "Differential Thermal Analysis Workflow", }, "xrd.title": { "tr": "XRD Analizi", "en": "XRD Analysis", }, "xrd.caption": { - "tr": "XRD desenlerinde ön işleme, pik çıkarımı ve nitel faz adayı eşlemesini kararlı akışta çalıştır.", - "en": "Run stable XRD preprocessing, peak extraction, and qualitative phase-candidate matching workflows.", + "tr": "X-Işını Difraksiyonu (XRD) desenlerinde ön işleme, pik çıkarımı ve nitel faz adayı eşlemesini kararlı akışta çalıştır.", + "en": "Run stable preprocessing, peak extraction, and qualitative phase-candidate matching for X-Ray Diffraction (XRD).", }, "xrd.hero_badge": { - "tr": "Difraksiyon İş Akışı", - "en": "Diffraction Workflow", + "tr": "X-Işını Difraksiyonu İş Akışı", + "en": "X-Ray Diffraction Workflow", }, "ftir.title": { "tr": "FTIR Analizi", "en": "FTIR Analysis", }, "ftir.caption": { - "tr": "FTIR spektrumlarında ön işleme, pik çıkarımı ve nitel benzerlik adayı sıralamasını kararlı akışta çalıştır.", - "en": "Run stable FTIR preprocessing, peak extraction, and qualitative similarity-candidate ranking workflows.", + "tr": "Fourier Dönüşümlü Kızılötesi (FTIR) spektrumlarında ön işleme, pik çıkarımı ve nitel benzerlik adayı sıralamasını kararlı akışta çalıştır.", + "en": "Run stable preprocessing, peak extraction, and qualitative similarity-candidate ranking for Fourier Transform Infrared (FTIR) spectra.", }, "ftir.hero_badge": { - "tr": "FTIR İş Akışı", - "en": "FTIR Workflow", + "tr": "Fourier Dönüşümlü Kızılötesi İş Akışı", + "en": "Fourier Transform Infrared Workflow", }, "raman.title": { "tr": "Raman Analizi", "en": "Raman Analysis", }, "raman.caption": { - "tr": "Raman spektrumlarında ön işleme, pik çıkarımı ve nitel benzerlik adayı sıralamasını kararlı akışta çalıştır.", - "en": "Run stable Raman preprocessing, peak extraction, and qualitative similarity-candidate ranking workflows.", + "tr": "Raman Spektroskopisi spektrumlarında ön işleme, pik çıkarımı ve nitel benzerlik adayı sıralamasını kararlı akışta çalıştır.", + "en": "Run stable preprocessing, peak extraction, and qualitative similarity-candidate ranking for Raman Spectroscopy spectra.", }, "raman.hero_badge": { - "tr": "Raman İş Akışı", - "en": "Raman Workflow", + "tr": "Raman Spektroskopisi İş Akışı", + "en": "Raman Spectroscopy Workflow", }, } From bbbe81ce773a597ca96f01fe62551c1b71428916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 15:22:43 +0300 Subject: [PATCH 08/17] Move about content into license page tabs --- app.py | 49 -------------------------------- tests/test_ui_consistency.py | 10 +++++++ ui/license_page.py | 54 +++++++++++++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 50 deletions(-) diff --git a/app.py b/app.py index 832eb7a5..71aca9ff 100644 --- a/app.py +++ b/app.py @@ -439,53 +439,4 @@ def _render_project_sidebar(): with st.expander(t("sidebar.pipeline"), expanded=False): render_history_sidebar() -# --- About panel & footer in sidebar --- -with st.sidebar: - st.markdown("---") - with st.expander(t("sidebar.about")): - if st.session_state.get("ui_language", "tr") == "tr": - st.markdown( - f"**MaterialScope v{APP_VERSION}**\n\n" - "QC ve Ar-Ge laboratuvarları için cihazdan bağımsız, çok modlu DSC/TGA/DTA/FTIR/RAMAN/XRD karakterizasyon çalışma alanı.\n\n" - "**Kararlı beta kapsamı**\n" - "- CSV/TXT/XLSX DSC, TGA, DTA, FTIR, RAMAN ve XRD koşularını içe aktar\n" - "- DSC, TGA, DTA, FTIR, RAMAN ve XRD analiz akışlarını çalıştır\n" - "- Çoklu koşuları Karşılaştırma Alanı ve Toplu Şablon Uygulayıcı ile yönet\n" - "- Kararlı sonuçları proje durumu, rapor ve export akışıyla sakla\n" - "- Laboratuvar önizleme modülleri yalnızca özel olarak etkinleştirilen buildlerde görünür\n\n" - "**Laboratuvar önizleme modülleri**\n" - "- Kinetik ve dekonvolüsyon modülleri önizleme anahtarı arkasında kalır ve ticari stabilite sözüne dahil değildir.\n\n" - "**Referans standartlar**\n" - "- ASTM E967 — DSC sıcaklık ve entalpi kalibrasyonu\n" - "- ASTM E1131 — TGA ile kompozisyon analizi\n" - "- ASTM E1356 — DSC ile cam geçişi\n" - "- ICTAC kinetik analiz rehberleri" - ) - st.caption( - "Pilot kabuk: Streamlit\n" - "Ticari yön: offline masaüstü kabuk + yıllık cihaz lisansı" - ) - else: - st.markdown( - f"**MaterialScope v{APP_VERSION}**\n\n" - "Vendor-independent multimodal DSC/TGA/DTA/FTIR/RAMAN/XRD characterization workbench for QC and R&D labs.\n\n" - "**Stable beta scope**\n" - "- Import DSC, TGA, DTA, FTIR, RAMAN, and XRD runs from CSV/TXT/XLSX exports\n" - "- Execute stable DSC, TGA, DTA, FTIR, RAMAN, and XRD analysis workflows\n" - "- Manage multiple runs through Compare Workspace and the Batch Template Runner\n" - "- Save stable results through the current project, report, and export flows\n" - "- Lab preview modules appear only in explicitly enabled builds\n\n" - "**Lab Preview modules**\n" - "- Kinetics and deconvolution stay available behind the preview toggle and are excluded from the commercial stability promise.\n\n" - "**Reference standards**\n" - "- ASTM E967 — DSC Temperature & Enthalpy Calibration\n" - "- ASTM E1131 — Compositional Analysis by TGA\n" - "- ASTM E1356 — Glass Transition by DSC\n" - "- ICTAC kinetic analysis guidance" - ) - st.caption( - "Pilot shell: Streamlit\n" - "Commercial direction: offline desktop shell + annual device licensing" - ) - pg.run() diff --git a/tests/test_ui_consistency.py b/tests/test_ui_consistency.py index ed675818..4930d3a9 100644 --- a/tests/test_ui_consistency.py +++ b/tests/test_ui_consistency.py @@ -61,3 +61,13 @@ def test_project_page_sidebar_hint_matches_sidebar_actions(): assert '"project.sidebar_hint"' in i18n assert "Proje Dosyasını Hazırla" in i18n assert "Load Selected Project" in i18n + + +def test_about_content_moves_from_sidebar_to_license_tabs(): + app_entry = _repo_text("app.py") + license_page = _repo_text("ui/license_page.py") + + assert 'with st.expander(t("sidebar.about"))' not in app_entry + assert 'activation_tab, branding_tab, about_tab = st.tabs([' in license_page + assert '"Hakkında" if lang == "tr" else "About"' in license_page + assert "_render_about_materialscope(lang)" in license_page diff --git a/ui/license_page.py b/ui/license_page.py index d1a0e2ad..8a127722 100644 --- a/ui/license_page.py +++ b/ui/license_page.py @@ -71,9 +71,10 @@ def render(): expanded=False, ) - activation_tab, branding_tab = st.tabs([ + activation_tab, branding_tab, about_tab = st.tabs([ "Aktivasyon" if lang == "tr" else "Activation", "Rapor Markası" if lang == "tr" else "Report Branding", + "Hakkında" if lang == "tr" else "About", ]) with activation_tab: @@ -151,6 +152,9 @@ def render(): if branding.get("logo_name"): st.caption(f"Mevcut logo: {branding['logo_name']}" if lang == "tr" else f"Current logo: {branding['logo_name']}") + with about_tab: + _render_about_materialscope(lang) + def _status_label(state, lang): labels = { @@ -166,3 +170,51 @@ def _status_label(state, lang): def _license_company(state): payload = (state or {}).get("license") or {} return payload.get("company_name", "") + + +def _render_about_materialscope(lang: str) -> None: + if lang == "tr": + st.markdown( + f"**MaterialScope v{APP_VERSION}**\n\n" + "QC ve Ar-Ge laboratuvarları için cihazdan bağımsız, çok modlu DSC/TGA/DTA/FTIR/RAMAN/XRD karakterizasyon çalışma alanı.\n\n" + "**Kararlı beta kapsamı**\n" + "- CSV/TXT/XLSX DSC, TGA, DTA, FTIR, RAMAN ve XRD koşularını içe aktar\n" + "- DSC, TGA, DTA, FTIR, RAMAN ve XRD analiz akışlarını çalıştır\n" + "- Çoklu koşuları Karşılaştırma Alanı ve Toplu Şablon Uygulayıcı ile yönet\n" + "- Kararlı sonuçları proje durumu, rapor ve export akışıyla sakla\n" + "- Laboratuvar önizleme modülleri yalnızca özel olarak etkinleştirilen buildlerde görünür\n\n" + "**Laboratuvar önizleme modülleri**\n" + "- Kinetik ve dekonvolüsyon modülleri önizleme anahtarı arkasında kalır ve ticari stabilite sözüne dahil değildir.\n\n" + "**Referans standartlar**\n" + "- ASTM E967 — DSC sıcaklık ve entalpi kalibrasyonu\n" + "- ASTM E1131 — TGA ile kompozisyon analizi\n" + "- ASTM E1356 — DSC ile cam geçişi\n" + "- ICTAC kinetik analiz rehberleri" + ) + st.caption( + "Pilot kabuk: Streamlit\n" + "Ticari yön: offline masaüstü kabuk + yıllık cihaz lisansı" + ) + return + + st.markdown( + f"**MaterialScope v{APP_VERSION}**\n\n" + "Vendor-independent multimodal DSC/TGA/DTA/FTIR/RAMAN/XRD characterization workbench for QC and R&D labs.\n\n" + "**Stable beta scope**\n" + "- Import DSC, TGA, DTA, FTIR, RAMAN, and XRD runs from CSV/TXT/XLSX exports\n" + "- Execute stable DSC, TGA, DTA, FTIR, RAMAN, and XRD analysis workflows\n" + "- Manage multiple runs through Compare Workspace and the Batch Template Runner\n" + "- Save stable results through the current project, report, and export flows\n" + "- Lab preview modules appear only in explicitly enabled builds\n\n" + "**Lab Preview modules**\n" + "- Kinetics and deconvolution stay available behind the preview toggle and are excluded from the commercial stability promise.\n\n" + "**Reference standards**\n" + "- ASTM E967 — DSC Temperature & Enthalpy Calibration\n" + "- ASTM E1131 — Compositional Analysis by TGA\n" + "- ASTM E1356 — Glass Transition by DSC\n" + "- ICTAC kinetic analysis guidance" + ) + st.caption( + "Pilot shell: Streamlit\n" + "Commercial direction: offline desktop shell + annual device licensing" + ) From 76d4122e15a392f8745d6d82fc156a203d1714b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 15:29:25 +0300 Subject: [PATCH 09/17] Add standalone about page and remove visible version labels --- app.py | 5 +- tests/test_ui_consistency.py | 19 ++++- ui/about_page.py | 148 +++++++++++++++++++++++++++++++++++ ui/license_page.py | 55 +------------ utils/i18n.py | 12 +++ 5 files changed, 179 insertions(+), 60 deletions(-) create mode 100644 ui/about_page.py diff --git a/app.py b/app.py index 71aca9ff..3e54b31b 100644 --- a/app.py +++ b/app.py @@ -298,7 +298,7 @@ }.get(license_state.get("status"), t("app.license.development")) st.markdown( f'' - f'', + f'', unsafe_allow_html=True, ) n_datasets = len(st.session_state.get("datasets", {})) @@ -381,6 +381,7 @@ def _render_project_sidebar(): # --- Page imports --- from ui.components.history_tracker import render_history_sidebar +from ui.about_page import render as about_render from ui.home import render as home_render from ui.compare_page import render as compare_render from ui.dsc_page import render as dsc_render @@ -406,7 +407,6 @@ def _render_project_sidebar(): ) else: show_preview_tools = False - st.sidebar.caption(t("app.preview_disabled")) pages = { tx("Ana Akış", "Primary"): [ @@ -422,6 +422,7 @@ def _render_project_sidebar(): st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report"), st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project"), st.Page(license_render, title=t("nav.license"), icon="🔐", url_path="license"), + st.Page(about_render, title=t("nav.about"), icon="ℹ️", url_path="about"), ], } if show_preview_tools: diff --git a/tests/test_ui_consistency.py b/tests/test_ui_consistency.py index 4930d3a9..d3965bf5 100644 --- a/tests/test_ui_consistency.py +++ b/tests/test_ui_consistency.py @@ -63,11 +63,22 @@ def test_project_page_sidebar_hint_matches_sidebar_actions(): assert "Load Selected Project" in i18n -def test_about_content_moves_from_sidebar_to_license_tabs(): +def test_about_page_is_navigation_item_not_license_tab(): app_entry = _repo_text("app.py") license_page = _repo_text("ui/license_page.py") + about_page = _repo_text("ui/about_page.py") assert 'with st.expander(t("sidebar.about"))' not in app_entry - assert 'activation_tab, branding_tab, about_tab = st.tabs([' in license_page - assert '"Hakkında" if lang == "tr" else "About"' in license_page - assert "_render_about_materialscope(lang)" in license_page + assert 'st.Page(about_render, title=t("nav.about"), icon="ℹ️", url_path="about")' in app_entry + assert 'activation_tab, branding_tab = st.tabs([' in license_page + assert "about_tab" not in license_page + assert 'render_page_header(t("about.title"), t("about.caption"), badge=t("about.hero_badge"))' in about_page + + +def test_sidebar_and_about_copy_do_not_show_version_or_preview_disabled_note(): + app_entry = _repo_text("app.py") + about_page = _repo_text("ui/about_page.py") + + assert 'v{APP_VERSION}' not in app_entry + assert 'st.sidebar.caption(t("app.preview_disabled"))' not in app_entry + assert "MaterialScope v" not in about_page diff --git a/ui/about_page.py b/ui/about_page.py new file mode 100644 index 00000000..d442037f --- /dev/null +++ b/ui/about_page.py @@ -0,0 +1,148 @@ +"""Expanded product/about page.""" + +from __future__ import annotations + +import streamlit as st + +from ui.components.chrome import render_page_header +from utils.i18n import t, tx + + +def render() -> None: + render_page_header(t("about.title"), t("about.caption"), badge=t("about.hero_badge")) + + overview_tab, scope_tab, standards_tab = st.tabs( + [ + tx("Genel Bakış", "Overview"), + tx("Kapsam ve Yol Haritası", "Scope and Roadmap"), + tx("Referanslar", "References"), + ] + ) + + with overview_tab: + col1, col2 = st.columns([1.35, 1]) + + with col1: + st.markdown( + tx( + """ +**MaterialScope**, QC ve Ar-Ge laboratuvarları için cihazdan bağımsız, çok modlu bir karakterizasyon çalışma alanıdır. + +Bu ürün tek bir vendor formatına kilitlenmeden DSC, TGA, DTA, FTIR, Raman ve XRD koşularını aynı oturum içinde içe aktarmayı, karşılaştırmayı, analiz etmeyi ve raporlamayı hedefler. + +Merkez yaklaşım şudur: +- ham veriyi tek bir normalize çalışma alanında toplamak +- her modalite için kararlı ve izlenebilir analiz akışı sunmak +- sonuçları rapor, proje arşivi ve dışa aktarım yüzeyiyle aynı ürün mantığında bağlamak + """, + """ +**MaterialScope** is a vendor-independent, multimodal characterization workbench for QC and R&D laboratories. + +The product is designed to import, compare, analyze, and report DSC, TGA, DTA, FTIR, Raman, and XRD runs inside one shared session without locking the workflow to a single vendor format. + +The core approach is: +- collect raw runs inside one normalized workspace +- provide stable and traceable analysis flows for each modality +- connect results to reporting, project archive, and export surfaces inside the same product logic + """, + ) + ) + + with col2: + st.metric(tx("Kararlı Modalite", "Stable Modalities"), "6") + st.metric(tx("Ana Yüzey", "Core Surfaces"), "Import / Compare / Analyze / Report / Project") + st.metric(tx("Dağıtım Profili", "Deployment Profile"), tx("Web Demo + Masaüstü Yönü", "Web Demo + Desktop Direction")) + + st.info( + tx( + "Bu sayfa ürünün neyi çözmeye çalıştığını, bugün hangi kapsamın kararlı olduğunu ve hangi alanların önizleme olarak tutulduğunu açıklar.", + "This page explains what the product is trying to solve, which scope is stable today, and which areas remain preview-only.", + ) + ) + + with scope_tab: + stable_col, preview_col = st.columns(2) + + with stable_col: + st.markdown(f"### {tx('Kararlı Beta Kapsamı', 'Stable Beta Scope')}") + st.markdown( + tx( + """ +- CSV/TXT/XLSX exportlarından DSC, TGA, DTA, FTIR, Raman ve XRD koşularını içe aktarma +- Karşılaştırma Alanı ile çoklu koşu overlay ve ortak metadata kontrolü +- Modalite bazlı kararlı analiz akışları +- Rapor Merkezi ile dışa aktarım ve rapor üretimi +- Proje Alanı ile oturum/proje arşivleme + """, + """ +- Import DSC, TGA, DTA, FTIR, Raman, and XRD runs from CSV/TXT/XLSX exports +- Multi-run overlays and shared metadata review through Compare Workspace +- Stable modality-specific analysis workflows +- Export and report generation through Report Center +- Session/project archiving through Project Workspace + """, + ) + ) + + with preview_col: + st.markdown(f"### {tx('Önizleme ve Gelecek Alanlar', 'Preview and Next Areas')}") + st.markdown( + tx( + """ +- Kinetik ve dekonvolüsyon modülleri hâlâ önizleme katmanında tutulur +- Üyelik/lisans/ticari paketleme akışı henüz son ürün seviyesinde değildir +- Custom domain, son prod sertleştirme ve geniş erişim politikaları daha sonraki fazdadır + """, + """ +- Kinetics and deconvolution remain in the preview layer +- Membership, licensing, and commercial packaging are not yet in final-product shape +- Custom domain, final production hardening, and broader access policies are planned for a later phase + """, + ) + ) + + st.caption( + tx( + "Hedef kısa vadede hazır görünen değil, güvenilir kullanılan bir analiz yüzeyi oluşturmaktır.", + "The near-term goal is not to look finished, but to become reliably usable.", + ) + ) + + with standards_tab: + col1, col2 = st.columns(2) + + with col1: + st.markdown(f"### {tx('Referans Standartlar', 'Reference Standards')}") + st.markdown( + tx( + """ +- ASTM E967 — DSC sıcaklık ve entalpi kalibrasyonu +- ASTM E1131 — TGA ile kompozisyon analizi +- ASTM E1356 — DSC ile cam geçişi +- ICTAC kinetik analiz rehberleri + """, + """ +- ASTM E967 — DSC temperature and enthalpy calibration +- ASTM E1131 — compositional analysis by TGA +- ASTM E1356 — glass transition by DSC +- ICTAC kinetic analysis guidance + """, + ) + ) + + with col2: + st.markdown(f"### {tx('Ürün Yönü', 'Product Direction')}") + st.markdown( + tx( + """ +- bugün: hoca/demo odaklı web erişimi +- yakın hedef: ready-to-use ürün yüzeyi ve kararlı proje/rapor akışı +- daha sonra: masaüstü kabuk, lisans yönetimi ve ticari dağıtım + """, + """ +- today: instructor/demo-focused web access +- near-term goal: ready-to-use product surface and stable project/report flow +- later: desktop shell, license management, and commercial distribution + """, + ) + ) diff --git a/ui/license_page.py b/ui/license_page.py index 8a127722..936a8ed1 100644 --- a/ui/license_page.py +++ b/ui/license_page.py @@ -71,10 +71,9 @@ def render(): expanded=False, ) - activation_tab, branding_tab, about_tab = st.tabs([ + activation_tab, branding_tab = st.tabs([ "Aktivasyon" if lang == "tr" else "Activation", "Rapor Markası" if lang == "tr" else "Report Branding", - "Hakkında" if lang == "tr" else "About", ]) with activation_tab: @@ -152,10 +151,6 @@ def render(): if branding.get("logo_name"): st.caption(f"Mevcut logo: {branding['logo_name']}" if lang == "tr" else f"Current logo: {branding['logo_name']}") - with about_tab: - _render_about_materialscope(lang) - - def _status_label(state, lang): labels = { "development": "Geliştirme Build'i" if lang == "tr" else "Development Build", @@ -170,51 +165,3 @@ def _status_label(state, lang): def _license_company(state): payload = (state or {}).get("license") or {} return payload.get("company_name", "") - - -def _render_about_materialscope(lang: str) -> None: - if lang == "tr": - st.markdown( - f"**MaterialScope v{APP_VERSION}**\n\n" - "QC ve Ar-Ge laboratuvarları için cihazdan bağımsız, çok modlu DSC/TGA/DTA/FTIR/RAMAN/XRD karakterizasyon çalışma alanı.\n\n" - "**Kararlı beta kapsamı**\n" - "- CSV/TXT/XLSX DSC, TGA, DTA, FTIR, RAMAN ve XRD koşularını içe aktar\n" - "- DSC, TGA, DTA, FTIR, RAMAN ve XRD analiz akışlarını çalıştır\n" - "- Çoklu koşuları Karşılaştırma Alanı ve Toplu Şablon Uygulayıcı ile yönet\n" - "- Kararlı sonuçları proje durumu, rapor ve export akışıyla sakla\n" - "- Laboratuvar önizleme modülleri yalnızca özel olarak etkinleştirilen buildlerde görünür\n\n" - "**Laboratuvar önizleme modülleri**\n" - "- Kinetik ve dekonvolüsyon modülleri önizleme anahtarı arkasında kalır ve ticari stabilite sözüne dahil değildir.\n\n" - "**Referans standartlar**\n" - "- ASTM E967 — DSC sıcaklık ve entalpi kalibrasyonu\n" - "- ASTM E1131 — TGA ile kompozisyon analizi\n" - "- ASTM E1356 — DSC ile cam geçişi\n" - "- ICTAC kinetik analiz rehberleri" - ) - st.caption( - "Pilot kabuk: Streamlit\n" - "Ticari yön: offline masaüstü kabuk + yıllık cihaz lisansı" - ) - return - - st.markdown( - f"**MaterialScope v{APP_VERSION}**\n\n" - "Vendor-independent multimodal DSC/TGA/DTA/FTIR/RAMAN/XRD characterization workbench for QC and R&D labs.\n\n" - "**Stable beta scope**\n" - "- Import DSC, TGA, DTA, FTIR, RAMAN, and XRD runs from CSV/TXT/XLSX exports\n" - "- Execute stable DSC, TGA, DTA, FTIR, RAMAN, and XRD analysis workflows\n" - "- Manage multiple runs through Compare Workspace and the Batch Template Runner\n" - "- Save stable results through the current project, report, and export flows\n" - "- Lab preview modules appear only in explicitly enabled builds\n\n" - "**Lab Preview modules**\n" - "- Kinetics and deconvolution stay available behind the preview toggle and are excluded from the commercial stability promise.\n\n" - "**Reference standards**\n" - "- ASTM E967 — DSC Temperature & Enthalpy Calibration\n" - "- ASTM E1131 — Compositional Analysis by TGA\n" - "- ASTM E1356 — Glass Transition by DSC\n" - "- ICTAC kinetic analysis guidance" - ) - st.caption( - "Pilot shell: Streamlit\n" - "Commercial direction: offline desktop shell + annual device licensing" - ) diff --git a/utils/i18n.py b/utils/i18n.py index c261f2a9..62e755fe 100644 --- a/utils/i18n.py +++ b/utils/i18n.py @@ -99,6 +99,18 @@ "tr": "Laboratuvar Önizlemesi", "en": "Lab Preview", }, + "about.title": { + "tr": "Hakkında", + "en": "About", + }, + "about.caption": { + "tr": "MaterialScope’un neyi çözdüğünü, bugün hangi kapsamın kararlı olduğunu ve ürün yönünü tek sayfada özetle.", + "en": "Summarize what MaterialScope solves, which scope is stable today, and the current product direction in one page.", + }, + "about.hero_badge": { + "tr": "Ürün Bağlamı", + "en": "Product Context", + }, "sidebar.project": { "tr": "Proje", "en": "Project", From 32d43009d3bf435bbf198a660a7c1e2a2c3bc940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 15:31:57 +0300 Subject: [PATCH 10/17] Add about navigation label translation --- utils/i18n.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/utils/i18n.py b/utils/i18n.py index 62e755fe..8fb63820 100644 --- a/utils/i18n.py +++ b/utils/i18n.py @@ -95,6 +95,10 @@ "tr": "Lisans ve Marka", "en": "License & Branding", }, + "nav.about": { + "tr": "Hakkında", + "en": "About", + }, "nav.preview": { "tr": "Laboratuvar Önizlemesi", "en": "Lab Preview", From 1799c4ab78bd4dc51d8c88dce238d6ceef4164d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 15:41:02 +0300 Subject: [PATCH 11/17] Group sidebar navigation by workflow --- app.py | 14 +++++++++----- tests/test_ui_consistency.py | 15 +++++++++++++++ utils/i18n.py | 12 ++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/app.py b/app.py index 3e54b31b..f9051a69 100644 --- a/app.py +++ b/app.py @@ -313,7 +313,6 @@ def _render_project_sidebar(): """Render project save/load actions in the sidebar.""" - st.markdown(f"**{t('sidebar.project')}**") st.caption(t("sidebar.project.caption")) has_project_data = bool(st.session_state.get("datasets") or st.session_state.get("results")) @@ -409,18 +408,22 @@ def _render_project_sidebar(): show_preview_tools = False pages = { - tx("Ana Akış", "Primary"): [ + t("nav.primary"): [ st.Page(home_render, title=t("nav.import"), icon="📂", default=True, url_path="import"), st.Page(compare_render, title=t("nav.compare"), icon="🧪", url_path="compare"), + st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report"), + st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project"), + ], + t("nav.analyses"): [ st.Page(dsc_render, title=t("nav.dsc"), icon="📈", url_path="dsc"), st.Page(tga_render, title=t("nav.tga"), icon="📉", url_path="tga"), st.Page(dta_render, title=tx("DTA Analizi", "DTA Analysis"), icon="📊", url_path="dta"), st.Page(ftir_render, title=t("nav.ftir"), icon="🧬", url_path="ftir"), st.Page(raman_render, title=t("nav.raman"), icon="🔦", url_path="raman"), st.Page(xrd_render, title=t("nav.xrd"), icon="🧿", url_path="xrd"), + ], + t("nav.management"): [ st.Page(library_render, title=tx("Kütüphane", "Library"), icon="🗃️", url_path="library"), - st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report"), - st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project"), st.Page(license_render, title=t("nav.license"), icon="🔐", url_path="license"), st.Page(about_render, title=t("nav.about"), icon="ℹ️", url_path="about"), ], @@ -435,7 +438,8 @@ def _render_project_sidebar(): # --- Pipeline history in sidebar --- with st.sidebar: - _render_project_sidebar() + with st.expander(t("sidebar.project"), expanded=False): + _render_project_sidebar() st.markdown("---") with st.expander(t("sidebar.pipeline"), expanded=False): render_history_sidebar() diff --git a/tests/test_ui_consistency.py b/tests/test_ui_consistency.py index d3965bf5..748a82c6 100644 --- a/tests/test_ui_consistency.py +++ b/tests/test_ui_consistency.py @@ -82,3 +82,18 @@ def test_sidebar_and_about_copy_do_not_show_version_or_preview_disabled_note(): assert 'v{APP_VERSION}' not in app_entry assert 'st.sidebar.caption(t("app.preview_disabled"))' not in app_entry assert "MaterialScope v" not in about_page + + +def test_sidebar_navigation_uses_grouped_scientific_structure(): + app_entry = _repo_text("app.py") + i18n = _repo_text("utils/i18n.py") + + assert 't("nav.primary")' in app_entry + assert 't("nav.analyses")' in app_entry + assert 't("nav.management")' in app_entry + assert 'with st.expander(t("sidebar.project"), expanded=False):' in app_entry + assert 'st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report")' in app_entry + assert 'st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project")' in app_entry + assert '"nav.primary"' in i18n + assert '"nav.analyses"' in i18n + assert '"nav.management"' in i18n diff --git a/utils/i18n.py b/utils/i18n.py index 8fb63820..e54fc727 100644 --- a/utils/i18n.py +++ b/utils/i18n.py @@ -59,6 +59,18 @@ "tr": "Veri Al", "en": "Import Runs", }, + "nav.primary": { + "tr": "Ana Akış", + "en": "Primary Flow", + }, + "nav.analyses": { + "tr": "Analizler", + "en": "Analyses", + }, + "nav.management": { + "tr": "Yönetim", + "en": "Management", + }, "nav.compare": { "tr": "Karşılaştırma", "en": "Compare Workspace", From 8db888a61123f8a0ca463694277a4924a5de28cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 15:52:10 +0300 Subject: [PATCH 12/17] Use custom sidebar navigation layout --- app.py | 87 ++++++++++++++++++++++++++---------- tests/test_ui_consistency.py | 3 ++ 2 files changed, 66 insertions(+), 24 deletions(-) diff --git a/app.py b/app.py index f9051a69..e2aca93b 100644 --- a/app.py +++ b/app.py @@ -217,6 +217,15 @@ border: 1px solid rgba(255,255,255,0.08); } +.sidebar-section-label { + margin: 1rem 0 0.45rem 0; + color: #8FA3BF !important; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + /* Expander styling */ details[data-testid="stExpander"] summary { font-weight: 500 !important; @@ -282,11 +291,12 @@ # --- Sidebar branding --- with st.sidebar: st.segmented_control( - t("app.language"), + "language", options=list(SUPPORTED_LANGUAGES.keys()), format_func=lambda code: SUPPORTED_LANGUAGES[code], key="ui_language", selection_mode="single", + label_visibility="collapsed", ) license_state = st.session_state.get("license_state", {}) license_label = { @@ -378,6 +388,13 @@ def _render_project_sidebar(): ) st.error(f"Project load failed: {exc} (Error ID: {error_id})") + +def _render_sidebar_page_section(title: str, page_items: list[tuple], current_page) -> None: + """Render one grouped sidebar navigation section.""" + st.markdown(f'', unsafe_allow_html=True) + for page, label, icon in page_items: + st.page_link(page, label=label, icon=icon, disabled=(page == current_page)) + # --- Page imports --- from ui.components.history_tracker import render_history_sidebar from ui.about_page import render as about_render @@ -407,34 +424,56 @@ def _render_project_sidebar(): else: show_preview_tools = False +primary_pages = [ + (st.Page(home_render, title=t("nav.import"), icon="📂", default=True, url_path="import"), t("nav.import"), "📂"), + (st.Page(compare_render, title=t("nav.compare"), icon="🧪", url_path="compare"), t("nav.compare"), "🧪"), + (st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report"), t("nav.report"), "📝"), + (st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project"), t("nav.project"), "🗂️"), +] +analysis_pages = [ + (st.Page(dsc_render, title=t("nav.dsc"), icon="📈", url_path="dsc"), t("nav.dsc"), "📈"), + (st.Page(tga_render, title=t("nav.tga"), icon="📉", url_path="tga"), t("nav.tga"), "📉"), + (st.Page(dta_render, title=tx("DTA Analizi", "DTA Analysis"), icon="📊", url_path="dta"), tx("DTA Analizi", "DTA Analysis"), "📊"), + (st.Page(ftir_render, title=t("nav.ftir"), icon="🧬", url_path="ftir"), t("nav.ftir"), "🧬"), + (st.Page(raman_render, title=t("nav.raman"), icon="🔦", url_path="raman"), t("nav.raman"), "🔦"), + (st.Page(xrd_render, title=t("nav.xrd"), icon="🧿", url_path="xrd"), t("nav.xrd"), "🧿"), +] +management_pages = [ + (st.Page(library_render, title=tx("Kütüphane", "Library"), icon="🗃️", url_path="library"), tx("Kütüphane", "Library"), "🗃️"), + (st.Page(license_render, title=t("nav.license"), icon="🔐", url_path="license"), t("nav.license"), "🔐"), + (st.Page(about_render, title=t("nav.about"), icon="ℹ️", url_path="about"), t("nav.about"), "ℹ️"), +] + pages = { - t("nav.primary"): [ - st.Page(home_render, title=t("nav.import"), icon="📂", default=True, url_path="import"), - st.Page(compare_render, title=t("nav.compare"), icon="🧪", url_path="compare"), - st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report"), - st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project"), - ], - t("nav.analyses"): [ - st.Page(dsc_render, title=t("nav.dsc"), icon="📈", url_path="dsc"), - st.Page(tga_render, title=t("nav.tga"), icon="📉", url_path="tga"), - st.Page(dta_render, title=tx("DTA Analizi", "DTA Analysis"), icon="📊", url_path="dta"), - st.Page(ftir_render, title=t("nav.ftir"), icon="🧬", url_path="ftir"), - st.Page(raman_render, title=t("nav.raman"), icon="🔦", url_path="raman"), - st.Page(xrd_render, title=t("nav.xrd"), icon="🧿", url_path="xrd"), - ], - t("nav.management"): [ - st.Page(library_render, title=tx("Kütüphane", "Library"), icon="🗃️", url_path="library"), - st.Page(license_render, title=t("nav.license"), icon="🔐", url_path="license"), - st.Page(about_render, title=t("nav.about"), icon="ℹ️", url_path="about"), - ], + t("nav.primary"): [page for page, _, _ in primary_pages], + t("nav.analyses"): [page for page, _, _ in analysis_pages], + t("nav.management"): [page for page, _, _ in management_pages], } +preview_pages = [] if show_preview_tools: - pages[t("nav.preview")] = [ - st.Page(kinetics_render, title=tx("Kinetik Analiz (Deneysel)", "Kinetic Analysis (Experimental)"), icon="⚡", url_path="kinetics"), - st.Page(deconv_render, title=tx("Pik Dekonvolüsyonu (Deneysel)", "Peak Deconvolution (Experimental)"), icon="🔍", url_path="deconvolution"), + preview_pages = [ + ( + st.Page(kinetics_render, title=tx("Kinetik Analiz (Deneysel)", "Kinetic Analysis (Experimental)"), icon="⚡", url_path="kinetics"), + tx("Kinetik Analiz (Deneysel)", "Kinetic Analysis (Experimental)"), + "⚡", + ), + ( + st.Page(deconv_render, title=tx("Pik Dekonvolüsyonu (Deneysel)", "Peak Deconvolution (Experimental)"), icon="🔍", url_path="deconvolution"), + tx("Pik Dekonvolüsyonu (Deneysel)", "Peak Deconvolution (Experimental)"), + "🔍", + ), ] + pages[t("nav.preview")] = [page for page, _, _ in preview_pages] + +pg = st.navigation(pages, position="hidden") -pg = st.navigation(pages) +with st.sidebar: + _render_sidebar_page_section(t("nav.primary"), primary_pages, pg) + _render_sidebar_page_section(t("nav.analyses"), analysis_pages, pg) + _render_sidebar_page_section(t("nav.management"), management_pages, pg) + if preview_pages: + _render_sidebar_page_section(t("nav.preview"), preview_pages, pg) + st.markdown("---") # --- Pipeline history in sidebar --- with st.sidebar: diff --git a/tests/test_ui_consistency.py b/tests/test_ui_consistency.py index 748a82c6..51c60149 100644 --- a/tests/test_ui_consistency.py +++ b/tests/test_ui_consistency.py @@ -91,6 +91,9 @@ def test_sidebar_navigation_uses_grouped_scientific_structure(): assert 't("nav.primary")' in app_entry assert 't("nav.analyses")' in app_entry assert 't("nav.management")' in app_entry + assert 'st.navigation(pages, position="hidden")' in app_entry + assert '_render_sidebar_page_section(t("nav.primary"), primary_pages, pg)' in app_entry + assert 'label_visibility="collapsed"' in app_entry assert 'with st.expander(t("sidebar.project"), expanded=False):' in app_entry assert 'st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report")' in app_entry assert 'st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project")' in app_entry From b4c0e29f262667b0a33ac8e7e64fe9c40dc67bd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 15:59:47 +0300 Subject: [PATCH 13/17] Tighten sidebar header spacing --- app.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/app.py b/app.py index e2aca93b..5922f630 100644 --- a/app.py +++ b/app.py @@ -83,6 +83,13 @@ color: #E2E8F0 !important; border-right: 1px solid rgba(255,255,255,0.06); } +section[data-testid="stSidebar"] div[data-testid="stSidebarHeader"] { + padding-top: 0.15rem !important; + padding-bottom: 0.15rem !important; +} +section[data-testid="stSidebar"] div[data-testid="stSidebarUserContent"] { + padding-top: 0.2rem !important; +} section[data-testid="stSidebar"] * { color: #E2E8F0 !important; } @@ -91,6 +98,10 @@ section[data-testid="stSidebar"] .stSegmentedControl label { color: #A9B8CC !important; } +section[data-testid="stSidebar"] .stSegmentedControl { + margin-top: 0 !important; + margin-bottom: 0.35rem !important; +} section[data-testid="stSidebar"] hr { border-color: rgba(255,255,255,0.1) !important; } @@ -198,12 +209,18 @@ font-size: 1.05rem; letter-spacing: 0.12em; color: #FFFFFF !important; - padding: 8px 0 4px 0; + padding: 0.1rem 0 0.2rem 0; } .sidebar-version { font-size: 0.7rem; color: #94A3B8 !important; letter-spacing: 0.05em; + line-height: 1.35; +} +.sidebar-license { + font-size: 0.78rem; + color: #B8C5D6 !important; + margin-top: 0.28rem; } .sidebar-badge { display: inline-block; @@ -218,7 +235,7 @@ } .sidebar-section-label { - margin: 1rem 0 0.45rem 0; + margin: 0.5rem 0 0.35rem 0; color: #8FA3BF !important; font-size: 0.7rem; font-weight: 700; @@ -308,7 +325,8 @@ }.get(license_state.get("status"), t("app.license.development")) st.markdown( f'' - f'', + f'' + f'', unsafe_allow_html=True, ) n_datasets = len(st.session_state.get("datasets", {})) @@ -317,8 +335,6 @@ f'', unsafe_allow_html=True, ) - st.caption(f"License: {license_label}") - st.markdown("---") def _render_project_sidebar(): From ff1a2130cb1810d6e6928a741d2c44ef56b32200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 16:08:56 +0300 Subject: [PATCH 14/17] Move project actions into project workspace --- app.py | 81 +---------------- tests/test_ui_consistency.py | 12 ++- ui/project_page.py | 170 +++++++++++++++++++++++++---------- utils/i18n.py | 4 +- 4 files changed, 139 insertions(+), 128 deletions(-) diff --git a/app.py b/app.py index 5922f630..66383c5d 100644 --- a/app.py +++ b/app.py @@ -12,12 +12,11 @@ from dotenv import load_dotenv from core.reference_library import maybe_refresh_library_manifest -from core.project_io import PROJECT_EXTENSION, save_project_archive, load_project_archive -from utils.diagnostics import configure_diagnostics_logger, record_exception +from utils.diagnostics import configure_diagnostics_logger from utils.i18n import SUPPORTED_LANGUAGES, t, tx -from utils.license_manager import APP_VERSION, commercial_mode_enabled, license_allows_write, load_license_state +from utils.license_manager import APP_VERSION, commercial_mode_enabled, load_license_state from utils.runtime_flags import preview_modules_enabled -from utils.session_state import clear_project_state, ensure_session_state, replace_project_state +from utils.session_state import ensure_session_state load_dotenv(dotenv_path=Path(__file__).resolve().with_name(".env"), override=False) @@ -336,75 +335,6 @@ unsafe_allow_html=True, ) - -def _render_project_sidebar(): - """Render project save/load actions in the sidebar.""" - st.caption(t("sidebar.project.caption")) - - has_project_data = bool(st.session_state.get("datasets") or st.session_state.get("results")) - if st.button(t("sidebar.project.new"), key="project_new"): - clear_project_state() - st.rerun() - - if has_project_data: - can_write = license_allows_write(st.session_state.get("license_state")) - if st.button( - t("sidebar.project.prepare"), - key="project_prepare", - disabled=not can_write, - help="Build the archive first, then download it explicitly.", - ): - try: - st.session_state["project_archive_bytes"] = save_project_archive(st.session_state) - st.session_state["project_archive_ready"] = True - except Exception as exc: - error_id = record_exception( - st.session_state, - area="project_load", - action="project_prepare", - message="Preparing project archive failed.", - context={"dataset_count": len(st.session_state.get("datasets", {}))}, - exception=exc, - ) - st.error(f"Project archive preparation failed: {exc} (Error ID: {error_id})") - - if st.session_state.get("project_archive_ready") and st.session_state.get("project_archive_bytes"): - st.download_button( - t("sidebar.project.download"), - data=st.session_state["project_archive_bytes"], - file_name=f"materialscope_project{PROJECT_EXTENSION}", - mime="application/zip", - key="project_save", - on_click="ignore", - help="This button only appears after you explicitly prepare the archive.", - ) - else: - st.caption("No datasets or saved results yet.") - - uploaded_project = st.file_uploader( - t("sidebar.project.load"), - type=[PROJECT_EXTENSION.lstrip(".")], - key="project_loader", - help="Load a previously saved MaterialScope project archive.", - ) - if uploaded_project is not None and st.button(t("sidebar.project.load_selected"), key="project_load_btn"): - try: - project_state = load_project_archive(uploaded_project) - replace_project_state(project_state) - st.success("Project loaded.") - st.rerun() - except Exception as exc: - error_id = record_exception( - st.session_state, - area="project_load", - action="project_load", - message="Loading project archive failed.", - context={"file_name": getattr(uploaded_project, "name", "")}, - exception=exc, - ) - st.error(f"Project load failed: {exc} (Error ID: {error_id})") - - def _render_sidebar_page_section(title: str, page_items: list[tuple], current_page) -> None: """Render one grouped sidebar navigation section.""" st.markdown(f'', unsafe_allow_html=True) @@ -442,9 +372,9 @@ def _render_sidebar_page_section(title: str, page_items: list[tuple], current_pa primary_pages = [ (st.Page(home_render, title=t("nav.import"), icon="📂", default=True, url_path="import"), t("nav.import"), "📂"), + (st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project"), t("nav.project"), "🗂️"), (st.Page(compare_render, title=t("nav.compare"), icon="🧪", url_path="compare"), t("nav.compare"), "🧪"), (st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report"), t("nav.report"), "📝"), - (st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project"), t("nav.project"), "🗂️"), ] analysis_pages = [ (st.Page(dsc_render, title=t("nav.dsc"), icon="📈", url_path="dsc"), t("nav.dsc"), "📈"), @@ -493,9 +423,6 @@ def _render_sidebar_page_section(title: str, page_items: list[tuple], current_pa # --- Pipeline history in sidebar --- with st.sidebar: - with st.expander(t("sidebar.project"), expanded=False): - _render_project_sidebar() - st.markdown("---") with st.expander(t("sidebar.pipeline"), expanded=False): render_history_sidebar() diff --git a/tests/test_ui_consistency.py b/tests/test_ui_consistency.py index 51c60149..0d49ae55 100644 --- a/tests/test_ui_consistency.py +++ b/tests/test_ui_consistency.py @@ -61,6 +61,9 @@ def test_project_page_sidebar_hint_matches_sidebar_actions(): assert '"project.sidebar_hint"' in i18n assert "Proje Dosyasını Hazırla" in i18n assert "Load Selected Project" in i18n + assert 'overview_tab, actions_tab = st.tabs(' in project_page + assert 'key="project_prepare_page"' in project_page + assert 'key="project_load_btn_page"' in project_page def test_about_page_is_navigation_item_not_license_tab(): @@ -94,9 +97,12 @@ def test_sidebar_navigation_uses_grouped_scientific_structure(): assert 'st.navigation(pages, position="hidden")' in app_entry assert '_render_sidebar_page_section(t("nav.primary"), primary_pages, pg)' in app_entry assert 'label_visibility="collapsed"' in app_entry - assert 'with st.expander(t("sidebar.project"), expanded=False):' in app_entry - assert 'st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report")' in app_entry - assert 'st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project")' in app_entry + assert 'with st.expander(t("sidebar.project"), expanded=False):' not in app_entry + import_idx = app_entry.index('st.Page(home_render, title=t("nav.import"), icon="📂", default=True, url_path="import")') + project_idx = app_entry.index('st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project")') + compare_idx = app_entry.index('st.Page(compare_render, title=t("nav.compare"), icon="🧪", url_path="compare")') + report_idx = app_entry.index('st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report")') + assert import_idx < project_idx < compare_idx < report_idx assert '"nav.primary"' in i18n assert '"nav.analyses"' in i18n assert '"nav.management"' in i18n diff --git a/ui/project_page.py b/ui/project_page.py index 39c0c3cb..9433e4ca 100644 --- a/ui/project_page.py +++ b/ui/project_page.py @@ -5,9 +5,13 @@ import pandas as pd import streamlit as st +from core.project_io import PROJECT_EXTENSION, load_project_archive, save_project_archive from core.result_serialization import split_valid_results from ui.components.chrome import render_page_header +from utils.diagnostics import record_exception from utils.i18n import t +from utils.license_manager import license_allows_write +from utils.session_state import clear_project_state, replace_project_state def render(): @@ -25,51 +29,125 @@ def render(): m3.metric("Görsel" if lang == "tr" else "Figures", str(len(figures))) m4.metric("Geçmiş Adımı" if lang == "tr" else "History Steps", str(len(st.session_state.get("analysis_history", [])))) - if datasets: - st.subheader("Yüklenen Koşular" if lang == "tr" else "Loaded Runs") - dataset_rows = [] - for key, dataset in datasets.items(): - dataset_rows.append( - { - ("Anahtar" if lang == "tr" else "Key"): key, - ("Tip" if lang == "tr" else "Type"): dataset.data_type, - "Vendor": dataset.metadata.get("vendor", "Generic"), - ("Numune" if lang == "tr" else "Sample"): dataset.metadata.get("sample_name") or ("Adsız" if lang == "tr" else "Unnamed"), - ("Isıtma Hızı" if lang == "tr" else "Heating Rate"): dataset.metadata.get("heating_rate") or "—", - ("Nokta" if lang == "tr" else "Points"): len(dataset.data), - } - ) - st.dataframe(pd.DataFrame(dataset_rows), width="stretch", hide_index=True) - - if valid_results: - st.subheader("Kayıtlı Sonuç Kayıtları" if lang == "tr" else "Saved Result Records") - result_rows = [] - for record in valid_results.values(): - result_rows.append( - { - "ID": record["id"], - ("Tip" if lang == "tr" else "Type"): record["analysis_type"], - ("Durum" if lang == "tr" else "Status"): record["status"], - ("Veri Seti" if lang == "tr" else "Dataset"): record.get("dataset_key") or "—", - ("Satır" if lang == "tr" else "Rows"): len(record.get("rows", [])), - } + overview_tab, actions_tab = st.tabs( + [ + "Çalışma Alanı Özeti" if lang == "tr" else "Workspace Summary", + "Proje İşlemleri" if lang == "tr" else "Project Actions", + ] + ) + + with overview_tab: + if datasets: + st.subheader("Yüklenen Koşular" if lang == "tr" else "Loaded Runs") + dataset_rows = [] + for key, dataset in datasets.items(): + dataset_rows.append( + { + ("Anahtar" if lang == "tr" else "Key"): key, + ("Tip" if lang == "tr" else "Type"): dataset.data_type, + "Vendor": dataset.metadata.get("vendor", "Generic"), + ("Numune" if lang == "tr" else "Sample"): dataset.metadata.get("sample_name") or ("Adsız" if lang == "tr" else "Unnamed"), + ("Isıtma Hızı" if lang == "tr" else "Heating Rate"): dataset.metadata.get("heating_rate") or "—", + ("Nokta" if lang == "tr" else "Points"): len(dataset.data), + } + ) + st.dataframe(pd.DataFrame(dataset_rows), width="stretch", hide_index=True) + + if valid_results: + st.subheader("Kayıtlı Sonuç Kayıtları" if lang == "tr" else "Saved Result Records") + result_rows = [] + for record in valid_results.values(): + result_rows.append( + { + "ID": record["id"], + ("Tip" if lang == "tr" else "Type"): record["analysis_type"], + ("Durum" if lang == "tr" else "Status"): record["status"], + ("Veri Seti" if lang == "tr" else "Dataset"): record.get("dataset_key") or "—", + ("Satır" if lang == "tr" else "Rows"): len(record.get("rows", [])), + } + ) + st.dataframe(pd.DataFrame(result_rows), width="stretch", hide_index=True) + + if workspace.get("selected_datasets"): + st.subheader("Karşılaştırma Alanı" if lang == "tr" else "Comparison Workspace") + st.write(f"**{'Tip' if lang == 'tr' else 'Type'}:** {workspace.get('analysis_type', 'N/A')}") + st.write(f"**{'Seçili koşular' if lang == 'tr' else 'Selected runs'}:** {', '.join(workspace['selected_datasets'])}") + if workspace.get("figure_key"): + st.write(f"**{'Kaydedilen görsel' if lang == 'tr' else 'Saved figure'}:** {workspace['figure_key']}") + if workspace.get("notes"): + st.write(f"**{'Notlar' if lang == 'tr' else 'Notes'}**") + st.write(workspace["notes"]) + + if issues: + st.warning("Bazı sonuç kayıtları eksik; export sırasında atlanacak." if lang == "tr" else "Some result records are incomplete and will be skipped from exports.") + for issue in issues: + st.caption(f"- {issue}") + + with actions_tab: + st.info(t("project.sidebar_hint")) + action_col1, action_col2 = st.columns(2) + + with action_col1: + if st.button(t("sidebar.project.new"), key="project_new_page"): + clear_project_state() + st.rerun() + + has_project_data = bool(datasets or valid_results) + can_write = license_allows_write(st.session_state.get("license_state")) + if st.button( + t("sidebar.project.prepare"), + key="project_prepare_page", + disabled=not has_project_data or not can_write, + help="Build the archive first, then download it explicitly.", + ): + try: + st.session_state["project_archive_bytes"] = save_project_archive(st.session_state) + st.session_state["project_archive_ready"] = True + st.success("Proje arşivi hazırlandı." if lang == "tr" else "Project archive prepared.") + except Exception as exc: + error_id = record_exception( + st.session_state, + area="project_load", + action="project_prepare", + message="Preparing project archive failed.", + context={"dataset_count": len(datasets)}, + exception=exc, + ) + st.error(f"Project archive preparation failed: {exc} (Error ID: {error_id})") + + if st.session_state.get("project_archive_ready") and st.session_state.get("project_archive_bytes"): + st.download_button( + t("sidebar.project.download"), + data=st.session_state["project_archive_bytes"], + file_name=f"materialscope_project{PROJECT_EXTENSION}", + mime="application/zip", + key="project_save_page", + on_click="ignore", + ) + elif not has_project_data: + st.caption("Önce veri veya sonuç oluştur." if lang == "tr" else "Create datasets or results first.") + + with action_col2: + uploaded_project = st.file_uploader( + t("sidebar.project.load"), + type=[PROJECT_EXTENSION.lstrip(".")], + key="project_loader_page", + help="Load a previously saved MaterialScope project archive.", ) - st.dataframe(pd.DataFrame(result_rows), width="stretch", hide_index=True) - - if workspace.get("selected_datasets"): - st.subheader("Karşılaştırma Alanı" if lang == "tr" else "Comparison Workspace") - st.write(f"**{'Tip' if lang == 'tr' else 'Type'}:** {workspace.get('analysis_type', 'N/A')}") - st.write(f"**{'Seçili koşular' if lang == 'tr' else 'Selected runs'}:** {', '.join(workspace['selected_datasets'])}") - if workspace.get("figure_key"): - st.write(f"**{'Kaydedilen görsel' if lang == 'tr' else 'Saved figure'}:** {workspace['figure_key']}") - if workspace.get("notes"): - st.write(f"**{'Notlar' if lang == 'tr' else 'Notes'}**") - st.write(workspace["notes"]) - - if issues: - st.warning("Bazı sonuç kayıtları eksik; export sırasında atlanacak." if lang == "tr" else "Some result records are incomplete and will be skipped from exports.") - for issue in issues: - st.caption(f"- {issue}") - - st.info(t("project.sidebar_hint")) + if uploaded_project is not None and st.button(t("sidebar.project.load_selected"), key="project_load_btn_page"): + try: + project_state = load_project_archive(uploaded_project) + replace_project_state(project_state) + st.success("Project loaded." if lang != "tr" else "Proje yüklendi.") + st.rerun() + except Exception as exc: + error_id = record_exception( + st.session_state, + area="project_load", + action="project_load", + message="Loading project archive failed.", + context={"file_name": getattr(uploaded_project, "name", "")}, + exception=exc, + ) + st.error(f"Project load failed: {exc} (Error ID: {error_id})") diff --git a/utils/i18n.py b/utils/i18n.py index e54fc727..287c4be1 100644 --- a/utils/i18n.py +++ b/utils/i18n.py @@ -212,8 +212,8 @@ "en": "Session Management", }, "project.sidebar_hint": { - "tr": "Sidebar içindeki `Proje` panelinden `Yeni Proje`, `Proje Dosyasını Hazırla`, `Projeyi Yükle` ve `Seçili Projeyi Aç` eylemlerini kullan.", - "en": "Use the sidebar `Project` panel for `New Project`, `Prepare Project File`, `Load Project`, and `Load Selected Project` actions.", + "tr": "`Proje İşlemleri` sekmesinden `Yeni Proje`, `Proje Dosyasını Hazırla`, `Projeyi Yükle` ve `Seçili Projeyi Aç` eylemlerini kullan.", + "en": "Use the `Project Actions` tab for `New Project`, `Prepare Project File`, `Load Project`, and `Load Selected Project` actions.", }, "license.title": { "tr": "Lisans ve Marka", From c9569661f5451b6331475bf681d921f493dbd33a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 17:09:30 +0300 Subject: [PATCH 15/17] Refine sidebar header and stable nav styling --- app.py | 207 ++++++++++++++++++++++++++--------- tests/test_ui_consistency.py | 10 +- 2 files changed, 164 insertions(+), 53 deletions(-) diff --git a/app.py b/app.py index 66383c5d..ac759573 100644 --- a/app.py +++ b/app.py @@ -83,11 +83,14 @@ border-right: 1px solid rgba(255,255,255,0.06); } section[data-testid="stSidebar"] div[data-testid="stSidebarHeader"] { - padding-top: 0.15rem !important; - padding-bottom: 0.15rem !important; + padding-top: 0 !important; + padding-bottom: 0.05rem !important; } section[data-testid="stSidebar"] div[data-testid="stSidebarUserContent"] { - padding-top: 0.2rem !important; + padding-top: 0 !important; +} +section[data-testid="stSidebar"] div[data-testid="stSidebarUserContent"] > div:first-child { + margin-top: -0.55rem !important; } section[data-testid="stSidebar"] * { color: #E2E8F0 !important; @@ -99,7 +102,16 @@ } section[data-testid="stSidebar"] .stSegmentedControl { margin-top: 0 !important; - margin-bottom: 0.35rem !important; + margin-bottom: 0.2rem !important; +} +section[data-testid="stSidebar"] .stSegmentedControl [role="radiogroup"] { + gap: 0.18rem !important; +} +section[data-testid="stSidebar"] .stSegmentedControl [role="radio"] { + min-height: 1.9rem !important; + padding: 0.2rem 0.58rem !important; + font-size: 0.72rem !important; + border-radius: 8px !important; } section[data-testid="stSidebar"] hr { border-color: rgba(255,255,255,0.1) !important; @@ -203,23 +215,27 @@ /* Sidebar branding */ .sidebar-brand { - font-family: 'IBM Plex Sans', sans-serif; + font-family: 'IBM Plex Mono', 'IBM Plex Sans', sans-serif; font-weight: 700; - font-size: 1.05rem; - letter-spacing: 0.12em; + font-size: 1.28rem; + letter-spacing: 0.04em; color: #FFFFFF !important; - padding: 0.1rem 0 0.2rem 0; + padding: 0 0 0.08rem 0; + margin-top: -0.08rem; } .sidebar-version { - font-size: 0.7rem; + font-size: 0.72rem; color: #94A3B8 !important; - letter-spacing: 0.05em; - line-height: 1.35; + letter-spacing: 0.01em; + line-height: 1.42; + max-width: 15rem; + margin-bottom: 0.12rem; } .sidebar-license { - font-size: 0.78rem; - color: #B8C5D6 !important; - margin-top: 0.28rem; + font-size: 0.7rem; + color: #A7B6C9 !important; + margin-top: 0.18rem; + line-height: 1.25; } .sidebar-badge { display: inline-block; @@ -234,18 +250,71 @@ } .sidebar-section-label { - margin: 0.5rem 0 0.35rem 0; - color: #8FA3BF !important; - font-size: 0.7rem; + margin: 0.45rem 0 0.3rem 0; + color: #C9D4E3 !important; + font-size: 0.84rem; font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; + letter-spacing: 0.01em; + text-transform: none; +} + +.sidebar-nav-item-active { + border-left-color: rgba(148, 196, 215, 0.88) !important; + background: linear-gradient(90deg, rgba(148,196,215,0.11) 0%, rgba(148,196,215,0.03) 100%) !important; +} + +.sidebar-nav-link, +.sidebar-nav-item-active { + display: grid; + grid-template-columns: 1rem 1fr; + align-items: center; + column-gap: 0.6rem; + padding: 0.38rem 0.45rem 0.38rem 0.7rem; + margin: 0.06rem 0 0.16rem 0; + border-left: 2px solid transparent; + border-radius: 0 10px 10px 0; + text-decoration: none !important; + transition: background-color 0.14s ease, border-color 0.14s ease, color 0.14s ease; +} + +.sidebar-nav-link:hover { + background: rgba(255,255,255,0.04) !important; + border-left-color: rgba(201, 212, 227, 0.45) !important; +} + +.sidebar-nav-icon { + width: 1rem; + text-align: center; + font-size: 0.92rem; + opacity: 0.9; +} + +.sidebar-nav-label { + color: #DCE5F1 !important; + font-size: 0.92rem; + font-weight: 500; + line-height: 1.22; +} + +.sidebar-nav-item-active .sidebar-nav-label { + color: #F2F6FB !important; + font-weight: 600; } /* Expander styling */ details[data-testid="stExpander"] summary { font-weight: 500 !important; } +section[data-testid="stSidebar"] details[data-testid="stExpander"] { + border: 1px solid rgba(255,255,255,0.06) !important; + border-radius: 12px !important; + background: rgba(255,255,255,0.02) !important; + margin: 0.25rem 0 0.4rem 0 !important; +} +section[data-testid="stSidebar"] details[data-testid="stExpander"] summary { + padding-top: 0.2rem !important; + padding-bottom: 0.2rem !important; +} /* Dataframe styling */ div[data-testid="stDataFrame"] { @@ -306,14 +375,6 @@ # --- Sidebar branding --- with st.sidebar: - st.segmented_control( - "language", - options=list(SUPPORTED_LANGUAGES.keys()), - format_func=lambda code: SUPPORTED_LANGUAGES[code], - key="ui_language", - selection_mode="single", - label_visibility="collapsed", - ) license_state = st.session_state.get("license_state", {}) license_label = { "development": t("app.license.development"), @@ -325,9 +386,21 @@ st.markdown( f'' f'' - f'', + , unsafe_allow_html=True, ) + header_meta_col, header_lang_col = st.columns([1.15, 0.95], gap="small") + with header_meta_col: + st.markdown(f'', unsafe_allow_html=True) + with header_lang_col: + st.segmented_control( + "language", + options=list(SUPPORTED_LANGUAGES.keys()), + format_func=lambda code: SUPPORTED_LANGUAGES[code], + key="ui_language", + selection_mode="single", + label_visibility="collapsed", + ) n_datasets = len(st.session_state.get("datasets", {})) if n_datasets > 0: st.markdown( @@ -335,11 +408,38 @@ unsafe_allow_html=True, ) -def _render_sidebar_page_section(title: str, page_items: list[tuple], current_page) -> None: +def _render_sidebar_page_section(title: str, page_items: list[tuple], current_path: str, *, collapsible: bool = False, expanded: bool = True) -> None: """Render one grouped sidebar navigation section.""" + def _render_items() -> None: + for _, label, icon, path in page_items: + if path == current_path: + st.markdown( + ( + '" + ), + unsafe_allow_html=True, + ) + else: + st.markdown( + ( + f'' + f'{icon}' + f'{label}' + "" + ), + unsafe_allow_html=True, + ) + + if collapsible: + with st.expander(title, expanded=expanded): + _render_items() + return + st.markdown(f'', unsafe_allow_html=True) - for page, label, icon in page_items: - st.page_link(page, label=label, icon=icon, disabled=(page == current_page)) + _render_items() # --- Page imports --- from ui.components.history_tracker import render_history_sidebar @@ -371,29 +471,29 @@ def _render_sidebar_page_section(title: str, page_items: list[tuple], current_pa show_preview_tools = False primary_pages = [ - (st.Page(home_render, title=t("nav.import"), icon="📂", default=True, url_path="import"), t("nav.import"), "📂"), - (st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project"), t("nav.project"), "🗂️"), - (st.Page(compare_render, title=t("nav.compare"), icon="🧪", url_path="compare"), t("nav.compare"), "🧪"), - (st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report"), t("nav.report"), "📝"), + (st.Page(home_render, title=t("nav.import"), icon="📂", default=True, url_path="import"), t("nav.import"), "📂", "import"), + (st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project"), t("nav.project"), "🗂️", "project"), + (st.Page(compare_render, title=t("nav.compare"), icon="🧪", url_path="compare"), t("nav.compare"), "🧪", "compare"), + (st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report"), t("nav.report"), "📝", "report"), ] analysis_pages = [ - (st.Page(dsc_render, title=t("nav.dsc"), icon="📈", url_path="dsc"), t("nav.dsc"), "📈"), - (st.Page(tga_render, title=t("nav.tga"), icon="📉", url_path="tga"), t("nav.tga"), "📉"), - (st.Page(dta_render, title=tx("DTA Analizi", "DTA Analysis"), icon="📊", url_path="dta"), tx("DTA Analizi", "DTA Analysis"), "📊"), - (st.Page(ftir_render, title=t("nav.ftir"), icon="🧬", url_path="ftir"), t("nav.ftir"), "🧬"), - (st.Page(raman_render, title=t("nav.raman"), icon="🔦", url_path="raman"), t("nav.raman"), "🔦"), - (st.Page(xrd_render, title=t("nav.xrd"), icon="🧿", url_path="xrd"), t("nav.xrd"), "🧿"), + (st.Page(dsc_render, title=t("nav.dsc"), icon="📈", url_path="dsc"), t("nav.dsc"), "📈", "dsc"), + (st.Page(tga_render, title=t("nav.tga"), icon="📉", url_path="tga"), t("nav.tga"), "📉", "tga"), + (st.Page(dta_render, title=tx("DTA Analizi", "DTA Analysis"), icon="📊", url_path="dta"), tx("DTA Analizi", "DTA Analysis"), "📊", "dta"), + (st.Page(ftir_render, title=t("nav.ftir"), icon="🧬", url_path="ftir"), t("nav.ftir"), "🧬", "ftir"), + (st.Page(raman_render, title=t("nav.raman"), icon="🔦", url_path="raman"), t("nav.raman"), "🔦", "raman"), + (st.Page(xrd_render, title=t("nav.xrd"), icon="🧿", url_path="xrd"), t("nav.xrd"), "🧿", "xrd"), ] management_pages = [ - (st.Page(library_render, title=tx("Kütüphane", "Library"), icon="🗃️", url_path="library"), tx("Kütüphane", "Library"), "🗃️"), - (st.Page(license_render, title=t("nav.license"), icon="🔐", url_path="license"), t("nav.license"), "🔐"), - (st.Page(about_render, title=t("nav.about"), icon="ℹ️", url_path="about"), t("nav.about"), "ℹ️"), + (st.Page(library_render, title=tx("Kütüphane", "Library"), icon="🗃️", url_path="library"), tx("Kütüphane", "Library"), "🗃️", "library"), + (st.Page(license_render, title=t("nav.license"), icon="🔐", url_path="license"), t("nav.license"), "🔐", "license"), + (st.Page(about_render, title=t("nav.about"), icon="ℹ️", url_path="about"), t("nav.about"), "ℹ️", "about"), ] pages = { - t("nav.primary"): [page for page, _, _ in primary_pages], - t("nav.analyses"): [page for page, _, _ in analysis_pages], - t("nav.management"): [page for page, _, _ in management_pages], + t("nav.primary"): [page for page, _, _, _ in primary_pages], + t("nav.analyses"): [page for page, _, _, _ in analysis_pages], + t("nav.management"): [page for page, _, _, _ in management_pages], } preview_pages = [] if show_preview_tools: @@ -402,23 +502,26 @@ def _render_sidebar_page_section(title: str, page_items: list[tuple], current_pa st.Page(kinetics_render, title=tx("Kinetik Analiz (Deneysel)", "Kinetic Analysis (Experimental)"), icon="⚡", url_path="kinetics"), tx("Kinetik Analiz (Deneysel)", "Kinetic Analysis (Experimental)"), "⚡", + "kinetics", ), ( st.Page(deconv_render, title=tx("Pik Dekonvolüsyonu (Deneysel)", "Peak Deconvolution (Experimental)"), icon="🔍", url_path="deconvolution"), tx("Pik Dekonvolüsyonu (Deneysel)", "Peak Deconvolution (Experimental)"), "🔍", + "deconvolution", ), ] - pages[t("nav.preview")] = [page for page, _, _ in preview_pages] + pages[t("nav.preview")] = [page for page, _, _, _ in preview_pages] pg = st.navigation(pages, position="hidden") +current_path = next((path for page, _, _, path in (primary_pages + analysis_pages + management_pages + preview_pages) if page == pg), "") with st.sidebar: - _render_sidebar_page_section(t("nav.primary"), primary_pages, pg) - _render_sidebar_page_section(t("nav.analyses"), analysis_pages, pg) - _render_sidebar_page_section(t("nav.management"), management_pages, pg) + _render_sidebar_page_section(t("nav.primary"), primary_pages, current_path) + _render_sidebar_page_section(t("nav.analyses"), analysis_pages, current_path) + _render_sidebar_page_section(t("nav.management"), management_pages, current_path) if preview_pages: - _render_sidebar_page_section(t("nav.preview"), preview_pages, pg) + _render_sidebar_page_section(t("nav.preview"), preview_pages, current_path, collapsible=True, expanded=False) st.markdown("---") # --- Pipeline history in sidebar --- diff --git a/tests/test_ui_consistency.py b/tests/test_ui_consistency.py index 0d49ae55..38e0f9ea 100644 --- a/tests/test_ui_consistency.py +++ b/tests/test_ui_consistency.py @@ -95,14 +95,22 @@ def test_sidebar_navigation_uses_grouped_scientific_structure(): assert 't("nav.analyses")' in app_entry assert 't("nav.management")' in app_entry assert 'st.navigation(pages, position="hidden")' in app_entry - assert '_render_sidebar_page_section(t("nav.primary"), primary_pages, pg)' in app_entry + assert '_render_sidebar_page_section(t("nav.primary"), primary_pages, current_path)' in app_entry assert 'label_visibility="collapsed"' in app_entry + assert 'header_meta_col, header_lang_col = st.columns([1.15, 0.95], gap="small")' in app_entry + assert '_render_sidebar_page_section(t("nav.analyses"), analysis_pages, current_path)' in app_entry + analyses_call_idx = app_entry.index('_render_sidebar_page_section(t("nav.analyses"), analysis_pages, current_path)') + assert 'collapsible=True' not in app_entry[max(0, analyses_call_idx - 40): analyses_call_idx + 120] assert 'with st.expander(t("sidebar.project"), expanded=False):' not in app_entry import_idx = app_entry.index('st.Page(home_render, title=t("nav.import"), icon="📂", default=True, url_path="import")') project_idx = app_entry.index('st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project")') compare_idx = app_entry.index('st.Page(compare_render, title=t("nav.compare"), icon="🧪", url_path="compare")') report_idx = app_entry.index('st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report")') assert import_idx < project_idx < compare_idx < report_idx + brand_idx = app_entry.index('f\'\'') + license_idx = app_entry.index('f\'\'') + segmented_idx = app_entry.index('st.segmented_control(') + assert brand_idx < license_idx < segmented_idx assert '"nav.primary"' in i18n assert '"nav.analyses"' in i18n assert '"nav.management"' in i18n From d9d4e28c98362cc7562c9e78f2e14d9929af0b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 17:48:11 +0300 Subject: [PATCH 16/17] Redesign project workspace dashboard actions --- app.py | 82 ++++------ tests/test_ui_consistency.py | 6 + ui/project_page.py | 298 ++++++++++++++++++++++++++--------- 3 files changed, 264 insertions(+), 122 deletions(-) diff --git a/app.py b/app.py index ac759573..37ad65eb 100644 --- a/app.py +++ b/app.py @@ -262,43 +262,44 @@ border-left-color: rgba(148, 196, 215, 0.88) !important; background: linear-gradient(90deg, rgba(148,196,215,0.11) 0%, rgba(148,196,215,0.03) 100%) !important; } - -.sidebar-nav-link, -.sidebar-nav-item-active { - display: grid; - grid-template-columns: 1rem 1fr; - align-items: center; - column-gap: 0.6rem; - padding: 0.38rem 0.45rem 0.38rem 0.7rem; - margin: 0.06rem 0 0.16rem 0; - border-left: 2px solid transparent; - border-radius: 0 10px 10px 0; +section[data-testid="stSidebar"] div[data-testid="stPageLink-NavLink"] { + margin: 0.06rem 0 0.16rem 0 !important; +} +section[data-testid="stSidebar"] div[data-testid="stPageLink-NavLink"] a, +section[data-testid="stSidebar"] div[data-testid="stPageLink-NavLink"] span[aria-disabled="true"] { + display: grid !important; + grid-template-columns: 1rem 1fr !important; + align-items: center !important; + column-gap: 0.6rem !important; + padding: 0.38rem 0.45rem 0.38rem 0.7rem !important; + margin: 0 !important; + border-left: 2px solid transparent !important; + border-radius: 0 10px 10px 0 !important; text-decoration: none !important; - transition: background-color 0.14s ease, border-color 0.14s ease, color 0.14s ease; + transition: background-color 0.14s ease, border-color 0.14s ease, color 0.14s ease !important; + min-height: 2.1rem !important; } - -.sidebar-nav-link:hover { +section[data-testid="stSidebar"] div[data-testid="stPageLink-NavLink"] a:hover { background: rgba(255,255,255,0.04) !important; border-left-color: rgba(201, 212, 227, 0.45) !important; } - -.sidebar-nav-icon { - width: 1rem; - text-align: center; - font-size: 0.92rem; - opacity: 0.9; -} - -.sidebar-nav-label { +section[data-testid="stSidebar"] div[data-testid="stPageLink-NavLink"] a p, +section[data-testid="stSidebar"] div[data-testid="stPageLink-NavLink"] span[aria-disabled="true"] p { color: #DCE5F1 !important; - font-size: 0.92rem; - font-weight: 500; - line-height: 1.22; + font-size: 0.92rem !important; + font-weight: 500 !important; + line-height: 1.22 !important; + margin: 0 !important; } - -.sidebar-nav-item-active .sidebar-nav-label { +section[data-testid="stSidebar"] div[data-testid="stPageLink-NavLink"] a[data-current-page="true"], +section[data-testid="stSidebar"] div[data-testid="stPageLink-NavLink"] span[aria-disabled="true"] { + border-left-color: rgba(148, 196, 215, 0.88) !important; + background: linear-gradient(90deg, rgba(148,196,215,0.11) 0%, rgba(148,196,215,0.03) 100%) !important; +} +section[data-testid="stSidebar"] div[data-testid="stPageLink-NavLink"] a[data-current-page="true"] p, +section[data-testid="stSidebar"] div[data-testid="stPageLink-NavLink"] span[aria-disabled="true"] p { color: #F2F6FB !important; - font-weight: 600; + font-weight: 600 !important; } /* Expander styling */ @@ -411,27 +412,8 @@ def _render_sidebar_page_section(title: str, page_items: list[tuple], current_path: str, *, collapsible: bool = False, expanded: bool = True) -> None: """Render one grouped sidebar navigation section.""" def _render_items() -> None: - for _, label, icon, path in page_items: - if path == current_path: - st.markdown( - ( - '" - ), - unsafe_allow_html=True, - ) - else: - st.markdown( - ( - f'' - f'{icon}' - f'{label}' - "" - ), - unsafe_allow_html=True, - ) + for page, label, icon, path in page_items: + st.page_link(page, label=label, icon=icon, disabled=(path == current_path)) if collapsible: with st.expander(title, expanded=expanded): diff --git a/tests/test_ui_consistency.py b/tests/test_ui_consistency.py index 38e0f9ea..ec25c5cd 100644 --- a/tests/test_ui_consistency.py +++ b/tests/test_ui_consistency.py @@ -64,6 +64,12 @@ def test_project_page_sidebar_hint_matches_sidebar_actions(): assert 'overview_tab, actions_tab = st.tabs(' in project_page assert 'key="project_prepare_page"' in project_page assert 'key="project_load_btn_page"' in project_page + assert 'action_cards_col, upload_panel_col = st.columns([1.6, 1.0], gap="large")' in project_page + assert 'st.subheader(_tx(lang, "Hızlı İşlemler", "Quick Actions"))' in project_page + assert 'st.subheader(_tx(lang, "Proje Yükle", "Load Project"))' in project_page + assert 'status_lines = [' in project_page + assert 'st.session_state["project_confirm_clear"] = True' in project_page + assert 'st.session_state["project_confirm_load"] = True' in project_page def test_about_page_is_navigation_item_not_license_tab(): diff --git a/ui/project_page.py b/ui/project_page.py index 9433e4ca..529a0580 100644 --- a/ui/project_page.py +++ b/ui/project_page.py @@ -1,7 +1,9 @@ -"""Project workspace overview page.""" +"""Project workspace overview page.""" from __future__ import annotations +import io + import pandas as pd import streamlit as st @@ -14,6 +16,56 @@ from utils.session_state import clear_project_state, replace_project_state +def _tx(lang: str, tr: str, en: str) -> str: + return tr if lang == "tr" else en + + +def _prepare_project_archive(lang: str, datasets: dict) -> bool: + try: + st.session_state["project_archive_bytes"] = save_project_archive(st.session_state) + st.session_state["project_archive_ready"] = True + st.success(_tx(lang, "Proje arşivi hazırlandı.", "Project archive prepared.")) + return True + except Exception as exc: + error_id = record_exception( + st.session_state, + area="project_load", + action="project_prepare", + message="Preparing project archive failed.", + context={"dataset_count": len(datasets)}, + exception=exc, + ) + st.error(f"Project archive preparation failed: {exc} (Error ID: {error_id})") + return False + + +def _load_project_from_session_payload(lang: str) -> None: + payload = st.session_state.get("project_pending_upload_bytes") + file_name = st.session_state.get("project_pending_upload_name", f"project{PROJECT_EXTENSION}") + if not payload: + return + try: + buffer = io.BytesIO(payload) + buffer.name = file_name + project_state = load_project_archive(buffer) + replace_project_state(project_state) + st.session_state.pop("project_confirm_load", None) + st.session_state.pop("project_pending_upload_bytes", None) + st.session_state.pop("project_pending_upload_name", None) + st.success(_tx(lang, "Proje yüklendi.", "Project loaded.")) + st.rerun() + except Exception as exc: + error_id = record_exception( + st.session_state, + area="project_load", + action="project_load", + message="Loading project archive failed.", + context={"file_name": file_name}, + exception=exc, + ) + st.error(f"Project load failed: {exc} (Error ID: {error_id})") + + def render(): render_page_header(t("project.title"), t("project.caption"), badge=t("project.hero_badge")) lang = st.session_state.get("ui_language", "tr") @@ -22,12 +74,166 @@ def render(): valid_results, issues = split_valid_results(st.session_state.get("results", {})) figures = st.session_state.get("figures", {}) or {} workspace = st.session_state.get("comparison_workspace", {}) or {} + history_steps = st.session_state.get("analysis_history", []) or [] + + archive_ready = bool(st.session_state.get("project_archive_ready") and st.session_state.get("project_archive_bytes")) + has_workspace_content = bool(datasets or valid_results or figures or workspace.get("selected_datasets") or history_steps) + has_project_artifacts = bool(valid_results or figures or history_steps) + can_write = license_allows_write(st.session_state.get("license_state")) m1, m2, m3, m4 = st.columns(4) - m1.metric("Veri Seti" if lang == "tr" else "Datasets", str(len(datasets))) - m2.metric("Kayıtlı Sonuç" if lang == "tr" else "Saved Results", str(len(valid_results))) - m3.metric("Görsel" if lang == "tr" else "Figures", str(len(figures))) - m4.metric("Geçmiş Adımı" if lang == "tr" else "History Steps", str(len(st.session_state.get("analysis_history", [])))) + m1.metric(_tx(lang, "Veri Seti", "Datasets"), str(len(datasets))) + m2.metric(_tx(lang, "Kayıtlı Sonuç", "Saved Results"), str(len(valid_results))) + m3.metric(_tx(lang, "Görsel", "Figures"), str(len(figures))) + m4.metric(_tx(lang, "Arşiv", "Archive"), _tx(lang, "Hazır" if archive_ready else "Bekliyor", "Ready" if archive_ready else "Pending")) + + action_cards_col, upload_panel_col = st.columns([1.6, 1.0], gap="large") + + with action_cards_col: + st.subheader(_tx(lang, "Hızlı İşlemler", "Quick Actions")) + new_card_col, prepare_card_col, download_card_col = st.columns(3, gap="small") + + with new_card_col: + with st.container(border=True): + st.caption(_tx(lang, "Mevcut çalışma alanını sıfırdan başlat.", "Start a fresh workspace.")) + if st.button(t("sidebar.project.new"), key="project_new_page", use_container_width=True): + if has_workspace_content: + st.session_state["project_confirm_clear"] = True + else: + clear_project_state() + st.rerun() + + with prepare_card_col: + with st.container(border=True): + st.caption( + _tx( + lang, + "En az bir analiz sonucu varsa indirilebilir arşiv hazırla.", + "Prepare a downloadable archive once at least one analysis result exists.", + ) + ) + if st.button( + t("sidebar.project.prepare"), + key="project_prepare_page", + disabled=not has_project_artifacts or not can_write, + use_container_width=True, + help=_tx( + lang, + "En az bir analiz sonucu oluştuktan sonra arşiv hazırlanabilir.", + "An archive can be prepared after at least one analysis result exists.", + ), + ): + _prepare_project_archive(lang, datasets) + + with download_card_col: + with st.container(border=True): + st.caption(_tx(lang, "Hazır arşivi indir ve oturumu dışa aktar.", "Download the prepared archive and export the session.")) + st.download_button( + t("sidebar.project.download"), + data=st.session_state.get("project_archive_bytes") or b"", + file_name=f"materialscope_project{PROJECT_EXTENSION}", + mime="application/zip", + key="project_save_page", + on_click="ignore", + disabled=not archive_ready, + use_container_width=True, + ) + + with st.container(border=True): + st.markdown(f"**{_tx(lang, 'Durum', 'Status')}**") + status_lines = [ + (_tx(lang, "Çalışma alanı", "Workspace"), _tx(lang, "Aktif" if has_workspace_content else "Boş", "Active" if has_workspace_content else "Empty")), + (_tx(lang, "Kaydedilmiş sonuçlar", "Saved results"), str(len(valid_results))), + (_tx(lang, "Karşılaştırma alanı", "Compare workspace"), _tx(lang, "Hazır" if workspace.get("selected_datasets") else "Boş", "Ready" if workspace.get("selected_datasets") else "Empty")), + (_tx(lang, "Arşiv durumu", "Archive status"), _tx(lang, "Hazır" if archive_ready else "Hazır değil", "Ready" if archive_ready else "Not ready")), + ] + for label, value in status_lines: + st.markdown(f"**{label}:** {value}") + + next_step_message = ( + _tx(lang, "Önce Veri Al sayfasından koşu yükle.", "Start by loading runs from Import Runs.") + if not datasets + else _tx(lang, "Sıradaki doğru adım: analiz sayfalarından en az bir sonucu kaydet.", "Next step: save at least one result from the analysis pages.") + if not valid_results + else _tx(lang, "Sıradaki doğru adım: Karşılaştırma Alanı'nda koşuları eşleştir.", "Next step: align runs in the Compare Workspace.") + if not workspace.get("selected_datasets") + else _tx(lang, "Sıradaki doğru adım: Rapor Merkezi'nde çıktı paketini hazırla.", "Next step: prepare the output package in Report Center.") + ) + st.info(next_step_message) + + if st.session_state.get("project_confirm_clear"): + with st.container(border=True): + st.warning( + _tx( + lang, + "Mevcut çalışma alanı üzerine yazılacak. İstersen önce arşiv hazırla, sonra temizle.", + "The current workspace will be overwritten. Prepare an archive first if you need to keep it.", + ) + ) + clear_action_col, save_first_col, cancel_clear_col = st.columns(3, gap="small") + with clear_action_col: + if st.button(_tx(lang, "Kaydetmeden Temizle", "Clear Without Saving"), key="project_clear_confirm", use_container_width=True): + st.session_state.pop("project_confirm_clear", None) + clear_project_state() + st.rerun() + with save_first_col: + if st.button( + _tx(lang, "Önce Arşivi Hazırla", "Prepare Archive First"), + key="project_prepare_before_clear", + disabled=not has_project_artifacts or not can_write, + use_container_width=True, + ): + _prepare_project_archive(lang, datasets) + with cancel_clear_col: + if st.button(_tx(lang, "Vazgeç", "Cancel"), key="project_clear_cancel", use_container_width=True): + st.session_state.pop("project_confirm_clear", None) + st.rerun() + + with upload_panel_col: + with st.container(border=True): + st.subheader(_tx(lang, "Proje Yükle", "Load Project")) + st.caption( + _tx( + lang, + "Arşivi sürükle-bırak ya da seç, sonra mevcut çalışma alanına uygula.", + "Drag and drop an archive or browse for it, then apply it to the current workspace.", + ) + ) + uploaded_project = st.file_uploader( + t("sidebar.project.load"), + type=[PROJECT_EXTENSION.lstrip(".")], + key="project_loader_page", + help="Load a previously saved MaterialScope project archive.", + ) + incoming_name = getattr(uploaded_project, "name", None) or _tx(lang, "Dosya seçilmedi", "No file selected") + st.markdown(f"**{_tx(lang, 'Seçilen arşiv', 'Selected archive')}:** {incoming_name}") + if st.button(t("sidebar.project.load_selected"), key="project_load_btn_page", disabled=uploaded_project is None, use_container_width=True): + if uploaded_project is not None: + st.session_state["project_pending_upload_bytes"] = uploaded_project.getvalue() + st.session_state["project_pending_upload_name"] = uploaded_project.name + if has_workspace_content: + st.session_state["project_confirm_load"] = True + else: + _load_project_from_session_payload(lang) + + if st.session_state.get("project_confirm_load"): + st.warning( + _tx( + lang, + "Seçili arşiv mevcut çalışma alanının üzerine yüklenecek.", + "The selected archive will replace the current workspace.", + ) + ) + load_confirm_col, load_cancel_col = st.columns(2, gap="small") + with load_confirm_col: + if st.button(_tx(lang, "Yüklemeye Devam Et", "Continue Loading"), key="project_load_confirm", use_container_width=True): + _load_project_from_session_payload(lang) + with load_cancel_col: + if st.button(_tx(lang, "İptal", "Cancel"), key="project_load_cancel", use_container_width=True): + st.session_state.pop("project_confirm_load", None) + st.session_state.pop("project_pending_upload_bytes", None) + st.session_state.pop("project_pending_upload_name", None) + st.rerun() overview_tab, actions_tab = st.tabs( [ @@ -79,75 +285,23 @@ def render(): st.write(workspace["notes"]) if issues: - st.warning("Bazı sonuç kayıtları eksik; export sırasında atlanacak." if lang == "tr" else "Some result records are incomplete and will be skipped from exports.") + st.warning( + "Bazı sonuç kayıtları eksik; export sırasında atlanacak." + if lang == "tr" + else "Some result records are incomplete and will be skipped from exports." + ) for issue in issues: st.caption(f"- {issue}") with actions_tab: st.info(t("project.sidebar_hint")) - action_col1, action_col2 = st.columns(2) - - with action_col1: - if st.button(t("sidebar.project.new"), key="project_new_page"): - clear_project_state() - st.rerun() - - has_project_data = bool(datasets or valid_results) - can_write = license_allows_write(st.session_state.get("license_state")) - if st.button( - t("sidebar.project.prepare"), - key="project_prepare_page", - disabled=not has_project_data or not can_write, - help="Build the archive first, then download it explicitly.", - ): - try: - st.session_state["project_archive_bytes"] = save_project_archive(st.session_state) - st.session_state["project_archive_ready"] = True - st.success("Proje arşivi hazırlandı." if lang == "tr" else "Project archive prepared.") - except Exception as exc: - error_id = record_exception( - st.session_state, - area="project_load", - action="project_prepare", - message="Preparing project archive failed.", - context={"dataset_count": len(datasets)}, - exception=exc, - ) - st.error(f"Project archive preparation failed: {exc} (Error ID: {error_id})") - - if st.session_state.get("project_archive_ready") and st.session_state.get("project_archive_bytes"): - st.download_button( - t("sidebar.project.download"), - data=st.session_state["project_archive_bytes"], - file_name=f"materialscope_project{PROJECT_EXTENSION}", - mime="application/zip", - key="project_save_page", - on_click="ignore", - ) - elif not has_project_data: - st.caption("Önce veri veya sonuç oluştur." if lang == "tr" else "Create datasets or results first.") - - with action_col2: - uploaded_project = st.file_uploader( - t("sidebar.project.load"), - type=[PROJECT_EXTENSION.lstrip(".")], - key="project_loader_page", - help="Load a previously saved MaterialScope project archive.", - ) - if uploaded_project is not None and st.button(t("sidebar.project.load_selected"), key="project_load_btn_page"): - try: - project_state = load_project_archive(uploaded_project) - replace_project_state(project_state) - st.success("Project loaded." if lang != "tr" else "Proje yüklendi.") - st.rerun() - except Exception as exc: - error_id = record_exception( - st.session_state, - area="project_load", - action="project_load", - message="Loading project archive failed.", - context={"file_name": getattr(uploaded_project, "name", "")}, - exception=exc, - ) - st.error(f"Project load failed: {exc} (Error ID: {error_id})") - + st.markdown(f"**{_tx(lang, 'Operasyon Notları', 'Operational Notes')}**") + st.markdown( + f"- {_tx(lang, 'Arşiv hazırlama yalnızca en az bir analiz sonucu üretildiğinde aktif olur.', 'Archive preparation activates only after at least one analysis result exists.')}" + ) + st.markdown( + f"- {_tx(lang, 'Yükleme işlemi mevcut çalışma alanını değiştireceği için önce açık bir onay ister.', 'Loading asks for confirmation before replacing the current workspace.')}" + ) + st.markdown( + f"- {_tx(lang, 'Rapor Merkezi ve Karşılaştırma Alanı için sıradaki adım önerisi üstteki dashboard üzerinde gösterilir.', 'The next recommended action for Compare Workspace and Report Center is shown in the dashboard above.')}" + ) From 1226653fb6520db9b2b2a92d034fe62c727f7c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Utku=20=C5=9EAH=C4=B0N?= Date: Sun, 12 Apr 2026 20:49:46 +0300 Subject: [PATCH 17/17] Refactor theme system and sidebar header layout - Add _theme_tokens() for dynamic light/dark theme CSS variables - Fix light theme: softer backgrounds, better contrast, no pure-white panels - Fix dark theme: file uploader, inputs, expanders use dark surfaces - Restructure sidebar header: brand/tagline/license on top, controls row below - Add theme toggle (light/dark) via segmented control in sidebar - Update plot_builder to respect theme tokens for chart colors - Add get_ui_theme() to session_state for theme persistence - Update test_ui_consistency for new sidebar structure --- app.py | 618 +++++++++++++++++++++++++++++----- tests/test_ui_consistency.py | 3 + ui/components/plot_builder.py | 252 ++++++++------ utils/session_state.py | 10 + 4 files changed, 694 insertions(+), 189 deletions(-) diff --git a/app.py b/app.py index 37ad65eb..9cfa3af2 100644 --- a/app.py +++ b/app.py @@ -14,9 +14,13 @@ from core.reference_library import maybe_refresh_library_manifest from utils.diagnostics import configure_diagnostics_logger from utils.i18n import SUPPORTED_LANGUAGES, t, tx -from utils.license_manager import APP_VERSION, commercial_mode_enabled, load_license_state +from utils.license_manager import ( + APP_VERSION, + commercial_mode_enabled, + load_license_state, +) from utils.runtime_flags import preview_modules_enabled -from utils.session_state import ensure_session_state +from utils.session_state import ensure_session_state, get_ui_theme load_dotenv(dotenv_path=Path(__file__).resolve().with_name(".env"), override=False) @@ -27,7 +31,94 @@ initial_sidebar_state="expanded", ) + +def _theme_tokens(theme_mode: str) -> dict[str, str]: + if theme_mode == "dark": + return { + "ink": "#E5EEF8", + "muted": "#B8C6D8", + "border": "#38506B", + "panel": "rgba(17,24,39,0.84)", + "panel_strong": "#121A2C", + "accent": "#1597A8", + "accent_strong": "#0F6E86", + "gold": "#F59E0B", + "bg_top": "#0A1422", + "bg_bottom": "#132235", + "sidebar_top": "#020817", + "sidebar_bottom": "#0D1727", + "sidebar_text": "#E2E8F0", + "sidebar_muted": "#9FB0C7", + "metric_bg": "linear-gradient(180deg, rgba(21,30,48,0.92) 0%, rgba(17,24,39,0.88) 100%)", + "tab_bg": "rgba(17,24,39,0.76)", + "tab_text": "#D2DEED", + "tab_active": "rgba(21,151,168,0.24)", + "field_bg": "rgba(19,31,49,0.76)", + "field_gloss": "rgba(255,255,255,0.07)", + "status_bg": "rgba(17,24,39,0.88)", + "status_text": "#D5E1F0", + "disabled_bg": "#314359", + "disabled_ink": "#D6E0EB", + "input_bg": "rgba(18,29,46,0.92)", + "input_border": "#4A607B", + "hero_start": "rgba(16,24,38,0.97)", + "hero_overlay": "rgba(245,158,11,0.14)", + "hero_end": "rgba(35,31,34,0.92)", + "shadow": "rgba(2,6,23,0.36)", + "alert_info_bg": "rgba(8,145,178,0.18)", + "alert_info_border": "rgba(34,211,238,0.26)", + "alert_warning_bg": "rgba(217,119,6,0.18)", + "alert_warning_border": "rgba(251,191,36,0.24)", + "alert_success_bg": "rgba(5,150,105,0.18)", + "alert_success_border": "rgba(52,211,153,0.24)", + "alert_error_bg": "rgba(220,38,38,0.18)", + "alert_error_border": "rgba(248,113,113,0.24)", + } + return { + "ink": "#0F172A", + "muted": "#5A6578", + "border": "#D1D9E0", + "panel": "#FFFFFF", + "panel_strong": "#F8FAFC", + "accent": "#0E7490", + "accent_strong": "#155E75", + "gold": "#B45309", + "bg_top": "#F0F2F5", + "bg_bottom": "#E8EBF0", + "sidebar_top": "#0F172A", + "sidebar_bottom": "#18263A", + "sidebar_text": "#E2E8F0", + "sidebar_muted": "#A9B8CC", + "metric_bg": "linear-gradient(180deg, #FFFFFF 0%, #F8FAFC 100%)", + "tab_bg": "#FFFFFF", + "tab_text": "#3E5267", + "tab_active": "rgba(14,116,144,0.12)", + "field_bg": "#FAFBFC", + "field_gloss": "rgba(255,255,255,0.36)", + "status_bg": "#FFFFFF", + "status_text": "#445163", + "disabled_bg": "#C9D6E2", + "disabled_ink": "#415264", + "input_bg": "#FAFBFC", + "input_border": "#D1D9E0", + "hero_start": "rgba(245,248,251,0.95)", + "hero_overlay": "rgba(180,83,9,0.08)", + "hero_end": "rgba(235,240,245,0.90)", + "shadow": "rgba(15,23,42,0.08)", + "alert_info_bg": "rgba(8,145,178,0.12)", + "alert_info_border": "rgba(14,116,144,0.20)", + "alert_warning_bg": "rgba(217,119,6,0.12)", + "alert_warning_border": "rgba(217,119,6,0.18)", + "alert_success_bg": "rgba(5,150,105,0.12)", + "alert_success_border": "rgba(5,150,105,0.18)", + "alert_error_bg": "rgba(220,38,38,0.12)", + "alert_error_border": "rgba(220,38,38,0.18)", + } + + ensure_session_state() +theme_mode = get_ui_theme() +theme_tokens = _theme_tokens(theme_mode) st.session_state["diagnostics_log_path"] = configure_diagnostics_logger() try: st.session_state["license_state"] = load_license_state(app_version=APP_VERSION) @@ -41,32 +132,71 @@ "commercial_mode": commercial_mode_enabled(), } try: - st.session_state["library_status"] = maybe_refresh_library_manifest(st.session_state.get("license_state")) + st.session_state["library_status"] = maybe_refresh_library_manifest( + st.session_state.get("license_state") + ) except Exception: st.session_state["library_status"] = None # --- Professional CSS --- -st.markdown(""" +theme_root_css = "\n".join( + [ + f" --ta-ink: {theme_tokens['ink']};", + f" --ta-muted: {theme_tokens['muted']};", + f" --ta-border: {theme_tokens['border']};", + f" --ta-panel: {theme_tokens['panel']};", + f" --ta-panel-strong: {theme_tokens['panel_strong']};", + f" --ta-accent: {theme_tokens['accent']};", + f" --ta-accent-strong: {theme_tokens['accent_strong']};", + f" --ta-gold: {theme_tokens['gold']};", + f" --ta-bg-top: {theme_tokens['bg_top']};", + f" --ta-bg-bottom: {theme_tokens['bg_bottom']};", + f" --ta-sidebar-top: {theme_tokens['sidebar_top']};", + f" --ta-sidebar-bottom: {theme_tokens['sidebar_bottom']};", + f" --ta-sidebar-text: {theme_tokens['sidebar_text']};", + f" --ta-sidebar-muted: {theme_tokens['sidebar_muted']};", + f" --ta-metric-bg: {theme_tokens['metric_bg']};", + f" --ta-tab-bg: {theme_tokens['tab_bg']};", + f" --ta-tab-text: {theme_tokens['tab_text']};", + f" --ta-tab-active: {theme_tokens['tab_active']};", + f" --ta-field-bg: {theme_tokens['field_bg']};", + f" --ta-field-gloss: {theme_tokens['field_gloss']};", + f" --ta-status-bg: {theme_tokens['status_bg']};", + f" --ta-status-text: {theme_tokens['status_text']};", + f" --ta-disabled-bg: {theme_tokens['disabled_bg']};", + f" --ta-disabled-ink: {theme_tokens['disabled_ink']};", + f" --ta-input-bg: {theme_tokens['input_bg']};", + f" --ta-input-border: {theme_tokens['input_border']};", + f" --ta-hero-start: {theme_tokens['hero_start']};", + f" --ta-hero-overlay: {theme_tokens['hero_overlay']};", + f" --ta-hero-end: {theme_tokens['hero_end']};", + f" --ta-shadow: {theme_tokens['shadow']};", + f" --ta-alert-info-bg: {theme_tokens['alert_info_bg']};", + f" --ta-alert-info-border: {theme_tokens['alert_info_border']};", + f" --ta-alert-warning-bg: {theme_tokens['alert_warning_bg']};", + f" --ta-alert-warning-border: {theme_tokens['alert_warning_border']};", + f" --ta-alert-success-bg: {theme_tokens['alert_success_bg']};", + f" --ta-alert-success-border: {theme_tokens['alert_success_border']};", + f" --ta-alert-error-bg: {theme_tokens['alert_error_bg']};", + f" --ta-alert-error-border: {theme_tokens['alert_error_border']};", + ] +) +st.markdown( + """ -""", unsafe_allow_html=True) +""", + unsafe_allow_html=True, +) + + +def _theme_option_label(mode: str) -> str: + return { + "light": "☀", + "dark": "☾", + }.get(mode, mode) + # --- Sidebar branding --- with st.sidebar: @@ -384,16 +710,16 @@ "expired_read_only": t("app.license.read_only"), "unlicensed": t("app.license.unlicensed"), }.get(license_state.get("status"), t("app.license.development")) + st.markdown( f'' f'' - , + f'', unsafe_allow_html=True, ) - header_meta_col, header_lang_col = st.columns([1.15, 0.95], gap="small") - with header_meta_col: - st.markdown(f'', unsafe_allow_html=True) - with header_lang_col: + + lang_col, theme_col = st.columns([1, 1], gap="small") + with lang_col: st.segmented_control( "language", options=list(SUPPORTED_LANGUAGES.keys()), @@ -402,6 +728,16 @@ selection_mode="single", label_visibility="collapsed", ) + with theme_col: + st.segmented_control( + "theme", + options=["light", "dark"], + format_func=_theme_option_label, + key="ui_theme", + selection_mode="single", + label_visibility="collapsed", + ) + n_datasets = len(st.session_state.get("datasets", {})) if n_datasets > 0: st.markdown( @@ -409,8 +745,17 @@ unsafe_allow_html=True, ) -def _render_sidebar_page_section(title: str, page_items: list[tuple], current_path: str, *, collapsible: bool = False, expanded: bool = True) -> None: + +def _render_sidebar_page_section( + title: str, + page_items: list[tuple], + current_path: str, + *, + collapsible: bool = False, + expanded: bool = True, +) -> None: """Render one grouped sidebar navigation section.""" + def _render_items() -> None: for page, label, icon, path in page_items: st.page_link(page, label=label, icon=icon, disabled=(path == current_path)) @@ -420,9 +765,12 @@ def _render_items() -> None: _render_items() return - st.markdown(f'', unsafe_allow_html=True) + st.markdown( + f'', unsafe_allow_html=True + ) _render_items() + # --- Page imports --- from ui.components.history_tracker import render_history_sidebar from ui.about_page import render as about_render @@ -453,23 +801,104 @@ def _render_items() -> None: show_preview_tools = False primary_pages = [ - (st.Page(home_render, title=t("nav.import"), icon="📂", default=True, url_path="import"), t("nav.import"), "📂", "import"), - (st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project"), t("nav.project"), "🗂️", "project"), - (st.Page(compare_render, title=t("nav.compare"), icon="🧪", url_path="compare"), t("nav.compare"), "🧪", "compare"), - (st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report"), t("nav.report"), "📝", "report"), + ( + st.Page( + home_render, + title=t("nav.import"), + icon="📂", + default=True, + url_path="import", + ), + t("nav.import"), + "📂", + "import", + ), + ( + st.Page(project_render, title=t("nav.project"), icon="🗂️", url_path="project"), + t("nav.project"), + "🗂️", + "project", + ), + ( + st.Page(compare_render, title=t("nav.compare"), icon="🧪", url_path="compare"), + t("nav.compare"), + "🧪", + "compare", + ), + ( + st.Page(export_render, title=t("nav.report"), icon="📝", url_path="report"), + t("nav.report"), + "📝", + "report", + ), ] analysis_pages = [ - (st.Page(dsc_render, title=t("nav.dsc"), icon="📈", url_path="dsc"), t("nav.dsc"), "📈", "dsc"), - (st.Page(tga_render, title=t("nav.tga"), icon="📉", url_path="tga"), t("nav.tga"), "📉", "tga"), - (st.Page(dta_render, title=tx("DTA Analizi", "DTA Analysis"), icon="📊", url_path="dta"), tx("DTA Analizi", "DTA Analysis"), "📊", "dta"), - (st.Page(ftir_render, title=t("nav.ftir"), icon="🧬", url_path="ftir"), t("nav.ftir"), "🧬", "ftir"), - (st.Page(raman_render, title=t("nav.raman"), icon="🔦", url_path="raman"), t("nav.raman"), "🔦", "raman"), - (st.Page(xrd_render, title=t("nav.xrd"), icon="🧿", url_path="xrd"), t("nav.xrd"), "🧿", "xrd"), + ( + st.Page(dsc_render, title=t("nav.dsc"), icon="📈", url_path="dsc"), + t("nav.dsc"), + "📈", + "dsc", + ), + ( + st.Page(tga_render, title=t("nav.tga"), icon="📉", url_path="tga"), + t("nav.tga"), + "📉", + "tga", + ), + ( + st.Page( + dta_render, + title=tx("DTA Analizi", "DTA Analysis"), + icon="📊", + url_path="dta", + ), + tx("DTA Analizi", "DTA Analysis"), + "📊", + "dta", + ), + ( + st.Page(ftir_render, title=t("nav.ftir"), icon="🧬", url_path="ftir"), + t("nav.ftir"), + "🧬", + "ftir", + ), + ( + st.Page(raman_render, title=t("nav.raman"), icon="🔦", url_path="raman"), + t("nav.raman"), + "🔦", + "raman", + ), + ( + st.Page(xrd_render, title=t("nav.xrd"), icon="🧿", url_path="xrd"), + t("nav.xrd"), + "🧿", + "xrd", + ), ] management_pages = [ - (st.Page(library_render, title=tx("Kütüphane", "Library"), icon="🗃️", url_path="library"), tx("Kütüphane", "Library"), "🗃️", "library"), - (st.Page(license_render, title=t("nav.license"), icon="🔐", url_path="license"), t("nav.license"), "🔐", "license"), - (st.Page(about_render, title=t("nav.about"), icon="ℹ️", url_path="about"), t("nav.about"), "ℹ️", "about"), + ( + st.Page( + library_render, + title=tx("Kütüphane", "Library"), + icon="🗃️", + url_path="library", + ), + tx("Kütüphane", "Library"), + "🗃️", + "library", + ), + ( + st.Page(license_render, title=t("nav.license"), icon="🔐", url_path="license"), + t("nav.license"), + "🔐", + "license", + ), + ( + st.Page(about_render, title=t("nav.about"), icon="ℹ️", url_path="about"), + t("nav.about"), + "ℹ️", + "about", + ), ] pages = { @@ -481,13 +910,27 @@ def _render_items() -> None: if show_preview_tools: preview_pages = [ ( - st.Page(kinetics_render, title=tx("Kinetik Analiz (Deneysel)", "Kinetic Analysis (Experimental)"), icon="⚡", url_path="kinetics"), + st.Page( + kinetics_render, + title=tx( + "Kinetik Analiz (Deneysel)", "Kinetic Analysis (Experimental)" + ), + icon="⚡", + url_path="kinetics", + ), tx("Kinetik Analiz (Deneysel)", "Kinetic Analysis (Experimental)"), "⚡", "kinetics", ), ( - st.Page(deconv_render, title=tx("Pik Dekonvolüsyonu (Deneysel)", "Peak Deconvolution (Experimental)"), icon="🔍", url_path="deconvolution"), + st.Page( + deconv_render, + title=tx( + "Pik Dekonvolüsyonu (Deneysel)", "Peak Deconvolution (Experimental)" + ), + icon="🔍", + url_path="deconvolution", + ), tx("Pik Dekonvolüsyonu (Deneysel)", "Peak Deconvolution (Experimental)"), "🔍", "deconvolution", @@ -496,14 +939,29 @@ def _render_items() -> None: pages[t("nav.preview")] = [page for page, _, _, _ in preview_pages] pg = st.navigation(pages, position="hidden") -current_path = next((path for page, _, _, path in (primary_pages + analysis_pages + management_pages + preview_pages) if page == pg), "") +current_path = next( + ( + path + for page, _, _, path in ( + primary_pages + analysis_pages + management_pages + preview_pages + ) + if page == pg + ), + "", +) with st.sidebar: _render_sidebar_page_section(t("nav.primary"), primary_pages, current_path) _render_sidebar_page_section(t("nav.analyses"), analysis_pages, current_path) _render_sidebar_page_section(t("nav.management"), management_pages, current_path) if preview_pages: - _render_sidebar_page_section(t("nav.preview"), preview_pages, current_path, collapsible=True, expanded=False) + _render_sidebar_page_section( + t("nav.preview"), + preview_pages, + current_path, + collapsible=True, + expanded=False, + ) st.markdown("---") # --- Pipeline history in sidebar --- diff --git a/tests/test_ui_consistency.py b/tests/test_ui_consistency.py index ec25c5cd..0303a145 100644 --- a/tests/test_ui_consistency.py +++ b/tests/test_ui_consistency.py @@ -95,6 +95,7 @@ def test_sidebar_and_about_copy_do_not_show_version_or_preview_disabled_note(): def test_sidebar_navigation_uses_grouped_scientific_structure(): app_entry = _repo_text("app.py") + session_state = _repo_text("utils/session_state.py") i18n = _repo_text("utils/i18n.py") assert 't("nav.primary")' in app_entry @@ -117,6 +118,8 @@ def test_sidebar_navigation_uses_grouped_scientific_structure(): license_idx = app_entry.index('f\'\'') segmented_idx = app_entry.index('st.segmented_control(') assert brand_idx < license_idx < segmented_idx + assert 'key="ui_theme"' in app_entry + assert '"ui_theme": "light"' in session_state assert '"nav.primary"' in i18n assert '"nav.analyses"' in i18n assert '"nav.management"' in i18n diff --git a/ui/components/plot_builder.py b/ui/components/plot_builder.py index 1e0281b4..d7282d2e 100644 --- a/ui/components/plot_builder.py +++ b/ui/components/plot_builder.py @@ -6,27 +6,49 @@ import plotly.graph_objects as go from plotly.subplots import make_subplots +from utils.session_state import get_ui_theme THERMAL_COLORS = [ - "#1F3B5B", - "#C45A5A", - "#4E7A64", - "#7A5C9E", - "#D08B45", - "#2C7DA0", - "#A44A3F", - "#667085", + "#0F766E", + "#2563EB", + "#DC2626", + "#7C3AED", + "#D97706", + "#0891B2", + "#65A30D", + "#475569", ] -BASELINE_COLOR = "#697586" +BASELINE_COLOR = "#64748B" _PLOT_FONT_FAMILY = "Aptos, Segoe UI, Arial, sans-serif" -_PLOT_TEXT_COLOR = "#1F2937" -_PLOT_SUBTLE_TEXT = "#64748B" -_PLOT_PAPER_BG = "#FCFCF8" -_PLOT_AREA_BG = "#FFFFFF" -_PLOT_GRID_COLOR = "#E7ECF3" -_PLOT_AXIS_COLOR = "#AAB4C3" -_PLOT_HOVER_BORDER = "#CBD5E1" +_PLOT_THEME = { + "light": { + "template": "plotly_white", + "text": "#102033", + "subtle_text": "#516072", + "paper_bg": "#F7FAFC", + "plot_bg": "#FFFFFF", + "grid": "#D8E1EA", + "axis": "#A8B6C7", + "hover_border": "#C7D2DF", + "legend_bg": "rgba(255,255,255,0.84)", + "annotation_bg": "rgba(255,255,255,0.90)", + "annotation_border": "rgba(120,138,160,0.24)", + }, + "dark": { + "template": "plotly_dark", + "text": "#E5EEF8", + "subtle_text": "#B8C6D8", + "paper_bg": "#0F172A", + "plot_bg": "#111C30", + "grid": "#314055", + "axis": "#6F829B", + "hover_border": "#44566F", + "legend_bg": "rgba(15,23,42,0.82)", + "annotation_bg": "rgba(15,23,42,0.88)", + "annotation_border": "rgba(122,150,182,0.28)", + }, +} _DEFAULT_EXPORT_WIDTH = 1400 _DEFAULT_EXPORT_HEIGHT = 840 _DEFAULT_DISPLAY_SETTINGS = { @@ -62,71 +84,77 @@ ), ) -DEFAULT_LAYOUT = dict( - template="plotly_white", - colorway=THERMAL_COLORS, - font=dict(family=_PLOT_FONT_FAMILY, size=12, color=_PLOT_TEXT_COLOR), - hoverlabel=dict( - bgcolor="#FFFFFF", - bordercolor=_PLOT_HOVER_BORDER, - font_size=12, - font_family=_PLOT_FONT_FAMILY, - font_color=_PLOT_TEXT_COLOR, - ), - legend=dict( - orientation="h", - yanchor="bottom", - y=1.02, - xanchor="left", - x=0.0, - bgcolor="rgba(255,255,255,0.68)", - bordercolor="rgba(0,0,0,0)", - borderwidth=0, - font=dict(size=10.5, color=_PLOT_SUBTLE_TEXT), - title=dict(text=""), - itemsizing="constant", - ), - title=dict( - x=0.0, - xanchor="left", - y=0.98, - yanchor="top", - pad=dict(b=10), - font=dict(size=18, color=_PLOT_TEXT_COLOR), - ), - margin=dict(l=76, r=44, t=88, b=68), - paper_bgcolor=_PLOT_PAPER_BG, - plot_bgcolor=_PLOT_AREA_BG, - autosize=True, - xaxis=dict( - gridcolor=_PLOT_GRID_COLOR, - gridwidth=1, - ticks="outside", - tickcolor=_PLOT_AXIS_COLOR, - ticklen=5, - showline=True, - linecolor=_PLOT_AXIS_COLOR, - linewidth=1.1, - zeroline=False, - mirror=False, - automargin=True, - title_standoff=12, - ), - yaxis=dict( - gridcolor=_PLOT_GRID_COLOR, - gridwidth=1, - ticks="outside", - tickcolor=_PLOT_AXIS_COLOR, - ticklen=5, - showline=True, - linecolor=_PLOT_AXIS_COLOR, - linewidth=1.1, - zeroline=False, - mirror=False, - automargin=True, - title_standoff=12, - ), -) +def _plot_tokens() -> dict[str, str]: + return _PLOT_THEME[get_ui_theme()] + + +def _default_layout() -> dict: + tokens = _plot_tokens() + return dict( + template=tokens["template"], + colorway=THERMAL_COLORS, + font=dict(family=_PLOT_FONT_FAMILY, size=12, color=tokens["text"]), + hoverlabel=dict( + bgcolor=tokens["plot_bg"], + bordercolor=tokens["hover_border"], + font_size=12, + font_family=_PLOT_FONT_FAMILY, + font_color=tokens["text"], + ), + legend=dict( + orientation="h", + yanchor="bottom", + y=1.02, + xanchor="left", + x=0.0, + bgcolor=tokens["legend_bg"], + bordercolor="rgba(0,0,0,0)", + borderwidth=0, + font=dict(size=10.5, color=tokens["subtle_text"]), + title=dict(text=""), + itemsizing="constant", + ), + title=dict( + x=0.0, + xanchor="left", + y=0.98, + yanchor="top", + pad=dict(b=10), + font=dict(size=18, color=tokens["text"]), + ), + margin=dict(l=76, r=44, t=88, b=68), + paper_bgcolor=tokens["paper_bg"], + plot_bgcolor=tokens["plot_bg"], + autosize=True, + xaxis=dict( + gridcolor=tokens["grid"], + gridwidth=1, + ticks="outside", + tickcolor=tokens["axis"], + ticklen=5, + showline=True, + linecolor=tokens["axis"], + linewidth=1.1, + zeroline=False, + mirror=False, + automargin=True, + title_standoff=12, + ), + yaxis=dict( + gridcolor=tokens["grid"], + gridwidth=1, + ticks="outside", + tickcolor=tokens["axis"], + ticklen=5, + showline=True, + linecolor=tokens["axis"], + linewidth=1.1, + zeroline=False, + mirror=False, + automargin=True, + title_standoff=12, + ), + ) def default_plot_display_settings(settings: dict | None = None, **overrides) -> dict: @@ -192,6 +220,7 @@ def build_plotly_config(settings: dict | None = None, *, filename: str | None = return config def _legend_layout(trace_count: int, *, legend_mode: str, compact: bool) -> tuple[bool, dict]: + tokens = _plot_tokens() if legend_mode == "hidden": return False, {} font_size = 10 if compact else 10.5 @@ -202,11 +231,11 @@ def _legend_layout(trace_count: int, *, legend_mode: str, compact: bool) -> tupl y=1.0, xanchor="left", x=1.02, - bgcolor="rgba(255,255,255,0.72)", + bgcolor=tokens["legend_bg"], bordercolor="rgba(0,0,0,0)", borderwidth=0, tracegroupgap=4, - font=dict(size=font_size, color=_PLOT_SUBTLE_TEXT), + font=dict(size=font_size, color=tokens["subtle_text"]), title=dict(text=""), itemsizing="constant", ) @@ -216,10 +245,10 @@ def _legend_layout(trace_count: int, *, legend_mode: str, compact: bool) -> tupl y=1.02, xanchor="left", x=0.0, - bgcolor="rgba(255,255,255,0.60)", + bgcolor=tokens["legend_bg"], bordercolor="rgba(0,0,0,0)", borderwidth=0, - font=dict(size=font_size, color=_PLOT_SUBTLE_TEXT), + font=dict(size=font_size, color=tokens["subtle_text"]), title=dict(text=""), itemsizing="constant", ) @@ -255,7 +284,7 @@ def _compose_title(title: str | None, subtitle: str | None, *, compact: bool) -> if not sub: return base sub_size = 11 if compact else 12 - return f"{base}
{sub}" + return f"{base}
{sub}" def _style_trace_defaults(fig, *, compact: bool) -> None: @@ -293,10 +322,11 @@ def apply_professional_plot_theme( subtitle: str | None = None, ): """Apply a shared publication-style theme across Plotly figures.""" + tokens = _plot_tokens() trace_count = sum(1 for trace in fig.data if getattr(trace, "showlegend", True) is not False) showlegend, legend = _legend_layout(trace_count, legend_mode=legend_mode, compact=compact) final_title = title if title is not None else getattr(fig.layout.title, "text", "") - layout = dict(DEFAULT_LAYOUT) + layout = _default_layout() layout.update( title=dict( text=_compose_title(final_title, subtitle, compact=compact), @@ -305,7 +335,7 @@ def apply_professional_plot_theme( y=0.98, yanchor="top", pad=dict(b=10), - font=dict(size=16 if compact else 18, color=_PLOT_TEXT_COLOR, family=_PLOT_FONT_FAMILY), + font=dict(size=16 if compact else 18, color=tokens["text"], family=_PLOT_FONT_FAMILY), ), showlegend=showlegend, legend=legend, @@ -326,30 +356,30 @@ def apply_professional_plot_theme( fig.update_layout(width=_DEFAULT_EXPORT_WIDTH, height=_DEFAULT_EXPORT_HEIGHT) fig.update_xaxes( showspikes=True, - spikecolor=_PLOT_AXIS_COLOR, + spikecolor=tokens["axis"], spikethickness=1, spikedash="dot", spikemode="across", - gridcolor=_PLOT_GRID_COLOR, - linecolor=_PLOT_AXIS_COLOR, - tickfont=dict(size=11, color=_PLOT_TEXT_COLOR), - title_font=dict(size=12, color=_PLOT_TEXT_COLOR, family=_PLOT_FONT_FAMILY), + gridcolor=tokens["grid"], + linecolor=tokens["axis"], + tickfont=dict(size=11, color=tokens["text"]), + title_font=dict(size=12, color=tokens["text"], family=_PLOT_FONT_FAMILY), ) fig.update_yaxes( showspikes=True, - spikecolor=_PLOT_AXIS_COLOR, + spikecolor=tokens["axis"], spikethickness=1, spikedash="dot", spikemode="across", - gridcolor=_PLOT_GRID_COLOR, - linecolor=_PLOT_AXIS_COLOR, - tickfont=dict(size=11, color=_PLOT_TEXT_COLOR), - title_font=dict(size=12, color=_PLOT_TEXT_COLOR, family=_PLOT_FONT_FAMILY), + gridcolor=tokens["grid"], + linecolor=tokens["axis"], + tickfont=dict(size=11, color=tokens["text"]), + title_font=dict(size=12, color=tokens["text"], family=_PLOT_FONT_FAMILY), ) fig.update_annotations( - font=dict(size=10.5 if compact else 11, color=_PLOT_SUBTLE_TEXT, family=_PLOT_FONT_FAMILY), - bgcolor="rgba(255,255,255,0.78)", - bordercolor="rgba(148,163,184,0.24)", + font=dict(size=10.5 if compact else 11, color=tokens["subtle_text"], family=_PLOT_FONT_FAMILY), + bgcolor=tokens["annotation_bg"], + bordercolor=tokens["annotation_border"], borderwidth=0, borderpad=2, ) @@ -406,17 +436,18 @@ def apply_plot_display_settings( def apply_plotly_config(fig): """Apply shared crosshair and hover behavior without overriding layout composition.""" + tokens = _plot_tokens() fig.update_layout(hovermode="x unified", hoverdistance=80, spikedistance=1000) fig.update_xaxes( showspikes=True, - spikecolor=_PLOT_AXIS_COLOR, + spikecolor=tokens["axis"], spikethickness=1, spikedash="dot", spikemode="across", ) fig.update_yaxes( showspikes=True, - spikecolor=_PLOT_AXIS_COLOR, + spikecolor=tokens["axis"], spikethickness=1, spikedash="dot", spikemode="across", @@ -426,6 +457,7 @@ def apply_plotly_config(fig): def _add_exo_annotation(fig): """Add 'exo up' annotation to DSC/DTA plots (industry standard).""" + tokens = _plot_tokens() fig.add_annotation( text="exo \u2191", xref="paper", yref="paper", @@ -433,7 +465,7 @@ def _add_exo_annotation(fig): xanchor="left", yanchor="bottom", showarrow=False, - font=dict(size=11, color=_PLOT_SUBTLE_TEXT, family=_PLOT_FONT_FAMILY), + font=dict(size=11, color=tokens["subtle_text"], family=_PLOT_FONT_FAMILY), ) @@ -457,12 +489,13 @@ def create_dsc_plot(temperature, heat_flow, title="DSC Curve", y_label="Heat Flow (mW/mg)", baseline=None, peaks=None, smoothed=None, display_settings: dict | None = None): """Create a DSC plot with optional baseline and peak markers.""" + tokens = _plot_tokens() fig = go.Figure() if smoothed is not None: fig.add_trace(go.Scatter( x=temperature, y=heat_flow, mode="lines", name="Raw", - line=dict(color="#CCCCCC", width=1), + line=dict(color=tokens["axis"], width=1), opacity=0.5, )) fig.add_trace(go.Scatter( @@ -499,7 +532,7 @@ def create_dsc_plot(temperature, heat_flow, title="DSC Curve", marker=dict(color=THERMAL_COLORS[3], size=9, symbol="diamond"), text=[f"{t:.1f}°C" for t in peak_temps], textposition="top center", - textfont=dict(size=10.5, color=_PLOT_SUBTLE_TEXT), + textfont=dict(size=10.5, color=tokens["subtle_text"]), hovertext=hover_texts, hoverinfo="text", )) @@ -508,9 +541,9 @@ def create_dsc_plot(temperature, heat_flow, title="DSC Curve", if p.onset_temperature is not None: fig.add_vline( x=p.onset_temperature, line_dash="dot", - line_color=_PLOT_AXIS_COLOR, opacity=0.55, + line_color=tokens["axis"], opacity=0.55, annotation_text=f"Onset {p.onset_temperature:.1f}°C", - annotation=dict(font=dict(size=10, color=_PLOT_SUBTLE_TEXT, family=_PLOT_FONT_FAMILY)), + annotation=dict(font=dict(size=10, color=tokens["subtle_text"], family=_PLOT_FONT_FAMILY)), ) fig.update_layout(xaxis_title="Temperature (°C)", yaxis_title=y_label) @@ -535,6 +568,7 @@ def create_tga_plot( display_settings: dict | None = None, ): """Create a TGA plot with optional DTG overlay and step markers.""" + tokens = _plot_tokens() if dtg is not None: fig = make_subplots(specs=[[{"secondary_y": True}]]) else: @@ -560,7 +594,7 @@ def create_tga_plot( opacity=0.08, line_width=0, annotation_text=f"{step_prefix} {i+1}: {step.mass_loss_percent:.1f}%", annotation_position="top left", - annotation=dict(font=dict(size=10, color=_PLOT_SUBTLE_TEXT, family=_PLOT_FONT_FAMILY)), + annotation=dict(font=dict(size=10, color=tokens["subtle_text"], family=_PLOT_FONT_FAMILY)), ) fig.update_layout(xaxis_title=x_label, yaxis_title=y_label) diff --git a/utils/session_state.py b/utils/session_state.py index 24f5bbc7..57f2cd0d 100644 --- a/utils/session_state.py +++ b/utils/session_state.py @@ -9,6 +9,7 @@ SESSION_DEFAULTS = { "ui_language": "tr", + "ui_theme": "light", "datasets": {}, "active_dataset": None, "results": {}, @@ -60,6 +61,15 @@ def ensure_session_state() -> None: st.session_state[key] = copy.deepcopy(default) +def get_ui_theme() -> str: + """Return the active UI theme without requiring a Streamlit runtime context.""" + try: + theme = str(st.session_state.get("ui_theme", "light")).strip().lower() + except Exception: + return "light" + return theme if theme in {"light", "dark"} else "light" + + def clear_project_state() -> None: """Clear all project-related session-state entries.""" keys = list(st.session_state.keys())