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..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`
@@ -166,10 +167,19 @@ 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.
+
+Optional runtime tuning:
+
+```dotenv
+BACKEND_STARTUP_TIMEOUT_SECONDS=30
+```
+
---
## Local Cloud-Library Development
diff --git a/app.py b/app.py
index 97cb1bb5..9cfa3af2 100644
--- a/app.py
+++ b/app.py
@@ -12,11 +12,15 @@
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.session_state import clear_project_state, ensure_session_state, replace_project_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, 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:
- st.segmented_control(
- t("app.language"),
- options=list(SUPPORTED_LANGUAGES.keys()),
- format_func=lambda code: SUPPORTED_LANGUAGES[code],
- key="ui_language",
- selection_mode="single",
- )
license_state = st.session_state.get("license_state", {})
license_label = {
"development": t("app.license.development"),
@@ -295,91 +710,70 @@
"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'
{t("app.brand")}
'
- f'',
+ f''
+ f'',
unsafe_allow_html=True,
)
+
+ lang_col, theme_col = st.columns([1, 1], gap="small")
+ with 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",
+ )
+ 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(
f'',
unsafe_allow_html=True,
)
- st.caption(f"License: {license_label}")
- st.markdown("---")
-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"))
- 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.",
+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))
+
+ if collapsible:
+ with st.expander(title, expanded=expanded):
+ _render_items()
+ return
+
+ st.markdown(
+ f'', unsafe_allow_html=True
)
- 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})")
+ _render_items()
+
# --- 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
@@ -396,87 +790,183 @@ 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"),
-)
-pages = {
- tx("Ana Akış", "Primary"): [
- st.Page(home_render, title=t("nav.import"), icon="📂", default=True, url_path="import"),
+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
+
+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",
+ ),
+]
+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"),
- st.Page(dta_render, title=tx("DTA Analizi", "DTA Analysis"), icon="📊", url_path="dta"),
+ 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"),
- 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"),
+ 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",
+ ),
+]
+
+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:
- 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)"),
+ "⚡",
+ "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]
+
+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
+ ),
+ "",
+)
-pg = st.navigation(pages)
+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,
+ )
+ st.markdown("---")
# --- Pipeline history in sidebar ---
with st.sidebar:
- _render_project_sidebar()
- st.markdown("---")
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\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\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/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/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=$!
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 b32897fc..4c4e9728 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
@@ -17,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)
@@ -34,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
@@ -125,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
@@ -170,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(
@@ -180,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
@@ -225,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(
@@ -235,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
@@ -281,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(
@@ -291,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
@@ -310,3 +328,133 @@ 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",
+ "metadata": _import_metadata(thermal_dataset),
+ },
+ )
+ 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",
+ "metadata": _import_metadata(second_dataset),
+ },
+ )
+ 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)
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
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/tests/test_ui_consistency.py b/tests/test_ui_consistency.py
new file mode 100644
index 00000000..0303a145
--- /dev/null
+++ b/tests/test_ui_consistency.py
@@ -0,0 +1,125 @@
+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_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")
+
+ 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
+ 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():
+ 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 '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
+
+
+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
+ 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, 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\'{t("app.brand")}
\'')
+ 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/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/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/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 83adf30a..1b1d2e99 100644
--- a/ui/dta_page.py
+++ b/ui/dta_page.py
@@ -24,13 +24,15 @@
)
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 ui.components.workflow_guide import render_analysis_workflow_guide
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,13 +137,8 @@ def _store_dta_result(selected_key, dataset, temperature, signal, state):
# ---------------------------------------------------------------------------
def render():
- st.title(tx("DTA Analizi", "DTA Analysis"))
- st.info(
- 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_page_header(t("dta.title"), t("dta.caption"), badge=t("dta.hero_badge"))
+ render_analysis_workflow_guide("DTA")
dta_datasets = _get_dta_datasets()
if not dta_datasets:
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/license_page.py b/ui/license_page.py
index d1a0e2ad..936a8ed1 100644
--- a/ui/license_page.py
+++ b/ui/license_page.py
@@ -151,7 +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']}")
-
def _status_label(state, lang):
labels = {
"development": "Geliştirme Build'i" if lang == "tr" else "Development Build",
diff --git a/ui/project_page.py b/ui/project_page.py
index e67f6ac7..529a0580 100644
--- a/ui/project_page.py
+++ b/ui/project_page.py
@@ -1,13 +1,69 @@
-"""Project workspace overview page."""
+"""Project workspace overview page."""
from __future__ import annotations
+import io
+
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 _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():
@@ -18,62 +74,234 @@ 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", []))))
-
- 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),
- }
+ 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.",
+ )
)
- 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", [])),
- }
+ 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(
- "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`."
+ 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(
+ [
+ "Ç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"))
+ 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.')}"
+ )
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 3ec60085..287c4be1 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",
@@ -55,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",
@@ -91,10 +107,26 @@
"tr": "Lisans ve Marka",
"en": "License & Branding",
},
+ "nav.about": {
+ "tr": "Hakkında",
+ "en": "About",
+ },
"nav.preview": {
"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",
@@ -179,6 +211,10 @@
"tr": "Oturum Yönetimi",
"en": "Session Management",
},
+ "project.sidebar_hint": {
+ "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",
"en": "License & Branding",
@@ -196,52 +232,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": "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": "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",
},
}
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)
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())