diff --git a/dashboard/app.py b/dashboard/app.py
index 1a4d5b7..6b231cf 100644
--- a/dashboard/app.py
+++ b/dashboard/app.py
@@ -12,7 +12,15 @@ if __package__ in (None, ""):
st.set_page_config(page_title="Jarca Ledger", page_icon="๐ฐ", layout="wide")
-from dashboard.common import clear_cache, get_db_path, get_transfer_review_items, inject_app_css, render_sidebar_nav # noqa: F401
+from dashboard.common import (
+ clear_cache,
+ get_db_path,
+ get_transfer_review_items, # noqa: F401
+ inject_app_css,
+ render_dashboard_body,
+ render_sidebar_nav,
+ validate_dashboard_page,
+) # noqa: F401
from dashboard.authentication import require_dashboard_authentication
from dashboard.page_views.accounts import page_accounts
from dashboard.page_views.assets import page_assets
@@ -32,15 +40,15 @@ def main() -> None:
db_path = get_db_path()
inject_app_css()
pages = ["์์ ์ฐ", "์์ฝ", "์์ฐ/๋ง๊ฐ", "๊ฑฐ๋๋ด์ญ", "ํ์ธํ์ ํ", "๋ฏธ๋ถ๋ฅ", "๋ถ๋ฅ ๊ท์น", "์นดํ
๊ณ ๋ฆฌ", "๊ณ์ข", "Import"]
- query_page = st.query_params.get("page", "์์ฝ")
- page = query_page if query_page in pages else "์์ฝ"
- st.session_state["page"] = page
+ query_page = st.query_params.get("page")
+ page = validate_dashboard_page(query_page, pages)
+ st.session_state["dashboard_page"] = page
with st.sidebar:
st.markdown("## ๐ฐ Jarca Ledger")
st.caption("๊ฐ๊ณ๋ถ ยท ๋ถ๋ฅ ยท Import ์ด์ ์ฝ์")
st.markdown('
๋ฉ๋ด
', unsafe_allow_html=True)
- render_sidebar_nav(pages, page)
+ page = render_sidebar_nav(pages, page)
if st.button("์๋ก๊ณ ์นจ", key="refresh_button", use_container_width=True):
clear_cache()
st.rerun()
@@ -56,7 +64,7 @@ def main() -> None:
"๊ณ์ข": page_accounts,
"Import": page_imports,
}
- dispatch[page](db_path)
+ render_dashboard_body(page, lambda: dispatch[page](db_path))
if __name__ == "__main__":
diff --git a/dashboard/common.py b/dashboard/common.py
index 60abf90..23c66a5 100644
--- a/dashboard/common.py
+++ b/dashboard/common.py
@@ -4,7 +4,6 @@ import sqlite3
from datetime import datetime
from html import escape
from typing import Any
-from urllib.parse import quote
import importlib
import inspect
from zoneinfo import ZoneInfo
@@ -314,6 +313,9 @@ def inject_app_css() -> None:
border-color: rgba(37, 99, 235, 0.48);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.10);
}
+ .st-key-dashboard_body:has(.st-key-dashboard_loading [data-testid="stAlert"]) .st-key-dashboard_content {
+ display: none;
+ }
.jarca-card {
border: 1px solid rgba(226, 232, 240, 0.86);
border-radius: 22px;
@@ -355,13 +357,45 @@ def render_section_label(icon: str, label: str) -> None:
st.markdown(f'{escape(icon)}{escape(label)}
', unsafe_allow_html=True)
-def render_sidebar_nav(pages: list[str], current_page: str) -> None:
- items = []
- for name in pages:
- active = " active" if name == current_page else ""
- href = f"?page={quote(name)}"
- items.append(f'{escape(name)}')
- st.markdown(f'{"".join(items)}
', unsafe_allow_html=True)
+def validate_dashboard_page(page: str | None, pages: list[str]) -> str:
+ return page if page in pages else "์์ฝ"
+
+
+def _sync_dashboard_page_query() -> None:
+ st.query_params["page"] = st.session_state["dashboard_page"]
+
+
+def render_sidebar_nav(pages: list[str], current_page: str) -> str:
+ if "dashboard_page" not in st.session_state:
+ st.session_state["dashboard_page"] = current_page
+ return st.radio(
+ "๋ฉ๋ด",
+ pages,
+ key="dashboard_page",
+ label_visibility="collapsed",
+ on_change=_sync_dashboard_page_query,
+ )
+
+
+def render_dashboard_body(page: str, renderer) -> None:
+ body = st.empty()
+ body.empty()
+ try:
+ with body.container():
+ with st.container(key="dashboard_body"):
+ loading = st.empty()
+ with loading.container():
+ with st.container(key="dashboard_loading"):
+ st.info(f"{page} ๋ถ๋ฌ์ค๋ ์คโฆ")
+ with st.container(key="dashboard_content"):
+ renderer()
+ loading.empty()
+ except Exception:
+ body.empty()
+ with body.container():
+ st.error("ํ์ด์ง๋ฅผ ๋ถ๋ฌ์ค์ง ๋ชปํ์ต๋๋ค. ๋ค์ ์๋ํด ์ฃผ์ธ์.")
+ if st.button("๋ค์ ์๋", key=f"retry_{page}"):
+ st.rerun()
def parse_optional_int(value: str, field_name: str) -> int | None:
diff --git a/docs/evidence/issue-9-dashboard-menu-transition/README.md b/docs/evidence/issue-9-dashboard-menu-transition/README.md
new file mode 100644
index 0000000..bba48c3
--- /dev/null
+++ b/docs/evidence/issue-9-dashboard-menu-transition/README.md
@@ -0,0 +1,47 @@
+# Issue #9 authenticated browser evidence
+
+This directory binds the dashboard menu-transition acceptance evidence to the exact Git head and working-tree files listed in `manifest.json`.
+
+## Captured transition
+
+- **Path:** `์์ฝ` โ `๋ถ๋ฅ ๊ท์น`
+- **Input:** native Streamlit menu click in an authenticated HTTPS browser session
+- **Fixture:** generated synthetic SQLite database only
+- **Sampling:** 35 DOM/computed-style frames at 50 ms intervals
+- **Viewport:** 1280 ร 720
+- **Browser:** Headless Chrome 154; Client Hints architecture `arm`, bitness `64`
+- **Fixture host:** Linux aarch64, Python 3.13.5, Streamlit 1.58.0, Uvicorn 0.49.0
+
+## Verified browser observations
+
+All twelve manifest checks are true:
+
+- the HTML document and sidebar markers stayed identical;
+- `[data-testid="stAppViewContainer"]` retained a non-`none` computed `backgroundImage` and remained visible/rendered in every frame;
+- `[data-testid="stSidebar"]` retained a non-`none` computed `backgroundImage` and remained visible/rendered in every frame;
+- computed `backgroundImage`, `backgroundColor`, `visibility`, `display`, `opacity`, and rendered geometry were captured for both surfaces in every frame;
+- the click and all 35 frames completed inside one uninterrupted Playwright evaluation context, so no full-document navigation occurred;
+- the target loading state was observed in two frames;
+- the previous `์์ฝ` body was absent in every loading frame;
+- the unrelated `source=evidence` query key survived;
+- final URL, selected menu, target body, and cleared loading state agreed.
+
+`browser-observations.json` contains every frame, body hashes, URL state, markers, computed surface styles, runtime metadata, and explicit limitations. `final-state.jpg` is the final visible browser state and contains no credential or secret.
+
+## Recheck
+
+Run the exact validator recorded in `manifest.json`. The captured result was:
+
+```text
+exit code: 0
+PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=35 full_document_loads=0 surfaces=2
+```
+
+The browser actions used Playwright MCP. That API reports success/error rather than a numeric process exit code; the manifest records this boundary instead of fabricating an exit status.
+
+## Evidence boundaries
+
+- This is synthetic-fixture browser evidence, not operating-ledger evidence.
+- A temporary HTTPS ingress adapter normalized the request scheme. Repository authentication and same-origin enforcement remained active.
+- The tunnel URL is intentionally ephemeral and retained only as provenance.
+- The screenshot supplements, but does not replace, the machine-readable continuous-frame observations.
diff --git a/docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json b/docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json
new file mode 100644
index 0000000..8a18ae4
--- /dev/null
+++ b/docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json
@@ -0,0 +1,1643 @@
+{
+ "schema": "jarca.issue9.browser-transition.v2",
+ "source": "authenticated Playwright browser runner through temporary HTTPS fixture ingress",
+ "captured_at": "2026-10-05T08:17:17.108Z",
+ "from_page": "์์ฝ",
+ "to_page": "๋ถ๋ฅ ๊ท์น",
+ "pre": {
+ "elapsed_ms": 0,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EC%9A%94%EC%95%BD&source=evidence",
+ "query_page": "์์ฝ",
+ "sibling_source": "evidence",
+ "selected": "์์ฝ",
+ "loading": false,
+ "previous_body_visible": true,
+ "target_body_visible": false,
+ "body_sha256": "fe2152704a93e27f9142fee10ef4963d98bc282fc30ee2713d3f34d7b47aca49",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ "frames": [
+ {
+ "elapsed_ms": 0,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EC%9A%94%EC%95%BD&source=evidence",
+ "query_page": "์์ฝ",
+ "sibling_source": "evidence",
+ "selected": "์์ฝ",
+ "loading": false,
+ "previous_body_visible": true,
+ "target_body_visible": false,
+ "body_sha256": "fe2152704a93e27f9142fee10ef4963d98bc282fc30ee2713d3f34d7b47aca49",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 50,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EC%9A%94%EC%95%BD&source=evidence",
+ "query_page": "์์ฝ",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": true,
+ "target_body_visible": false,
+ "body_sha256": "fe2152704a93e27f9142fee10ef4963d98bc282fc30ee2713d3f34d7b47aca49",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 100,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EC%9A%94%EC%95%BD&source=evidence",
+ "query_page": "์์ฝ",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": true,
+ "target_body_visible": false,
+ "body_sha256": "fe2152704a93e27f9142fee10ef4963d98bc282fc30ee2713d3f34d7b47aca49",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 150,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EC%9A%94%EC%95%BD&source=evidence",
+ "query_page": "์์ฝ",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": true,
+ "target_body_visible": false,
+ "body_sha256": "fe2152704a93e27f9142fee10ef4963d98bc282fc30ee2713d3f34d7b47aca49",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 200,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EC%9A%94%EC%95%BD&source=evidence",
+ "query_page": "์์ฝ",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": true,
+ "target_body_visible": false,
+ "body_sha256": "fe2152704a93e27f9142fee10ef4963d98bc282fc30ee2713d3f34d7b47aca49",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 250,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EC%9A%94%EC%95%BD&source=evidence",
+ "query_page": "์์ฝ",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": true,
+ "target_body_visible": false,
+ "body_sha256": "fe2152704a93e27f9142fee10ef4963d98bc282fc30ee2713d3f34d7b47aca49",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 300,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EC%9A%94%EC%95%BD&source=evidence",
+ "query_page": "์์ฝ",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": true,
+ "target_body_visible": false,
+ "body_sha256": "fe2152704a93e27f9142fee10ef4963d98bc282fc30ee2713d3f34d7b47aca49",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 350,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EC%9A%94%EC%95%BD&source=evidence",
+ "query_page": "์์ฝ",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": true,
+ "target_body_visible": false,
+ "body_sha256": "fe2152704a93e27f9142fee10ef4963d98bc282fc30ee2713d3f34d7b47aca49",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 400,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EC%9A%94%EC%95%BD&source=evidence",
+ "query_page": "์์ฝ",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": true,
+ "target_body_visible": false,
+ "body_sha256": "fe2152704a93e27f9142fee10ef4963d98bc282fc30ee2713d3f34d7b47aca49",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 450,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EC%9A%94%EC%95%BD&source=evidence",
+ "query_page": "์์ฝ",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": true,
+ "target_body_visible": false,
+ "body_sha256": "fe2152704a93e27f9142fee10ef4963d98bc282fc30ee2713d3f34d7b47aca49",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 500,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EC%9A%94%EC%95%BD&source=evidence",
+ "query_page": "์์ฝ",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": true,
+ "target_body_visible": false,
+ "body_sha256": "fe2152704a93e27f9142fee10ef4963d98bc282fc30ee2713d3f34d7b47aca49",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 550,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": true,
+ "target_body_visible": false,
+ "body_sha256": "fe2152704a93e27f9142fee10ef4963d98bc282fc30ee2713d3f34d7b47aca49",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 600,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": true,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 650,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": true,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 700,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 750,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 800,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 850,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 900,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 950,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1000,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1050,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1100,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1150,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1200,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1250,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1300,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1350,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1400,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1450,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1500,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1550,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1600,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1650,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ {
+ "elapsed_ms": 1700,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ }
+ ],
+ "post": {
+ "elapsed_ms": 1700,
+ "url": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence",
+ "query_page": "๋ถ๋ฅ ๊ท์น",
+ "sibling_source": "evidence",
+ "selected": "๋ถ๋ฅ ๊ท์น",
+ "loading": false,
+ "previous_body_visible": false,
+ "target_body_visible": true,
+ "body_sha256": "87ecc82dee8866b103ec07702108a8d00ce460ff2739e4521ca6742aa6f80f60",
+ "document_marker": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "sidebar_marker": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "background": "rgb(255, 255, 255)",
+ "surfaces": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
+ "architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ }
+ },
+ "stable_shell": {
+ "document_marker": {
+ "pre": "c830670d-8588-4000-9e30-10bc0a6bfe1a",
+ "post": "c830670d-8588-4000-9e30-10bc0a6bfe1a"
+ },
+ "sidebar_marker": {
+ "pre": "fb10199d-796c-4fe1-8eff-571ad06c0224",
+ "post": "fb10199d-796c-4fe1-8eff-571ad06c0224"
+ },
+ "background": {
+ "pre": "rgb(255, 255, 255)",
+ "post": "rgb(255, 255, 255)"
+ },
+ "surfaces": {
+ "pre": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ },
+ "post": {
+ "stAppViewContainer": {
+ "backgroundImage": "radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.1), rgba(0, 0, 0, 0) 480px), radial-gradient(circle at 100% 0%, rgba(6, 182, 212, 0.1), rgba(0, 0, 0, 0) 448px), linear-gradient(rgb(248, 250, 252) 0%, rgb(255, 255, 255) 45%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "flex",
+ "opacity": "1",
+ "rendered": true
+ },
+ "stSidebar": {
+ "backgroundImage": "linear-gradient(rgb(255, 255, 255) 0%, rgb(248, 250, 252) 100%)",
+ "backgroundColor": "rgba(0, 0, 0, 0)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": true
+ }
+ }
+ }
+ },
+ "history_events": [
+ {
+ "type": "same-document-query-update",
+ "from": "https://bb8d254926f5fd.lhr.life/?page=%EC%9A%94%EC%95%BD&source=evidence",
+ "to": "https://bb8d254926f5fd.lhr.life/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&source=evidence"
+ }
+ ],
+ "document_load_events": [],
+ "navigation_observer": "The entire click and 35-frame sample ran inside one Playwright page.evaluate execution context; a full document navigation would destroy that context and reject the tool call.",
+ "checks": {
+ "document_identity_stable": true,
+ "sidebar_identity_stable": true,
+ "app_surface_background_stable": true,
+ "sidebar_surface_background_stable": true,
+ "app_surface_visible_all_frames": true,
+ "sidebar_surface_visible_all_frames": true,
+ "surface_backgrounds_observed": true,
+ "zero_full_document_loads": true,
+ "loading_observed": true,
+ "previous_body_absent_while_loading": true,
+ "query_sibling_preserved": true,
+ "final_agreement": true
+ },
+ "limitations": [
+ "Generated synthetic SQLite fixture only; no operating ledger data was used.",
+ "A temporary HTTPS ingress adapter normalized the trusted request scheme for repository same-origin authentication.",
+ "The ephemeral tunnel URL is expired provenance and is not a rerun target."
+ ]
+}
diff --git a/docs/evidence/issue-9-dashboard-menu-transition/final-state.jpg b/docs/evidence/issue-9-dashboard-menu-transition/final-state.jpg
new file mode 100644
index 0000000..049ee01
Binary files /dev/null and b/docs/evidence/issue-9-dashboard-menu-transition/final-state.jpg differ
diff --git a/docs/evidence/issue-9-dashboard-menu-transition/manifest.json b/docs/evidence/issue-9-dashboard-menu-transition/manifest.json
new file mode 100644
index 0000000..15eb3df
--- /dev/null
+++ b/docs/evidence/issue-9-dashboard-menu-transition/manifest.json
@@ -0,0 +1,68 @@
+{
+ "schema": "jarca.issue9.evidence-manifest.v2",
+ "issue": "jsfamily/jarca-ledger-mcp#9",
+ "captured_at": "2026-10-05T08:17:17.108Z",
+ "source": {
+ "git_head_base": "a02aceee416c5770dfd1f35ac94367f3c4e0c8c8",
+ "working_tree": true,
+ "files_sha256": {
+ "dashboard/app.py": "4c7b3a6264b4fb4ae0e445ddc04c625b295443816b198aa5a60615ac44846ff2",
+ "dashboard/common.py": "9a0b9dbb4ca88f27757fc293173821c48a56a30bc23f16b0f24731f246411c5a",
+ "scripts/streamlit_browser_evidence.py": "3cfb449ce1d6fae3abb795727d7ca1bc10dbb759bb811f121abd74fa42993dd7",
+ "tests/test_dashboard_menu_transition.py": "d23c35d88f6289b7c7e84a271df6ff3f6ff1f25a3a885aa696cc93db0b37035e",
+ "tests/test_streamlit_browser_evidence.py": "7f595ccf4009961225328486c19258bb0f7bc7dbfb584c60616860274112724a"
+ }
+ },
+ "execution": {
+ "browser_tool": "mcp__playwright__browser_evaluate + mcp__playwright__browser_run_code_unsafe",
+ "tool_status": "success",
+ "exit_code": null,
+ "exit_code_note": "Playwright MCP reports success/error instead of a numeric process exit code.",
+ "transition": "์์ฝ -> ๋ถ๋ฅ ๊ท์น",
+ "sampling_interval_ms": 50,
+ "frame_count": 35,
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "browser": "HeadlessChrome/154.0.0.0",
+ "browser_architecture": {
+ "client_hint_architecture": "arm",
+ "client_hint_bitness": "64",
+ "navigator_platform": "Linux x86_64"
+ },
+ "fixture_host_architecture": "aarch64",
+ "python": "3.13.5",
+ "streamlit": "1.58.0",
+ "uvicorn": "0.49.0"
+ },
+ "checks": {
+ "document_identity_stable": true,
+ "sidebar_identity_stable": true,
+ "app_surface_background_stable": true,
+ "sidebar_surface_background_stable": true,
+ "app_surface_visible_all_frames": true,
+ "sidebar_surface_visible_all_frames": true,
+ "surface_backgrounds_observed": true,
+ "zero_full_document_loads": true,
+ "loading_observed": true,
+ "previous_body_absent_while_loading": true,
+ "query_sibling_preserved": true,
+ "final_agreement": true
+ },
+ "artifacts_sha256": {
+ "browser-observations.json": "414b62efb2f40483aae4227896040c1b5d887b2c31333f63046e6f34aec2fe8f",
+ "final-state.jpg": "cc4e1c28463f033ab1c26d1dc27c9bb93c6fc5495321c0cef73fdb1098221234"
+ },
+ "validator": {
+ "command": "python3 -c \"import hashlib,json,math,pathlib; r=pathlib.Path('.'); e=r/'docs/evidence/issue-9-dashboard-menu-transition'; m=json.loads((e/'manifest.json').read_text()); d=json.loads((e/'browser-observations.json').read_text()); frames=d['frames']; surfaces=('stAppViewContainer','stSidebar'); assert all(hashlib.sha256((r/p).read_bytes()).hexdigest()==h for p,h in m['source']['files_sha256'].items()); assert all(hashlib.sha256((e/p).read_bytes()).hexdigest()==h for p,h in m['artifacts_sha256'].items()); assert all(d['checks'].values()) and not d['document_load_events'] and len(frames)==35; assert all(isinstance(f['previous_body_visible'],bool) for f in frames); assert any(f['loading'] and f['previous_body_visible'] is False for f in frames); valid=lambda x: isinstance(x,dict) and str(x.get('backgroundImage','')).strip().lower() not in ('','none') and bool(str(x.get('backgroundColor','')).strip()) and x.get('visibility')=='visible' and x.get('display')!='none' and math.isfinite(float(x.get('opacity','nan'))) and float(x['opacity'])>0 and x.get('rendered') is True; assert all(valid(f['surfaces'][s]) for f in frames for s in surfaces); ss=d['stable_shell']; assert all(isinstance(ss[k]['pre'],str) and ss[k]['pre'] and ss[k]['pre']==ss[k]['post'] for k in ('document_marker','sidebar_marker','background')); assert ss['surfaces']['pre']==frames[0]['surfaces'] and ss['surfaces']['post']==d['post']['surfaces'] and ss['surfaces']['pre']==ss['surfaces']['post']; print('PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=%d full_document_loads=0 surfaces=2' % len(frames))\"",
+ "exit_code": 0,
+ "output": "PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=35 full_document_loads=0 surfaces=2"
+ },
+ "limitations": [
+ "Generated synthetic SQLite fixture only; no operating ledger data was used.",
+ "A temporary HTTPS ingress adapter normalized the trusted request scheme for repository same-origin authentication.",
+ "The ephemeral tunnel URL is expired provenance and is not a rerun target.",
+ "The same-document no-load assertion is bound to the uninterrupted Playwright evaluation context, stable document marker, and empty document-load event record."
+ ]
+}
diff --git a/docs/evidence/issue-9-dashboard-menu-transition/rework-verification-plan.json b/docs/evidence/issue-9-dashboard-menu-transition/rework-verification-plan.json
new file mode 100644
index 0000000..36bf446
--- /dev/null
+++ b/docs/evidence/issue-9-dashboard-menu-transition/rework-verification-plan.json
@@ -0,0 +1,122 @@
+{
+ "environment": [
+ "os",
+ "kernel",
+ "architecture",
+ "python",
+ "uv"
+ ],
+ "command_env_allowlist": [
+ "PATH",
+ "HOME",
+ "TMPDIR",
+ "LANG",
+ "VIRTUAL_ENV",
+ "UV_CACHE_DIR"
+ ],
+ "secret_env_names": [],
+ "forbidden_regexes": [],
+ "hash_paths": [
+ "docs/evidence/issue-9-dashboard-menu-transition/README.md",
+ "docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json",
+ "docs/evidence/issue-9-dashboard-menu-transition/final-state.jpg",
+ "docs/evidence/issue-9-dashboard-menu-transition/manifest.json",
+ "docs/project-structure.md",
+ "docs/requirements/req-002_dashboard-menu-transition.md",
+ "docs/runtime-flow.md",
+ "scripts/streamlit_browser_evidence.py",
+ "tests/test_streamlit_browser_evidence.py"
+ ],
+ "commands": [
+ {
+ "id": "full-suite",
+ "purpose": "Run the repository full test suite at the rework source revision",
+ "argv": [
+ "uv",
+ "run",
+ "--extra",
+ "dashboard",
+ "--extra",
+ "dev",
+ "pytest"
+ ],
+ "expected_exit_code": 0,
+ "max_output_bytes": 65536,
+ "claims": [
+ "full-suite-result"
+ ]
+ },
+ {
+ "id": "browser-evidence-validator",
+ "purpose": "Validate checked-in authenticated browser evidence, source and artifact hashes",
+ "argv": [
+ "python3",
+ "-c",
+ "import hashlib,json,math,pathlib; r=pathlib.Path('.'); e=r/'docs/evidence/issue-9-dashboard-menu-transition'; m=json.loads((e/'manifest.json').read_text()); d=json.loads((e/'browser-observations.json').read_text()); frames=d['frames']; surfaces=('stAppViewContainer','stSidebar'); assert all(hashlib.sha256((r/p).read_bytes()).hexdigest()==h for p,h in m['source']['files_sha256'].items()); assert all(hashlib.sha256((e/p).read_bytes()).hexdigest()==h for p,h in m['artifacts_sha256'].items()); assert all(d['checks'].values()) and not d['document_load_events'] and len(frames)==35; assert all(isinstance(f['previous_body_visible'],bool) for f in frames); assert any(f['loading'] and f['previous_body_visible'] is False for f in frames); valid=lambda x: isinstance(x,dict) and str(x.get('backgroundImage','')).strip().lower() not in ('','none') and bool(str(x.get('backgroundColor','')).strip()) and x.get('visibility')=='visible' and x.get('display')!='none' and math.isfinite(float(x.get('opacity','nan'))) and float(x['opacity'])>0 and x.get('rendered') is True; assert all(valid(f['surfaces'][s]) for f in frames for s in surfaces); ss=d['stable_shell']; assert all(isinstance(ss[k]['pre'],str) and ss[k]['pre'] and ss[k]['pre']==ss[k]['post'] for k in ('document_marker','sidebar_marker','background')); assert ss['surfaces']['pre']==frames[0]['surfaces'] and ss['surfaces']['post']==d['post']['surfaces'] and ss['surfaces']['pre']==ss['surfaces']['post']; print('PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=%d full_document_loads=0 surfaces=2' % len(frames))"
+ ],
+ "expected_exit_code": 0,
+ "max_output_bytes": 8192,
+ "claims": [
+ "browser-evidence-result"
+ ]
+ },
+ {
+ "id": "documentation-inventory",
+ "purpose": "Validate canonical requirement and documentation remediation coverage",
+ "argv": [
+ "python3",
+ "-c",
+ "from pathlib import Path\nimport re\nq=Path('docs/requirements/req-002_dashboard-menu-transition.md').read_text(); p=Path('docs/project-structure.md').read_text(); f=Path('docs/runtime-flow.md').read_text()\nrb=re.split(r'(?=^### REQ-002\\.\\d{2}(?!-AC)\\b)',q,flags=re.M)[1:]; ab=re.split(r'(?=^### REQ-002\\.\\d{2}-AC01\\b)',q,flags=re.M)[1:]\nassert len(rb)==len(ab)==9 and all('**Impact**' in x for x in rb) and all('**Cases**' in x and '**State Invariants**' in x for x in ab)\nassert all(x in q for x in ('REQ-001.05-AC01','REQ-001.07-AC01','REQ-001.08-AC01','REQ-001.09-AC01'))\nassert all(x in p for x in ('dashboard/common.py','inject_app_css()','JARCA_COLORS','JARCA_PLOTLY_TEMPLATE','outside the replaceable body'))\nassert all(x in f for x in ('do not initiate or replay financial writes or external calls','explicit page user actions','no new idempotency contract'))\nprint('PASS requirements=9 impact=9 acceptance_criteria=9 cases=9 state_invariants=9 missing=0 ambiguous=0')"
+ ],
+ "expected_exit_code": 0,
+ "max_output_bytes": 8192,
+ "claims": [
+ "documentation-contract-result"
+ ]
+ },
+ {
+ "id": "ruff",
+ "purpose": "Run static lint checks",
+ "argv": [
+ "uv",
+ "run",
+ "--extra",
+ "dashboard",
+ "--extra",
+ "dev",
+ "ruff",
+ "check",
+ "dashboard",
+ "tests",
+ "scripts"
+ ],
+ "expected_exit_code": 0,
+ "max_output_bytes": 8192,
+ "claims": [
+ "lint-result"
+ ]
+ },
+ {
+ "id": "compile",
+ "purpose": "Compile Python sources and tests",
+ "argv": [
+ "python3",
+ "-m",
+ "compileall",
+ "-q",
+ "dashboard",
+ "tests",
+ "scripts"
+ ],
+ "expected_exit_code": 0,
+ "max_output_bytes": 8192,
+ "claims": [
+ "compile-result"
+ ]
+ }
+ ],
+ "limitations": [
+ "The authenticated browser transition was captured before this clean source commit from byte-identical hashed files; this manifest validates the tracked browser artifacts and their exact source hashes rather than rerunning the browser.",
+ "Gitea commit status and Actions state are authoritative external readbacks and are not produced by this local manifest."
+ ]
+}
diff --git a/docs/evidence/issue-9-dashboard-menu-transition/rework-verification.json b/docs/evidence/issue-9-dashboard-menu-transition/rework-verification.json
new file mode 100644
index 0000000..a52c5d3
--- /dev/null
+++ b/docs/evidence/issue-9-dashboard-menu-transition/rework-verification.json
@@ -0,0 +1 @@
+{"claims":{"browser-evidence-result":["/commands/1/argv","/commands/1/exit_code","/commands/1/expected_exit_code","/commands/1/output","/commands/1/started_at_utc","/commands/1/finished_at_utc"],"compile-result":["/commands/4/argv","/commands/4/exit_code","/commands/4/expected_exit_code","/commands/4/output","/commands/4/started_at_utc","/commands/4/finished_at_utc"],"documentation-contract-result":["/commands/2/argv","/commands/2/exit_code","/commands/2/expected_exit_code","/commands/2/output","/commands/2/started_at_utc","/commands/2/finished_at_utc"],"full-suite-result":["/commands/0/argv","/commands/0/exit_code","/commands/0/expected_exit_code","/commands/0/output","/commands/0/started_at_utc","/commands/0/finished_at_utc"],"lint-result":["/commands/3/argv","/commands/3/exit_code","/commands/3/expected_exit_code","/commands/3/output","/commands/3/started_at_utc","/commands/3/finished_at_utc"]},"commands":[{"argv":["uv","run","--extra","dashboard","--extra","dev","pytest"],"claim_ids":["full-suite-result"],"duration_seconds":89.70536988321692,"exit_code":0,"expected_exit_code":0,"finished_at_utc":"2026-10-05T08:44:42.787954Z","id":"full-suite","max_output_bytes":65536,"output":"........................................................................ [ 47%]\n............................ss.......................................... [ 94%]\n....ss... [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-9/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n149 passed, 4 skipped, 1 warning in 82.58s (0:01:22)\n","purpose":"Run the repository full test suite at the rework source revision","started_at_utc":"2026-10-05T08:43:13.082951Z"},{"argv":["python3","-c","import hashlib,json,math,pathlib; r=pathlib.Path('.'); e=r/'docs/evidence/issue-9-dashboard-menu-transition'; m=json.loads((e/'manifest.json').read_text()); d=json.loads((e/'browser-observations.json').read_text()); frames=d['frames']; surfaces=('stAppViewContainer','stSidebar'); assert all(hashlib.sha256((r/p).read_bytes()).hexdigest()==h for p,h in m['source']['files_sha256'].items()); assert all(hashlib.sha256((e/p).read_bytes()).hexdigest()==h for p,h in m['artifacts_sha256'].items()); assert all(d['checks'].values()) and not d['document_load_events'] and len(frames)==35; assert all(isinstance(f['previous_body_visible'],bool) for f in frames); assert any(f['loading'] and f['previous_body_visible'] is False for f in frames); valid=lambda x: isinstance(x,dict) and str(x.get('backgroundImage','')).strip().lower() not in ('','none') and bool(str(x.get('backgroundColor','')).strip()) and x.get('visibility')=='visible' and x.get('display')!='none' and math.isfinite(float(x.get('opacity','nan'))) and float(x['opacity'])>0 and x.get('rendered') is True; assert all(valid(f['surfaces'][s]) for f in frames for s in surfaces); ss=d['stable_shell']; assert all(isinstance(ss[k]['pre'],str) and ss[k]['pre'] and ss[k]['pre']==ss[k]['post'] for k in ('document_marker','sidebar_marker','background')); assert ss['surfaces']['pre']==frames[0]['surfaces'] and ss['surfaces']['post']==d['post']['surfaces'] and ss['surfaces']['pre']==ss['surfaces']['post']; print('PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=%d full_document_loads=0 surfaces=2' % len(frames))"],"claim_ids":["browser-evidence-result"],"duration_seconds":0.13439339399337769,"exit_code":0,"expected_exit_code":0,"finished_at_utc":"2026-10-05T08:44:42.921955Z","id":"browser-evidence-validator","max_output_bytes":8192,"output":"PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=35 full_document_loads=0 surfaces=2\n","purpose":"Validate checked-in authenticated browser evidence, source and artifact hashes","started_at_utc":"2026-10-05T08:44:42.788428Z"},{"argv":["python3","-c","from pathlib import Path\nimport re\nq=Path('docs/requirements/req-002_dashboard-menu-transition.md').read_text(); p=Path('docs/project-structure.md').read_text(); f=Path('docs/runtime-flow.md').read_text()\nrb=re.split(r'(?=^### REQ-002\\.\\d{2}(?!-AC)\\b)',q,flags=re.M)[1:]; ab=re.split(r'(?=^### REQ-002\\.\\d{2}-AC01\\b)',q,flags=re.M)[1:]\nassert len(rb)==len(ab)==9 and all('**Impact**' in x for x in rb) and all('**Cases**' in x and '**State Invariants**' in x for x in ab)\nassert all(x in q for x in ('REQ-001.05-AC01','REQ-001.07-AC01','REQ-001.08-AC01','REQ-001.09-AC01'))\nassert all(x in p for x in ('dashboard/common.py','inject_app_css()','JARCA_COLORS','JARCA_PLOTLY_TEMPLATE','outside the replaceable body'))\nassert all(x in f for x in ('do not initiate or replay financial writes or external calls','explicit page user actions','no new idempotency contract'))\nprint('PASS requirements=9 impact=9 acceptance_criteria=9 cases=9 state_invariants=9 missing=0 ambiguous=0')"],"claim_ids":["documentation-contract-result"],"duration_seconds":0.09890262316912413,"exit_code":0,"expected_exit_code":0,"finished_at_utc":"2026-10-05T08:44:43.021294Z","id":"documentation-inventory","max_output_bytes":8192,"output":"PASS requirements=9 impact=9 acceptance_criteria=9 cases=9 state_invariants=9 missing=0 ambiguous=0\n","purpose":"Validate canonical requirement and documentation remediation coverage","started_at_utc":"2026-10-05T08:44:42.922902Z"},{"argv":["uv","run","--extra","dashboard","--extra","dev","ruff","check","dashboard","tests","scripts"],"claim_ids":["lint-result"],"duration_seconds":0.1826452910900116,"exit_code":0,"expected_exit_code":0,"finished_at_utc":"2026-10-05T08:44:43.204372Z","id":"ruff","max_output_bytes":8192,"output":"All checks passed!\n","purpose":"Run static lint checks","started_at_utc":"2026-10-05T08:44:43.021868Z"},{"argv":["python3","-m","compileall","-q","dashboard","tests","scripts"],"claim_ids":["compile-result"],"duration_seconds":0.2716978546231985,"exit_code":0,"expected_exit_code":0,"finished_at_utc":"2026-10-05T08:44:43.476143Z","id":"compile","max_output_bytes":8192,"output":"","purpose":"Compile Python sources and tests","started_at_utc":"2026-10-05T08:44:43.204547Z"}],"environment":{"architecture":"aarch64","kernel":"6.18.34+rpt-rpi-v8","os":"Linux","python":"3.13.5","uv":"uv 0.11.6 (aarch64-unknown-linux-musl)"},"excluded_paths":["docs/evidence/issue-9-dashboard-menu-transition/rework-verification-plan.json","docs/evidence/issue-9-dashboard-menu-transition/rework-verification.json"],"files":[{"blob_oid":"bba48c3353a6266b523e79f97f733869b8e9dcbb","path":"docs/evidence/issue-9-dashboard-menu-transition/README.md","sha256":"eb4fefbfce82299c1f5655f613911194362c474c812dc40a2d0f3e1a49948aac"},{"blob_oid":"8a18ae47915acece61ed4ca8372d5ec6e44a51e1","path":"docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json","sha256":"414b62efb2f40483aae4227896040c1b5d887b2c31333f63046e6f34aec2fe8f"},{"blob_oid":"049ee017f96f80388cc2fee39ef452f38a5e93db","path":"docs/evidence/issue-9-dashboard-menu-transition/final-state.jpg","sha256":"cc4e1c28463f033ab1c26d1dc27c9bb93c6fc5495321c0cef73fdb1098221234"},{"blob_oid":"15eb3df527b5bec8d1ee0184460b740ffce4023e","path":"docs/evidence/issue-9-dashboard-menu-transition/manifest.json","sha256":"8190660fce780c072472b9e76e0ad1f3c71865c8aa3450edf332c9d8d8468d91"},{"blob_oid":"0c69b5ab20a46c527e8eb75a870e346e580b7328","path":"docs/project-structure.md","sha256":"2241b1f44ec8933e879c193e4be63dc7aa0fb06c235a8b1b225b1bbce52ca8fd"},{"blob_oid":"44da358850a6c911b9d3072ccf588f99b4f9cefb","path":"docs/requirements/req-002_dashboard-menu-transition.md","sha256":"6f1c7f6fde9c4dc2c001468c8e8aca3dbe198e2ac5ac6e94e53d72f92d3f1688"},{"blob_oid":"2b25cb84c53ecfa7915ab287ef808cd4d2daa65f","path":"docs/runtime-flow.md","sha256":"76141addb868dd1a7c1b27c881813bbd5b3e39915c72771d1a3057f06d6a7d56"},{"blob_oid":"3975fac9d359156e0ddb3c6ce4528f026d45dc8e","path":"scripts/streamlit_browser_evidence.py","sha256":"3cfb449ce1d6fae3abb795727d7ca1bc10dbb759bb811f121abd74fa42993dd7"},{"blob_oid":"b413fa48adc89796fbd994e3540d4cb0ac947433","path":"tests/test_streamlit_browser_evidence.py","sha256":"7f595ccf4009961225328486c19258bb0f7bc7dbfb584c60616860274112724a"}],"generated_at":"2026-10-05T08:44:43.540085Z","limitations":["The authenticated browser transition was captured before this clean source commit from byte-identical hashed files; this manifest validates the tracked browser artifacts and their exact source hashes rather than rerunning the browser.","Gitea commit status and Actions state are authoritative external readbacks and are not produced by this local manifest."],"plan":{"path":"docs/evidence/issue-9-dashboard-menu-transition/rework-verification-plan.json","sha256":"0421786d3b22bc806339681943dd97f3c68b62662fff38a3c2c513bc9b575385"},"redaction":{"checked":true,"violations":[]},"result":"success","schema":"evidence-manifest/v1","source_revision":"f01dc182ec4d9b04e30d1d5bc13652a561332f24","source_tree_clean":true}
diff --git a/docs/interface-contract.md b/docs/interface-contract.md
index e47679a..21beda7 100644
--- a/docs/interface-contract.md
+++ b/docs/interface-contract.md
@@ -221,3 +221,41 @@ Set-Cookie: __Host-jarca_dashboard_auth=; Max-Age=0; Secure; HttpOnly; SameSite=
- ์คํจ ๊ณ์ฝ: wrong credential, invalid configuration, cross-origin, malformed cookie tests
- ๊ฒฝ๊ณ ๊ณ์ฝ: expiry, rotation, oversized token, same-host different-port origin tests
- ๊ตฌํยท์คํค๋งยท์์ ์ ํฉ์ฑ: `tests/test_dashboard_auth_server.py`, `tests/test_dashboard_authentication_app.py`, `tests/test_dashboard_persistent_authentication.py`, source/document redaction search
+
+## Dashboard ๋ฉ๋ด ์ ํ ๊ณ์ฝ โ REQ-002
+
+### dashboard-menu-transition
+
+- ์ฑ
์: authenticated Dashboard์์ ๊ณตํต shell/background๋ฅผ ์ ์งํ๊ณ ์ ํ๋ page body๋ง ๊ต์ฒดํ๋ค.
+- ์ข
๋ฅยทํ๋กํ ์ฝ: Streamlit rerun, `st.radio` callback, query parameter
+- ํธ์ถ ๋์ยทoperation: `dashboard.app.main()` โ `render_sidebar_nav()` โ `render_dashboard_body()`
+- ๋ฒ์ : 1.0
+- ํธ์ถ ์ฃผ์ฒด: authenticated browser
+- ์ธ์ฆ ๋ฐฉ์: ๊ธฐ์กด `gate` ๊ณ์ฝ ์ ํ; ๋ฉ๋ด๋ ์ธ์ฆ์ ์ฐํํ์ง ์์
+- ํ์ ๊ถํ: valid Dashboard auth cookie
+
+#### ์ํ์ ์
๋ ฅ
+
+์ ํํ menu options/order๋ `์์ ์ฐ`, `์์ฝ`, `์์ฐ/๋ง๊ฐ`, `๊ฑฐ๋๋ด์ญ`, `ํ์ธํ์ ํ`, `๋ฏธ๋ถ๋ฅ`, `๋ถ๋ฅ ๊ท์น`, `์นดํ
๊ณ ๋ฆฌ`, `๊ณ์ข`, `Import`์ด๋ค. selected value๋ `st.session_state["dashboard_page"]`์ ๋๋ค. ์ง์ query์ `page`๊ฐ known option์ด๋ฉด ํด๋น page๋ฅผ, absent/unknown์ด๋ฉด `์์ฝ`์ ์ฌ์ฉํ๋ค.
+
+#### ์ถ๋ ฅยท๋ถ๋ณ์กฐ๊ฑด
+
+- callback์ `st.query_params["page"]`๋ง ๊ฐฑ์ ํ๊ณ `month`, `account_id`, `filter` ๋ฑ sibling key๋ฅผ ๋ณด์กดํ๋ค.
+- menu click์ document/path navigation์ด ์๋๋ฉฐ stable `st.empty` body container ์์์ target renderer๋ฅผ ์คํํ๋ค.
+- ๊ณตํต sidebar, refresh control, CSS/background๋ ์ ์งํ๊ณ body๋ง target content๋ก ๋์ฒดํ๋ค.
+- ๋๋ฆฐ target์๋ ` ๋ถ๋ฌ์ค๋ ์คโฆ`์ ํ์ํ ๋ค renderer ์๋ฃ ์ placeholder๋ฅผ ์ ๊ฑฐํ๋ค.
+- renderer ์์ธ๋ body ์์ `ํ์ด์ง๋ฅผ ๋ถ๋ฌ์ค์ง ๋ชปํ์ต๋๋ค. ๋ค์ ์๋ํด ์ฃผ์ธ์.`์ retry control๋ก ๋์ฒดํ๋ฉฐ ์์ธ ์์ธ๋ฅผ ๋
ธ์ถํ์ง ์๋๋ค.
+- serialized callback์ด ์ฌ๋ฌ ๋ฒ ์คํ๋๋ฉด ๋ง์ง๋ง selection์ด query์ body์ ์ต์ข
์ํ๊ฐ ๋๋ค.
+
+#### ์ค๋ฅ
+
+| ์ฝ๋ | ๋ถ๋ฅ | ๋ฐ์ ์กฐ๊ฑด | ์ฌ์๋ ๊ฐ๋ฅ ์ฌ๋ถ | ํธ์ถ์ ๋์ | ์คํจ ์ ์ํ ๋ณ๊ฒฝ |
+|---|---|---|---|---|---|
+| `page_fallback` | request | missing/unknown `page` | ๊ฐ๋ฅ | `์์ฝ` ํ์ | sibling query ์ ์ง |
+| `renderer_unavailable` | rendering | target renderer exception | ๊ฐ๋ฅ | generic error์ retry ํด๋ฆญ | previous body ์ ๊ฑฐ, auth/shell ์ ์ง |
+
+#### ๊ฒ์ฆ ๊ฒฝ๊ณ
+
+AppTest๋ callback, query mapping, synthetic renderer output๊ณผ error/loading event๋ฅผ ๊ฒ์ฆํ์ง๋ง ์ค์ browser์ document navigation ๋ถ์ฌ, paint timing, focus/scroll, network request ๋๋ perceived shell continuity๋ฅผ ์ฆ๋ช
ํ์ง ์๋๋ค. ๋ฐ๋ผ์ `REQ-002.03-AC01`์ authenticated real-browser evidence๊ฐ ํ์์ด๋ฉฐ AppTest๋ companion evidence์ผ ๋ฟ์ด๋ค. history/back-forward์ path URL์ ๊ณ์ฝ์์ ์ ์ธํ๋ค.
+
+์์ธ acceptance target์ `docs/requirements/req-002_dashboard-menu-transition.md`์ `REQ-002.01-AC01`๋ถํฐ `REQ-002.09-AC01`๊น์ง๋ฅผ ๋ฐ๋ฅธ๋ค.
diff --git a/docs/project-structure.md b/docs/project-structure.md
index 60ac861..0c69b5a 100644
--- a/docs/project-structure.md
+++ b/docs/project-structure.md
@@ -17,6 +17,7 @@ dashboard/
โโโ auth_server.py
โโโ authentication.py
โโโ app.py
+โโโ common.py
โโโ page_views/
```
@@ -25,9 +26,12 @@ dashboard/
| `dashboard/server.py` | source | ASGI app ์กฐ๋ฆฝ | `app` | route wiring only | source review required | developer |
| `dashboard/auth_server.py` | source | login/cleanup/logout response | `create_auth_routes()` | no DB/page imports | source review required | developer |
| `dashboard/authentication.py` | source | config, constant-time compare, token verify, gate | `require_dashboard_authentication()` | server-only secret use | source review required | developer |
-| `dashboard/app.py` | source | gate then page dispatch | `main()` | no token signing details | source review required | developer |
+| `dashboard/app.py` | source | auth gate first, then DB path resolution, common shell/sidebar/refresh assembly, and target body renderer dispatch | `main()` | shell/background remain outside the replaceable body; no token signing details | source review required | developer |
+| `dashboard/common.py` | source | shared Dashboard shell, menu/query transition, stable body container, loading/error/retry rendering, and app background/style | `render_sidebar_nav()`, `render_dashboard_body()`, `inject_app_css()` | no authentication transport or financial write orchestration | source review required | developer |
| `dashboard/page_views/` | source | financial UI pages | `page_*` functions | no auth transport calls | source review required | developer |
+`dashboard/common.py` owns the shared visual theme through `JARCA_COLORS`, `JARCA_PLOTLY_TEMPLATE`, and `inject_app_css()`. Page renderers consume that theme but do not own or replace the authenticated shell/sidebar background.
+
### verification-tests โ ์ธ์ฆ ๊ฒ์ฆ
```text
@@ -35,6 +39,8 @@ tests/
โโโ test_dashboard_authentication.py
โโโ test_dashboard_persistent_authentication.py
โโโ test_dashboard_auth_server.py
+โโโ test_dashboard_menu_transition.py
+โโโ test_streamlit_browser_evidence.py
โโโ test_dashboard_authentication_app.py
```
@@ -43,12 +49,23 @@ tests/
| `tests/test_dashboard_authentication.py` | test | credential compare, delay, redaction | pytest tests | test-only settings | source change paired with test | developer |
| `tests/test_dashboard_persistent_authentication.py` | test | claims, TTL, tamper, rotation | pytest tests | no real secrets | source change paired with test | developer |
| `tests/test_dashboard_auth_server.py` | test | HTTP routes and cookie scope | pytest tests | synthetic test credentials | source change paired with test | developer |
+| `tests/test_dashboard_menu_transition.py` | test | REQ-002 menu/query/body transition and cache-clear regression | pytest/AppTest | synthetic fixture DB; no real secrets | source change paired with test and browser evidence where required | developer |
+| `tests/test_streamlit_browser_evidence.py` | test | transition evidence manifest, redaction, TLS fixture and fail-closed validation | pytest | generated synthetic values only; no captured credential values | evidence-harness change paired with test | developer |
| `tests/test_dashboard_authentication_app.py` | test | gate ordering and cleanup/UI | AppTest | fixture DB only | source change paired with test | developer |
+### browser-evidence โ ์ค์ ์ ํ ๊ด์ฐฐ
+
+| ๊ฒฝ๋ก | ์ข
๋ฅ | ์ฑ
์ | ๊ณต๊ฐ ์ง์
์ | ๋ด๋ถ ๊ตฌํ ๊ฒฝ๊ณ | ํธ์ง ์ ์ฑ
| ์์ฑ ์ฃผ์ฒด |
+|---|---|---|---|---|---|---|
+| `scripts/streamlit_browser_evidence.py` | verification tool | synthetic DB์ runtime ์์ฑ ์ธ์ฆ ์ค์ ์ผ๋ก ์ค์ HTTPS Dashboard ์ ํ์ frame/DOM/document/shell ๊ด์ฐฐ์ ์์งํ๊ณ redacted manifest๋ฅผ ๊ฒ์ฆ | `--mode transition` | ์ด์ DBยทcredential ์ฌ์ฉ ๊ธ์ง; ์ฑ๊ณต ์ฆ๊ฑฐ๋ browser/server teardown ๋ค์๋ง ํ์ | source review and focused test required | developer |
+| `docs/evidence/issue-9-dashboard-menu-transition/` | evidence artifact | exact source hash์ ๊ฒฐํฉํ authenticated browser frame, screenshot, runtime, validator ๊ฒฐ๊ณผ | `manifest.json` | synthetic fixture only; credential/token/cookie ๊ฐ ๊ธ์ง | immutable after verification except complete regeneration | verification runner |
+
### deployment-docs โ ์ด์ ๋ฌธ์
```text
docs/requirements/req-001_dashboard-administrator-authentication.md
+docs/requirements/req-002_dashboard-menu-transition.md
+docs/evidence/issue-9-dashboard-menu-transition/
docs/interface-contract.md
docs/runtime-flow.md
README.md
@@ -58,6 +75,7 @@ deploy/k8s/README.md
| ๊ฒฝ๋ก | ์ข
๋ฅ | ์ฑ
์ | ๊ณต๊ฐ ์ง์
์ | ๋ด๋ถ ๊ตฌํ ๊ฒฝ๊ณ | ํธ์ง ์ ์ฑ
| ์์ฑ ์ฃผ์ฒด |
|---|---|---|---|---|---|---|
| `docs/requirements/req-001_dashboard-administrator-authentication.md` | requirement | approved REQ-001 revision 2.0 | REQ-001 | canonical traceability | update in place | developer |
+| `docs/requirements/req-002_dashboard-menu-transition.md` | requirement | approved REQ-002 revision 1.0 | REQ-002 | canonical traceability | update in place | developer |
| `docs/interface-contract.md` | contract | HTTP/cookie/gate contracts | contract IDs | no implementation secrets | update with API changes | developer |
| `docs/runtime-flow.md` | design | runtime states/transitions | flow IDs | no secret values | update with flow changes | developer |
| `README.md` | operations | local/deployment constraints | operator commands | no secret values | operational review | developer |
@@ -71,15 +89,16 @@ deploy/k8s/README.md
| `dashboard.server` | `dashboard.app` | ํ์ฉ | app remains protected by gate | `docs/runtime-flow.md` |
| `dashboard.app` | `dashboard.authentication` | ํ์ฉ | gate precedes DB/page dispatch | `docs/interface-contract.md` |
| `dashboard.authentication` | `dashboard.page_views` | ๊ธ์ง | auth module never imports financial pages | `docs/project-structure.md` |
+| `dashboard.app` | `dashboard.common` | ํ์ฉ | menu/query/body transition helpers only after auth gate | `docs/interface-contract.md` |
| `dashboard.auth_server` | `jarca_ledger_mcp` DB | ๊ธ์ง | login/cleanup/logout are DB-free | `docs/requirements/req-001_dashboard-administrator-authentication.md` |
## ๋ณ๊ฒฝ ๊ท์น
| ๊ตฌ๋ถ | ๊ท์น |
|---|---|
-| ์ ๋ชจ๋ยทํ์ผ ๋ฐฐ์น | auth route/gate changes stay in `dashboard/`; no DB session module |
+| ์ ๋ชจ๋ยทํ์ผ ๋ฐฐ์น | auth route/gate changes stay in `dashboard/`; menu transition stays in existing `dashboard.app`/`dashboard.common`; no DB session module |
| ์ด๋ฆยทํจํค์ง | use existing `dashboard` package and snake_case modules |
-| ์์คยทํ
์คํธ ๋์ | auth source behavior requires focused pytest/AppTest coverage |
+| ์์คยทํ
์คํธ ๋์ | auth source behavior requires focused pytest/AppTest coverage; REQ-002 transition behavior requires focused pytest/AppTest targets and real-browser evidence where specified |
| ์์ฑ ์ฐ์ถ๋ฌผ | evidence may be redacted; never commit credentials, secrets, tokens, or cookie values |
## ๊ฒ์ฆ
@@ -98,4 +117,4 @@ deploy/k8s/README.md
```
- ์ ์ ๋ถ์ ๊ธฐ๋ ๊ฒฐ๊ณผ: exit 0 with no diagnostics
-- ๋ฌธ์์ ์ค์ ํธ๋ฆฌ์ ์ ํฉ์ฑ: every listed source/test/document path exists; dependency table has no forbidden reverse edge
+- ๋ฌธ์์ ์ค์ ํธ๋ฆฌ์ ์ ํฉ์ฑ: every listed source/test/document path exists; dependency table has no forbidden reverse edge; REQ-002 symbols resolve to `dashboard/app.py`, `dashboard/common.py`, and named tests
diff --git a/docs/requirements/req-002_dashboard-menu-transition.md b/docs/requirements/req-002_dashboard-menu-transition.md
new file mode 100644
index 0000000..44da358
--- /dev/null
+++ b/docs/requirements/req-002_dashboard-menu-transition.md
@@ -0,0 +1,427 @@
+---
+{
+ "schema": "gitea-agent.requirement/v1",
+ "id": "REQ-002",
+ "title": "Dashboard ๋ฉ๋ด ์ ํ ์ ๊ณตํต ํ๋ฉด ์ฐ์์ฑ",
+ "revision": "1.0",
+ "status": "approved",
+ "depends_on": ["REQ-001"],
+ "applies_to": ["dashboard"],
+ "language": "ko"
+}
+---
+
+# REQ-002 โ Dashboard ๋ฉ๋ด ์ ํ ์ ๊ณตํต ํ๋ฉด ์ฐ์์ฑ
+
+## 1. ๋ชฉ์
+
+์ธ์ฆ๋ Dashboard์์ ๋ฉ๋ด๋ฅผ ๋ฐ๊ฟ ๋ ๋ฌธ์ ์ ์ฒด๋ฅผ ์ด๋์ํค์ง ์๊ณ ๊ณตํต shell๊ณผ ๋ฐฐ๊ฒฝ์ ์ ์งํ๋ฉฐ body๋ง ๊ต์ฒดํ๋ค. ํ์ฌ ๋ฉ๋ด ์ ํ, URL ์ํ, ๋๋ฆฐ ๋ ๋๋ง, ๋ ๋๋ง ์คํจ์ ๋์์ ์์ธก ๊ฐ๋ฅํ๊ฒ ๋ง๋ ๋ค.
+
+## 2. ๋ฐฐ๊ฒฝ
+
+Issue #9์ Dashboard ๋ฉ๋ด ์ ํ ๊ณ์ฝ์ด๋ค. REQ-001์ ์ธ์ฆ ๊ฒฝ๊ณ, cookie, logout, page dispatch์ ๊ธฐ์กด ๊ธ์ต ํ์ด์ง ๊ณ์ฝ์ ์ ์งํ ์ฑ Streamlit ๋ฉ๋ด ์ ํ์ ์ฐ์์ ์ธ ๋จ์ผ ํ๋ฉด์ผ๋ก ์ ๊ณตํ๋ค.
+
+## 3. ๋ฒ์
+
+### ํฌํจ
+
+- Streamlit `st.radio` ๊ธฐ๋ฐ ๋ฉ๋ด์ ์ ํํ 10๊ฐ label/order์ selected state
+- ๋ฉ๋ด ํด๋ฆญ ์ document navigation ์์ด ๊ณตํต shell/background๋ฅผ ์ ์งํ๊ณ body๋ง ๊ต์ฒด
+- ๋๋ฆฐ ๋์ page ๋ ๋๋ง ์ค ๋์๋ณ loading ํ์
+- renderer ์์ธ์ local generic error์ retry
+- ๋น ๋ฅธ ์ฐ์ ์ ํ์์ latest serialized selection wins
+- ์ง์ `?page=` ์ ๊ทผ, ๋๋ฝ/unknown page์ `์์ฝ` fallback, sibling query key ๋ณด์กด
+- ๊ธฐ์กด auth gate, cookie, logout, finance page, cache-clear ํ๊ท ๊ฒ์ฆ
+
+### ์ ์ธ
+
+- browser history/back-forward ๋์, history API ๊ณ์ฝ, path URL ์ค๊ณ
+- ์ธ์ฆ ๋ฐฉ์ยทcookie ์์ฑยทMCP transportยทSQLite schema ๋ณ๊ฒฝ
+- page renderer์ ๊ธ์ต ๊ณ์ฐยทgateway ์๋ฏธ ๋ณ๊ฒฝ
+- ๋ณ๋ document route ๋๋ full-document navigation ๊ตฌํ
+
+## 4. ์ฉ์ด์ ๊ธฐ์ค
+
+- **๊ณตํต shell**: ์ธ์ฆ ํ `dashboard/app.py`๊ฐ ์ ๊ณตํ๋ sidebar, ๋ฉ๋ด, refresh control ๋ฐ ์ฑ ๋ฐฐ๊ฒฝ/CSS.
+- **body**: ์ ํ๋ page renderer๊ฐ ๊ณตํต shell ์์์ ์ฑ์ฐ๋ ์ฃผ ์ฝํ
์ธ ์์ญ.
+- **latest serialized selection**: Streamlit rerun/์ฝ๋ฐฑ ์ง๋ ฌํ ์์์์ ๋ง์ง๋ง์ผ๋ก ๋ฐ์๋ `dashboard_page` ์ ํ.
+- **AppTest**: Streamlit `streamlit.testing.v1.AppTest`์ synthetic runtime ๊ฒ์ฆ. DOM, ์ค์ browser document navigation, ์ค์ paint/์ง์ฐ ์ฒด๊ฐ์ ๊ฒ์ฆํ์ง ๋ชปํ๋ค.
+- **real-browser evidence**: ์ค์ browser์์ authenticated session์ผ๋ก ๋ฉ๋ด๋ฅผ ํด๋ฆญํ๊ณ URL/document/body/shell์ ๊ด์ฐฐํด ๋จ๊ธฐ๋ ์ฆ๊ฑฐ. AppTest๋ฅผ ๋์ฒดํ์ง ์์ผ๋ฉฐ AppTest๋ง์ผ๋ก ์ถฉ์กฑํ ์ ์๋ browser-level ํญ๋ชฉ์ ํ์๋ค.
+
+## 5. ์๊ตฌ์ฌํญ
+
+### REQ-002.01 โ ๋ฉ๋ด ์ ์ฒด์ฑ
+
+๋ฉ๋ด๋ ์ ํํ ๋ค์ ์์์ label์ ์ ์งํ๋ค: `์์ ์ฐ`, `์์ฝ`, `์์ฐ/๋ง๊ฐ`, `๊ฑฐ๋๋ด์ญ`, `ํ์ธํ์ ํ`, `๋ฏธ๋ถ๋ฅ`, `๋ถ๋ฅ ๊ท์น`, `์นดํ
๊ณ ๋ฆฌ`, `๊ณ์ข`, `Import`. selected state๋ ํ์ฌ page์ ์ผ์นํ๋ค.
+
+**Impact**
+
+- Interfaces: `dashboard_page` radio and `page` query parameter
+- Persistence: none
+- Consumers: dashboard page renderers
+- Migration: none
+- Compatibility: existing labels, order, and selected-state contract
+- Deployment prerequisites: none
+
+### REQ-002.02 โ URL๊ณผ ์ง์ ์ ๊ทผ
+
+๋ฉ๋ด ์ ํ์ query์ `page`๋ง ๋์ label๋ก ๊ฐฑ์ ํ๊ณ `month`, `account_id`, `filter` ๋ฑ sibling query key๋ฅผ ๋ณด์กดํ๋ค. ์ง์ `?page=`์ ํด๋น page๋ฅผ ๋ ๋๋งํ๊ณ , missing/unknown ๊ฐ์ `์์ฝ`์ผ๋ก fallbackํ๋ค.
+
+**Impact**
+
+- Interfaces: browser query parameters
+- Persistence: none
+- Consumers: direct links and page renderers
+- Migration: none
+- Compatibility: sibling query keys and direct page access
+- Deployment prerequisites: none
+
+### REQ-002.03 โ ์ฐ์ ํ๋ฉด
+
+๋ฉ๋ด ํด๋ฆญ์ document navigation์ ์ผ์ผํค์ง ์๋๋ค. ๊ณตํต shell๊ณผ background๋ ์ ์งํ๊ณ body๋ง ์ ํ๋ renderer์ ๋ด์ฉ์ผ๋ก ๊ต์ฒดํ๋ค. history/back-forward์ path URL์ ์ด ์๊ตฌ์ฌํญ์ ํ์ ๋ฒ์๊ฐ ์๋๋ค.
+
+**Impact**
+
+- Interfaces: Streamlit shell/body rendering
+- Persistence: none
+- Consumers: authenticated dashboard users
+- Migration: none
+- Compatibility: existing shell, background, and path behavior
+- Deployment prerequisites: none
+
+### REQ-002.04 โ ์ ํ ์ค ํ์
+
+๋๋ฆฐ renderer๊ฐ ์คํ๋๋ ๋์ target page label์ ํฌํจํ `๋ถ๋ฌ์ค๋ ์คโฆ` loading์ ๋์ body ์์ญ์ ํ์ํ๊ณ , renderer ์๋ฃ ํ loading placeholder๋ฅผ ์ ๊ฑฐํ๋ค.
+
+**Impact**
+
+- Interfaces: target body loading state
+- Persistence: none
+- Consumers: dashboard users
+- Migration: none
+- Compatibility: existing renderer output after completion
+- Deployment prerequisites: none
+
+### REQ-002.05 โ local failure์ retry
+
+target renderer ์์ธ๋ ๊ณตํต shell์ ๋ฌด๋๋จ๋ฆฌ์ง ์๊ณ body ์์ญ์ generic error์ `๋ค์ ์๋` control๋ก ๊ต์ฒดํ๋ค. ์์ธ ์์ธยทsecret์ UI์ ๋
ธ์ถํ์ง ์๋๋ค.
+
+**Impact**
+
+- Interfaces: body error and retry control
+- Persistence: none
+- Consumers: dashboard users and support operators
+- Migration: none
+- Compatibility: shell remains available; secrets remain redacted
+- Deployment prerequisites: none
+
+### REQ-002.06 โ ์ต์ ์ ํ ์ฐ์
+
+์ฐ์ ๋ฉ๋ด ์ ํ์ serialized callback ์์์ ๋ง์ง๋ง ์ ํ์ query์ body์ ๋ฐ์ํ๋ฉฐ ์ด์ ์ ํ์ด ๋ง์ง๋ง ์ํ๋ฅผ ๋ฎ์ด์ฐ์ง ์๋๋ค.
+
+**Impact**
+
+- Interfaces: serialized menu callback and query state
+- Persistence: none
+- Consumers: page selection state
+- Migration: none
+- Compatibility: latest-selection ordering contract
+- Deployment prerequisites: none
+
+### REQ-002.07 โ ์ธ์ฆ ๊ฒฝ๊ณ ๋ณด์กด
+
+๋ชจ๋ menu/page dispatch๋ REQ-001 gate์ cookie ๊ฒ์ฆ ๋ค์๋ง ์คํ๋๋ค. no-cookie/invalid-cookie๋ page body์ ๋๋ฌํ์ง ์์ผ๋ฉฐ valid cookie๋ ๊ธฐ์กด Dashboard์ ์ ๊ทผํ๋ค.
+
+**Impact**
+
+- Interfaces: REQ-001 authentication gate and cookie verification
+- Persistence: none
+- Consumers: authenticated dashboard dispatch
+- Migration: none
+- Compatibility: REQ-001 expiry, rotation, cleanup, and logout behavior
+- Deployment prerequisites: existing REQ-001 settings
+
+### REQ-002.08 โ ๊ธ์ต page ํ๊ท ์์
+
+์์ฝ, ์์ฐ/๋ง๊ฐ, ๋ถ๋ฅ ๊ท์น, ํ์ธํ์ ํ, ์์ ์ฐ์ ๊ธฐ์กด synthetic fixture DB ํต์ฌ ๋ด์ฉ๊ณผ ๊ธฐ์กด page query ๊ณ์ฝ์ ์ ์งํ๋ค.
+
+**Impact**
+
+- Interfaces: existing page/query contracts
+- Persistence: existing synthetic fixture DB reads and writes
+- Consumers: financial page renderers
+- Migration: none
+- Compatibility: existing financial calculations and gateway semantics
+- Deployment prerequisites: existing fixture and page setup
+
+### REQ-002.09 โ ์ด์ control ํ๊ท ์์
+
+refresh control์ ๊ธฐ์กด `clear_cache` callable ๊ณ์ฝ์ ์ ์งํ๊ณ , ๋ฉ๋ด ์ ํ์ cache-clear ๋์์ ๋ณ๊ฒฝํ์ง ์๋๋ค.
+
+**Impact**
+
+- Interfaces: `clear_cache` callable and refresh control
+- Persistence: none
+- Consumers: dashboard operators
+- Migration: none
+- Compatibility: existing cache-clear behavior
+- Deployment prerequisites: none
+
+## 6. ์ ์ฝ๊ณผ ๋ถ๋ณ์กฐ๊ฑด
+
+- `dashboard/app.py`์ auth gate์ page dispatch๋ ์ ์งํ๋ฉฐ ์ธ์ฆ ์ ์ DB path๋ฅผ resolveํ์ง ์๋๋ค.
+- ๋ฉ๋ด ํด๋ฆญ์ full document reload ๋๋ path navigation์ ์ถ๊ฐํ์ง ์๋๋ค.
+- body ๊ต์ฒด๋ stable `st.empty` container ์์์ ์ํํ๋ฉฐ ๊ณตํต shell/background๋ฅผ ์ญ์ ํ์ง ์๋๋ค.
+- loading/error/retry๋ target body์ ๊ตญ์์ ์ผ๋ก ํ์ํ๊ณ generic error ์ธ renderer exception ๋ด์ฉ์ ๊ณต๊ฐํ์ง ์๋๋ค.
+- query update๋ `page` key๋ง ๋ณ๊ฒฝํ๋ฉฐ sibling page-specific key๋ฅผ ์ญ์ ํ๊ฑฐ๋ ์ ๊ทํํ์ง ์๋๋ค.
+- REQ-001์ auth/cookie/logout, financial gateway/page, cache-clear, MCP ๋ฐ SQLite semantics๋ ๋ณ๊ฒฝํ์ง ์๋๋ค.
+
+## 7. ์ธ์ ๊ธฐ์ค
+
+๊ฐ ๊ธฐ์ค์ ํ๋์ ๋ช
์๋ test/evidence target์ผ๋ก ํ์ ํ๋ค. AppTest๋ synthetic runtime ํ์ ์ด๋ฉฐ real-browser evidence๊ฐ ํ์ํ ๊ธฐ์ค์ ๋์ ํ์ง ์๋๋ค.
+
+### REQ-002.01-AC01 โ label/order/selected
+
+**Cases**: positive, unchanged-state
+
+**State Invariants**: labels/order and selected state remain exact.
+
+**ํ์ ๊ธฐ์ค**
+
+์ ํํ 10๊ฐ label๊ณผ ์์๊ฐ radio options๋ก ์ ๋ฌ๋๊ณ `dashboard_page` selected state๊ฐ ์ ์ง๋๋ค.
+
+**๊ฒ์ฆ**
+
+`tests/test_dashboard_menu_transition.py::test_dashboard_menu_labels_and_order_are_preserved`
+
+**๊ฒ์ฆ ๋ถ๋ฅ**
+
+- ์์ค: unit
+- ๋ฐฑ์๋: pytest monkeypatch
+- ์ฆ๊ฑฐ: radio options, key, label visibility assertion
+
+### REQ-002.02-AC01 โ query์ fallback
+
+**Cases**: positive, boundary, unchanged-state
+
+**State Invariants**: only `page` changes; sibling query keys remain unchanged.
+
+**ํ์ ๊ธฐ์ค**
+
+callback์ `page`๋ง ๊ฐฑ์ ํ๊ณ sibling key๋ฅผ ๋ณด์กดํ๋ฉฐ, missing/unknown์ `์์ฝ`, known direct query๋ ์์ฒญ page๋ฅผ ์ ํํ๋ค.
+
+**๊ฒ์ฆ**
+
+`tests/test_dashboard_menu_transition.py::test_sidebar_selection_updates_only_page_query_parameter`, `tests/test_dashboard_menu_transition.py::test_missing_or_unknown_page_falls_back_to_overview`, `tests/test_dashboard_menu_transition.py::test_direct_page_query_access_renders_requested_page`
+
+**๊ฒ์ฆ ๋ถ๋ฅ**
+
+- ์์ค: unit/integration
+- ๋ฐฑ์๋: pytest monkeypatch
+- ์ฆ๊ฑฐ: exact query mapping and selected renderer assertion
+
+### REQ-002.03-AC01 โ body-only transition
+
+**Cases**: positive, unchanged-state
+
+**State Invariants**: document, shell, and background remain stable; body changes only.
+
+**ํ์ ๊ธฐ์ค**
+
+์ค์ browser์์ authenticated menu click ํ document URL path์ full-document identity๊ฐ ๋ฐ๋์ง ์๊ณ ๊ณตํต sidebar/background๊ฐ ๋จ์ผ๋ฉฐ target body๋ง ๋ฐ๋๋ค. history/back-forward์ path URL์ ํ์ ํ์ง ์๋๋ค.
+
+**๊ฒ์ฆ**
+
+Required real-browser evidence: `docs/evidence/issue-9-dashboard-menu-transition/`์ authenticated `์์ฝ` โ `๋ถ๋ฅ ๊ท์น` native click ์ฐ์ frame, screenshot, DOM, URL, document/sidebar identity์ background ๊ด์ฐฐ. `manifest.json`์ ์ ํํ working-tree file hash, runtime viewportยทintervalยทํ๊ฒฝยท์ ์ฝ, artifact hash ๋ฐ validator exit code๋ฅผ ๊ธฐ๋กํ๋ค. ์ด ์คํ์ local Chrome ๋ถ์ฌ ๋๋ฌธ์ Playwright MCP browser runner๋ก ์์งํ์ผ๋ฉฐ, ์ฌํ ๊ฐ๋ฅํ local fail-closed harness๋ `scripts/streamlit_browser_evidence.py --mode transition`๊ณผ `tests/test_streamlit_browser_evidence.py`๋ก ์ ์งํ๋ค. AppTest companion: `tests/test_dashboard_menu_transition.py::test_streamlit_menu_click_updates_body_and_preserves_page_query_siblings`.
+
+**๊ฒ์ฆ ๋ถ๋ฅ**
+
+- ์์ค: browser evidence + integration companion
+- ๋ฐฑ์๋: real browser, Streamlit AppTest
+- ํ๊ณ: AppTest๋ ์ค์ document navigation, browser paint, focus/scroll, network request, perceived continuity๋ฅผ ์ฆ๋ช
ํ์ง ๋ชปํ๋ค.
+
+### REQ-002.04-AC01 โ target loading
+
+**Cases**: positive, boundary
+
+**State Invariants**: loading appears before completion and is removed after rendering.
+
+**ํ์ ๊ธฐ์ค**
+
+slow renderer ๋์ target label loading์ด ๋จผ์ ํ์๋๊ณ ์๋ฃ ํ placeholder๊ฐ ์ ๊ฑฐ๋๋ค.
+
+**๊ฒ์ฆ**
+
+`tests/test_dashboard_menu_transition.py::test_slow_transition_clears_previous_body_and_shows_target_loading`; real-browser frames: `docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json`
+
+**๊ฒ์ฆ ๋ถ๋ฅ**
+
+- ์์ค: unit + browser evidence
+- ๋ฐฑ์๋: pytest fake Streamlit placeholder, authenticated Playwright browser
+- ์ฆ๊ฑฐ: ordered event list์ 50 ms ์ฐ์ frame์์ `loading=true`, `previous_body_visible=false`, ์ต์ข
`loading=false`
+- ํ๊ณ: browser evidence๋ ์ค์ authenticated transition์ 50 ms ๊ฐ๊ฒฉ์ผ๋ก ํ๋ณธํํ์ผ๋ฉฐ loading์ 2๊ฐ frame์์ ๊ด์ฐฐ๋๋ค. ํ๋ณธ ์ฌ์ด์ ๋ชจ๋ ์๊ฐ์ ๋ณด์ฅํ์ง ์๋๋ค.
+
+### REQ-002.05-AC01 โ local error/retry
+
+**Cases**: negative, positive
+
+**State Invariants**: shell remains intact; renderer details and secrets are not exposed.
+
+**ํ์ ๊ธฐ์ค**
+
+renderer ์์ธ๊ฐ generic error/retry body๋ก ๊ตญ์ ๋์ฒด๋๊ณ ์์ธ ์์ธ๊ฐ ๋
ธ์ถ๋์ง ์๋๋ค.
+
+**๊ฒ์ฆ**
+
+`tests/test_dashboard_menu_transition.py::test_body_error_is_local_and_retry_rerenders_target`
+
+**๊ฒ์ฆ ๋ถ๋ฅ**
+
+- ์์ค: unit
+- ๋ฐฑ์๋: pytest monkeypatch
+- ์ฆ๊ฑฐ: error text, retry event, secret-detail absence assertion
+
+### REQ-002.06-AC01 โ latest selection
+
+**Cases**: concurrency, unchanged-state
+
+**State Invariants**: latest serialized selection is final.
+
+**ํ์ ๊ธฐ์ค**
+
+์ฐ์ ์ ํ `๊ฑฐ๋๋ด์ญ` โ `์์ฐ/๋ง๊ฐ` โ `Import` ํ query๊ฐ ๋ง์ง๋ง `Import`์ด๊ณ sibling key๊ฐ ๋ณด์กด๋๋ค.
+
+**๊ฒ์ฆ**
+
+`tests/test_dashboard_menu_transition.py::test_rapid_menu_clicks_commit_latest_selection_only`
+
+**๊ฒ์ฆ ๋ถ๋ฅ**
+
+- ์์ค: unit
+- ๋ฐฑ์๋: pytest monkeypatch
+- ์ฆ๊ฑฐ: final query mapping
+
+### REQ-002.07-AC01 โ auth regression
+
+**Cases**: negative, positive
+
+**State Invariants**: auth gate precedes DB/page dispatch; cookie semantics remain REQ-001.
+
+**ํ์ ๊ธฐ์ค**
+
+valid cookie๊ฐ ๊ธฐ์กด Dashboard๋ฅผ ์ด๊ณ cookie๊ฐ session state์ ์ ์ฅ๋์ง ์์ผ๋ฉฐ gate rejection์ DB/page dispatch ์ ์ ์ข
๋ฃ๋๋ค.
+
+**๊ฒ์ฆ**
+
+`tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state`, `tests/test_dashboard_authentication_app.py::test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects`
+
+**๊ฒ์ฆ ๋ถ๋ฅ**
+
+- ์์ค: integration regression
+- ๋ฐฑ์๋: Streamlit AppTest, synthetic fixture DB
+- ์ฆ๊ฑฐ: authenticated body and pre-gate forbidden DB sentinel
+
+### REQ-002.08-AC01 โ finance page regression
+
+**Cases**: positive, unchanged-state
+
+**State Invariants**: existing financial page results and query contracts remain unchanged.
+
+**ํ์ ๊ธฐ์ค**
+
+๊ธฐ์กด ํต์ฌ ๊ธ์ต page์ expected text์ synthetic fixture DB ๊ฒฐ๊ณผ๊ฐ ์ ์ง๋๋ค.
+
+**๊ฒ์ฆ**
+
+`tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db`
+
+**๊ฒ์ฆ ๋ถ๋ฅ**
+
+- ์์ค: regression integration
+- ๋ฐฑ์๋: Streamlit AppTest, synthetic SQLite
+- ์ฆ๊ฑฐ: page expectation and metric assertions
+
+### REQ-002.09-AC01 โ cache-clear regression
+
+**Cases**: positive, unchanged-state
+
+**State Invariants**: `clear_cache` remains callable and menu transition does not alter it.
+
+**ํ์ ๊ธฐ์ค**
+
+refresh control์ด callable `clear_cache` ๊ณ์ฝ์ ์ ์งํ๋ค.
+
+**๊ฒ์ฆ**
+
+`tests/test_dashboard_menu_transition.py::test_refresh_control_preserves_cache_clear_contract`
+
+**๊ฒ์ฆ ๋ถ๋ฅ**
+
+- ์์ค: regression unit
+- ๋ฐฑ์๋: pytest
+- ์ฆ๊ฑฐ: callable/name assertion
+
+### Acceptance Criteria canonical fields
+
+Each Acceptance Criterion has explicit `Cases` and `State Invariants` in addition to the existing Korean verification text.
+
+| Acceptance Criterion | Cases | State Invariants |
+|---|---|---|
+| `REQ-002.01-AC01` | positive, unchanged-state | labels/order and selected state remain exact |
+| `REQ-002.02-AC01` | positive, boundary, unchanged-state | only `page` changes; sibling query keys remain unchanged |
+| `REQ-002.03-AC01` | positive, unchanged-state | document, shell, and background remain stable; body changes only |
+| `REQ-002.04-AC01` | positive, boundary | loading appears before completion and is removed after rendering |
+| `REQ-002.05-AC01` | negative, positive | shell remains intact; renderer details and secrets are not exposed |
+| `REQ-002.06-AC01` | concurrency, unchanged-state | latest serialized selection is final |
+| `REQ-002.07-AC01` | negative, positive | auth gate precedes DB/page dispatch; cookie semantics remain REQ-001 |
+| `REQ-002.08-AC01` | positive, unchanged-state | existing financial page results and query contracts remain unchanged |
+| `REQ-002.09-AC01` | positive, unchanged-state | `clear_cache` remains callable and menu transition does not alter it |
+
+## 8. ์ค๋ฅ ๋ฐ ์ถฉ๋ ๊ณ์ฝ
+
+์ธ์ฆ ์คํจ๋ ํญ์ REQ-001 gate๊ฐ ์ฐ์ ํ๋ฉฐ ๋ฉ๋ดยทquery๋ก ์ฐํํ ์ ์๋ค. renderer ์คํจ๋ ํด๋น body๋ง generic error/retry๋ก ๋์ฒดํ๋ค. ๋น ๋ฅธ ์ ํ์ Streamlit serialized callback ์์๋ฅผ ๋ฐ๋ฅด๋ฉฐ ๋ง์ง๋ง serialized selection์ด ์ต์ข
์ํ๋ค. history/back-forward์ path URL์ ์๊ตฌํ์ง ์๋๋ค.
+
+## 9. ๋ฐ์ดํฐ ๋ฐ ์ธํฐํ์ด์ค ๊ณ์ฝ
+
+- `dashboard_page` is the canonical selected menu field; `page` is the only query field written by menu selection.
+- `month`, `account_id`, `filter`, and other sibling query keys are preserved verbatim.
+- Body loading, error, retry, and renderer output are local to the stable body container; shell/background are not replaced.
+
+## 10. ํ์ํธํ์ฑ
+
+- Authentication expiry, rotation, invalid-cookie cleanup, and explicit logout remain governed by `REQ-001.05-AC01`, `REQ-001.07-AC01`, `REQ-001.08-AC01`, and `REQ-001.09-AC01`.
+- Existing exact tests remain the authority: `tests/test_dashboard_persistent_authentication.py::test_token_expiry_is_absolute_and_not_sliding`, `tests/test_dashboard_persistent_authentication.py::test_tampering_and_rotations_invalidate_token`, and `tests/test_dashboard_auth_server.py::test_logout_clears_cookie_with_matching_scope`.
+- Menu/query reruns/rendering do not initiate or replay financial writes or external calls. Financial writes and external calls remain behind explicit page user actions; this adds no new idempotency contract.
+
+## 11. ๊ฒ์ฆ ๊ณํ
+
+- Menu transition tests and required browser evidence verify REQ-002 criteria; REQ-001 authentication tests above verify expiry, rotation, and logout regressions.
+
+### Test Traceability Contract
+
+Test definitions reference the exact Acceptance Criterion IDs and execution evidence records revision, commit, environment, and result separately.
+
+## 12. ์ถ์ ์ฑ
+
+- ๊ทผ๊ฑฐ ์ด์: `#9`
+- ๊ตฌํ PR: `#10`
+- ๊ด๋ จ ์ค๊ณ ๋ฌธ์: `docs/project-structure.md`, `docs/runtime-flow.md`
+- ๊ด๋ จ requirement: `REQ-001`
+- ๊ตฌํ ์ง์
์ : `dashboard/app.py::main`, `dashboard/common.py::render_sidebar_nav`, `dashboard/common.py::validate_dashboard_page`, `dashboard/common.py::render_dashboard_body`, `dashboard/common.py::_sync_dashboard_page_query`
+- ๊ฒ์ฆ ํ์ผ: `tests/test_dashboard_menu_transition.py`, `tests/test_streamlit_browser_evidence.py`, `tests/test_dashboard_app.py`, `tests/test_dashboard_authentication_app.py`
+- browser evidence ๋๊ตฌ: `scripts/streamlit_browser_evidence.py --mode transition`
+
+## 13. ํ์ ๋ ๊ฒฐ์
+
+- Keep the authenticated shell and background stable while replacing only the body.
+- Preserve REQ-001 authentication semantics and keep financial writes/external calls behind explicit page actions.
+
+## 14. Open Questions
+
+- ์์
+
+## 15. ์น์ธ ๊ธฐ๋ก
+
+- ์ํ: `approved`
+- ์น์ธ ์ฃผ์ฒด: existing requirement approval
+- ์น์ธ ๊ทผ๊ฑฐ: `REQ-002` revision `1.0`
diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md
index 63f9049..2b25cb8 100644
--- a/docs/runtime-flow.md
+++ b/docs/runtime-flow.md
@@ -112,3 +112,34 @@ ASGI startup๋ถํฐ login, token restoration, invalid-cookie cleanup, explicit lo
- ๊ฒฝ๊ณ ํ๋ฆ: TTL boundary, rotation, oversized token, same-host different-port origin
- ๋์์ฑ ํ๋ฆ: independent failed submissions and no application lock; ingress rate-limit configuration review
- ์ด์ ์ง์
์ ๊ณผ ๊ธฐ๋ ๊ฒฐ๊ณผ: `uvicorn dashboard.server:app --host 0.0.0.0 --port 8501`; valid HTTPS reaches login or authenticated page, invalid cookie receives matching deletion before unauthenticated page
+
+## ๋ฉ๋ด ์ ํ โ REQ-002
+
+์ธ์ฆ gate์ ๊ธฐ์กด `dashboard-01` page dispatch์ ์ ํ ์์๋ ๋ณํ์ง ์๋๋ค. valid cookie๋ฅผ ํต๊ณผํ ๋ค์๋ง menu transition flow๊ฐ ์คํ๋๋ฉฐ, auth ์คํจ๋ ๊ธฐ์กด cleanup/unauthenticated ํ๋ฆ์ผ๋ก ์ข
๋ฃ๋๋ค.
+
+### transition โ authenticated body replacement
+
+- ์์ ์ด๋ฒคํธ: authenticated browser selects a value in `st.radio(key="dashboard_page")`
+- ์ ํ์กฐ๊ฑด: `gate-02` success; current page/query resolved
+- ์คํ ๋ฐฉ์: Streamlit callback and rerun, no document/path navigation
+- ํธ๋์ญ์
ยท์์์ฑ ๊ฒฝ๊ณ: no new persistence; existing financial SQLite rules unchanged
+
+| ๋จ๊ณ ID | ์คํ ์ฃผ์ฒด | ์์ ์กฐ๊ฑดยท์
๋ ฅ | ์ฒ๋ฆฌ | ์ฝ๊ธฐยท์ฐ๊ธฐ ์ํ | ์ธ๋ถ ํธ์ถ | ์ฑ๊ณต ์ ๋ค์ ๋จ๊ณ | ์คํจ ์ ์ ์ด |
+|---|---|---|---|---|---|---|---|
+| `transition-01` | dashboard app/common | authenticated selected menu | `render_sidebar_nav` reads exact 10 options and selected state | session state `dashboard_page` | Streamlit radio callback | `transition-02` | `dashboard-fail` |
+| `transition-02` | common callback | selected `dashboard_page` | write query `page` only; preserve sibling query keys | browser query state | Streamlit query params | `transition-03` | `transition-03` |
+| `transition-03` | dashboard app | validated page | open stable `st.empty` body, show target loading, invoke target renderer | body placeholder only; existing DB reads/writes | page/gateway code | `transition-04` | `transition-error` |
+| `transition-04` | dashboard app | renderer completed | remove target loading and leave target body; shell/background remain | body replacement only | none | `authenticated` | `dashboard-fail` |
+| `transition-error` | dashboard app | renderer exception | clear partial body and show generic error/retry locally | body error state only | none | retry reruns target | `authenticated` with shell intact |
+
+### transition invariants
+
+- known direct `?page=` selects that page; missing/unknown selects `์์ฝ`.
+- serialized rapid selections commit the latest selection only.
+- menu transition never runs before auth and never changes cookie/logout/DB schema/MCP semantics.
+- menu/query reruns and rendering do not initiate or replay financial writes or external calls; those remain behind explicit page user actions, with no new idempotency contract.
+- history/back-forward and path URL behavior are explicitly outside this flow.
+
+### transition verification boundary
+
+`tests/test_dashboard_menu_transition.py` provides exact AppTest/unit evidence for labels, query preservation, fallback/direct access, loading, local error, latest selection, and cache-clear. `tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db` and `tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state` cover finance/auth regressions. AppTest is synthetic and cannot establish actual browser document continuity, paint timing, focus/scroll, or network behavior; `scripts/streamlit_browser_evidence.py --mode transition`์ authenticated HTTPS frame/DOM/document/shell evidence๊ฐ REQ-002.03-AC01์ ํ์๋ค. `tests/test_streamlit_browser_evidence.py`๋ ์ด manifest๊ฐ ์ค์ teardown ์ดํ์๋ง ์ฑ๊ณตํ๊ณ document navigation, shell identity drift, ๋๋ฝ ๊ด์ฐฐ ๋๋ secret ํฌํจ ์ fail-closedํ๋์ง ๊ฒ์ฆํ๋ค.
diff --git a/scripts/streamlit_browser_evidence.py b/scripts/streamlit_browser_evidence.py
index 1ca9eed..3975fac 100755
--- a/scripts/streamlit_browser_evidence.py
+++ b/scripts/streamlit_browser_evidence.py
@@ -14,10 +14,16 @@ import asyncio
import base64
import json
import os
+import platform
import shutil
import signal
import subprocess
+import sys
import time
+import hashlib
+import math
+import secrets
+import ssl
import urllib.parse
import urllib.request
from datetime import datetime
@@ -44,6 +50,13 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--output-dir", default="", help="Evidence output directory. Defaults under docs/batches/BAT-004/evidence/run-.")
parser.add_argument("--page", action="append", dest="pages", help="Page to capture. Repeatable. Defaults to core E2E evidence pages.")
parser.add_argument("--keep-server", action="store_true", help="Leave Streamlit running after capture for manual inspection.")
+ parser.add_argument("--mode", choices=("capture", "transition"), default="capture", help="Capture mode")
+ parser.add_argument("--cert-file", default="", help="TLS certificate for transition mode")
+ parser.add_argument("--key-file", default="", help="TLS private key for transition mode")
+ parser.add_argument("--from-page", default="์์ฝ")
+ parser.add_argument("--to-page", default="๋ถ๋ฅ ๊ท์น")
+ parser.add_argument("--interval-ms", type=int, default=100)
+ parser.add_argument("--frames", type=int, default=8)
return parser.parse_args()
@@ -145,12 +158,13 @@ def seed_fixture_db(db_path: Path) -> dict[str, Any]:
return {"account_id": account_id, "transaction_ids": transactions, "db_path": str(db_path)}
-def wait_for_http(url: str, timeout_seconds: int = 45) -> None:
+def wait_for_http(url: str, timeout_seconds: int = 45, ssl_context: ssl.SSLContext | None = None) -> None:
+ """Wait for HTTP readiness, using verified TLS unless explicitly overridden."""
deadline = time.time() + timeout_seconds
last_error: str | None = None
while time.time() < deadline:
try:
- with urllib.request.urlopen(url, timeout=2) as response:
+ with urllib.request.urlopen(url, timeout=2, context=ssl_context) as response:
if response.status == 200:
return
last_error = f"HTTP {response.status}"
@@ -195,6 +209,7 @@ class CdpClient:
def __init__(self, ws: websockets.ClientConnection) -> None:
self.ws = ws
self.next_id = 1
+ self.events: list[dict[str, Any]] = []
async def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
command_id = self.next_id
@@ -207,6 +222,8 @@ class CdpClient:
if "error" in payload:
raise RuntimeError(f"CDP {method} failed: {payload['error']}")
return payload.get("result", {})
+ if payload.get("method"):
+ self.events.append(payload)
async def cdp_capture(ws_url: str, url: str, expected_text: str, screenshot: Path, snapshot: Path) -> dict[str, Any]:
@@ -295,10 +312,233 @@ def capture_page(chrome: str, base_url: str, page: str, output_dir: Path, devtoo
}
+def redact_secrets(value: Any, secrets_to_redact: tuple[str, ...] = ()) -> Any:
+ """Return JSON-safe evidence with credential-like values removed."""
+ if isinstance(value, dict):
+ return {key: redact_secrets(item, secrets_to_redact) for key, item in value.items()}
+ if isinstance(value, list):
+ return [redact_secrets(item, secrets_to_redact) for item in value]
+ if isinstance(value, str):
+ result = value
+ for secret in secrets_to_redact:
+ if secret:
+ result = result.replace(secret, "[REDACTED]")
+ return result
+ return value
+
+
+def _validate_surface_records(records: Any, label: str) -> None:
+ required = ("stAppViewContainer", "stSidebar")
+ if not isinstance(records, dict) or any(not isinstance(records.get(name), dict) for name in required):
+ raise ValueError(f"transition observations require surface records for {label}")
+ for name in required:
+ surface = records[name]
+ if any(key not in surface or surface[key] is None for key in ("backgroundImage", "backgroundColor", "visibility", "display", "opacity", "rendered")):
+ raise ValueError(f"transition observations require complete surface records for {label}")
+ if not str(surface["backgroundImage"]).strip() or str(surface["backgroundImage"]).strip().lower() == "none":
+ raise ValueError(f"transition observations reject empty surface background for {label}")
+ if not str(surface["backgroundColor"]).strip():
+ raise ValueError(f"transition observations reject empty surface background color for {label}")
+ try:
+ opacity = float(surface["opacity"])
+ except (TypeError, ValueError) as exc:
+ raise ValueError(f"transition observations require valid surface opacity for {label}") from exc
+ if not math.isfinite(opacity) or surface["visibility"] != "visible" or surface["display"] == "none" or opacity <= 0 or surface["rendered"] is not True:
+ raise ValueError(f"transition observations require visible rendered surfaces for {label}")
+
+
+def validate_transition_manifest(manifest: dict[str, Any], secrets_to_redact: tuple[str, ...] = ()) -> None:
+ """Fail closed unless transition evidence contains every required observation."""
+ serialized = json.dumps(manifest, ensure_ascii=False)
+ if any(secret and secret in serialized for secret in secrets_to_redact):
+ raise ValueError("secret value present in transition manifest")
+ if manifest.get("success") is not True:
+ raise ValueError("transition manifest is not successful")
+ observations = manifest.get("observations") or {}
+ frames = observations.get("frames")
+ if not isinstance(frames, list) or len(frames) < 2:
+ raise ValueError("transition observations require at least two frames")
+ for index, frame in enumerate(frames):
+ _validate_surface_records(frame.get("surfaces"), f"frame {index}")
+ document_events = observations.get("document_navigation_events")
+ if not isinstance(document_events, list) or document_events:
+ raise ValueError("transition observations require zero document navigation events")
+ shell = observations.get("stable_shell") or {}
+ stable_fields = ("document_marker", "sidebar_marker", "background")
+ if any(not isinstance(shell.get(key), dict) for key in stable_fields):
+ raise ValueError("transition observations require pre/post stable shell markers")
+ if any(
+ not isinstance(values.get(position), str) or not values[position].strip()
+ for values in (shell[key] for key in stable_fields)
+ for position in ("pre", "post")
+ ):
+ raise ValueError("transition observations require non-empty stable shell markers")
+ if any(shell[key]["pre"] != shell[key]["post"] for key in stable_fields):
+ raise ValueError("transition observations require stable shell identity and background")
+ stable_surfaces = shell.get("surfaces")
+ if not isinstance(stable_surfaces, dict) or stable_surfaces.get("pre") != frames[0].get("surfaces"):
+ raise ValueError("transition observations require stable shell surface records")
+ _validate_surface_records(stable_surfaces.get("pre"), "stable shell pre")
+ _validate_surface_records(stable_surfaces.get("post"), "stable shell post")
+ if stable_surfaces["pre"] != stable_surfaces["post"] or stable_surfaces["post"] != frames[-1].get("surfaces"):
+ raise ValueError("transition observations require stable shell surface consistency")
+ if observations.get("prior_body_removed") is not True:
+ raise ValueError("transition observations require prior body removal")
+ loading_frames = [frame for frame in frames if frame.get("loading") is True]
+ if observations.get("loading_observed") is not True or not loading_frames:
+ raise ValueError("transition observations require observed loading state")
+ if any(frame.get("prior_body_visible") is not False for frame in loading_frames):
+ raise ValueError("transition observations require prior body absence while loading")
+ if not observations.get("target_page"):
+ raise ValueError("transition observations require target page")
+ if observations.get("final_agreement") is not True:
+ raise ValueError("transition observations require target body agreement")
+ runtime = manifest.get("runtime") or {}
+ if not runtime.get("viewport") or not isinstance(runtime.get("interval_ms"), int):
+ raise ValueError("transition manifest requires viewport and interval")
+ command = manifest.get("command") or {}
+ if command.get("exit_code") != 0 or command.get("teardown") != "complete":
+ raise ValueError("transition manifest requires successful command teardown")
+ source_revision = manifest.get("source_revision")
+ if not isinstance(source_revision, str) or len(source_revision) != 40:
+ raise ValueError("transition manifest requires full source revision")
+ source_files = manifest.get("source_files")
+ if not isinstance(source_files, dict) or not source_files or any(
+ not isinstance(path, str)
+ or not isinstance(digest, str)
+ or len(digest) != 64
+ for path, digest in source_files.items()
+ ):
+ raise ValueError("transition manifest requires source files and SHA-256 digests")
+ source_paths = tuple(source_files)
+ if manifest.get("source_revision_kind") != "git-head-base":
+ raise ValueError("transition manifest requires git-head-base revision kind")
+ if source_revision != current_git_revision():
+ raise ValueError("transition manifest source revision does not match current Git HEAD")
+ if source_files != source_file_digests(source_paths):
+ raise ValueError("transition manifest source file digests do not match current files")
+ if manifest.get("source_worktree_dirty") != git_worktree_dirty(source_paths):
+ raise ValueError("transition manifest working-tree state does not match current files")
+ if not isinstance(manifest.get("evidence_command"), list) or not manifest["evidence_command"]:
+ raise ValueError("transition manifest requires evidence command")
+ if not runtime.get("host_architecture") or not runtime.get("browser_user_agent"):
+ raise ValueError("transition manifest requires runtime identity")
+
+
+def current_git_revision() -> str:
+ return subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout.strip()
+
+
+def git_worktree_dirty(paths: tuple[str, ...]) -> bool:
+ result = subprocess.run(
+ ["git", "status", "--porcelain", "--", *paths],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ return bool(result.stdout.strip())
+
+
+def source_file_digests(paths: tuple[str, ...]) -> dict[str, str]:
+ for path in paths:
+ candidate = Path(path)
+ if candidate.is_absolute() or ".." in candidate.parts:
+ raise ValueError("source file paths must be repository-relative")
+ return {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() for path in paths}
+
+
+async def cdp_transition(ws_url: str, base_url: str, from_page: str, to_page: str,
+ screenshot: Path, snapshot: Path, interval_ms: int, frame_count: int,
+ admin_id: str, admin_password: str) -> dict[str, Any]:
+ async with websockets.connect(ws_url, max_size=16 * 1024 * 1024) as ws:
+ cdp = CdpClient(ws)
+ await cdp.call("Page.enable")
+ await cdp.call("Runtime.enable")
+ await cdp.call("Emulation.setDeviceMetricsOverride", {"width": 1440, "height": 1400, "deviceScaleFactor": 1, "mobile": False})
+ await cdp.call("Page.navigate", {"url": base_url})
+ await asyncio.sleep(1)
+ login_expression = """(async () => {
+ const body = new URLSearchParams({admin_id: %s, admin_password: %s});
+ const response = await fetch('/api/auth/login', {method:'POST', credentials:'include', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body});
+ return {status: response.status, location: response.url};
+ })()""" % (json.dumps(admin_id), json.dumps(admin_password))
+ await cdp.call("Runtime.evaluate", {"expression": login_expression, "awaitPromise": True, "returnByValue": True})
+ await cdp.call("Page.navigate", {"url": f"{base_url}/?page={urllib.parse.quote(from_page)}"})
+ deadline = time.time() + 45
+ while time.time() < deadline:
+ check = await cdp.call("Runtime.evaluate", {"expression": "document.body ? document.body.innerText : ''", "returnByValue": True})
+ if from_page in (check.get("result", {}).get("value", "") or ""):
+ break
+ await asyncio.sleep(.5)
+ install = """(() => {
+ window.__evidenceEvents=[];
+ window.__evidenceDocumentMarker ||= crypto.randomUUID();
+ window.__evidencePriorBodyText = (document.querySelector('[data-testid=stMain]') || document.body)?.innerText || '';
+ const sidebar=document.querySelector('[data-testid=stSidebar]');
+ if (sidebar) sidebar.dataset.evidenceMarker ||= crypto.randomUUID();
+ window.__evidenceSurface = (selector) => { const element=document.querySelector(selector); if (!element) return null; const style=getComputedStyle(element); const rect=element.getBoundingClientRect(); return {backgroundImage:style.backgroundImage, backgroundColor:style.backgroundColor, visibility:style.visibility, display:style.display, opacity:style.opacity, rendered:rect.width>0 && rect.height>0 && style.visibility!=='hidden' && style.display!=='none' && Number(style.opacity)>0}; };
+ for (const name of ['pushState','replaceState']) {
+ const old=history[name];
+ history[name]=function(){window.__evidenceEvents.push({type:name,url:String(arguments[2]||location.href)}); return old.apply(this,arguments)};
+ }
+ })()"""
+ await cdp.call("Runtime.evaluate", {"expression": install})
+ cdp.events.clear()
+ observations: list[dict[str, Any]] = []
+ click = """(() => { const labels=[...document.querySelectorAll('label')]; const target=labels.find(x=>x.innerText.trim()===%s); if(target){target.click(); return true;} const input=[...document.querySelectorAll('input')].find(x=>x.value===%s); if(input){input.click(); return true;} return false; })()""" % (json.dumps(to_page), json.dumps(to_page))
+ deadline = time.time() + 45
+ index = 0
+ while time.time() < deadline and (index < frame_count or not observations or observations[-1].get("selected_menu") != to_page):
+ expression = """(() => { const body=document.body; const text=body?.innerText||''; const main=document.querySelector('[data-testid=stMain]')||body; const mainText=main.innerText||''; const sidebar=document.querySelector('[data-testid=stSidebar]'); const selectedInput=document.querySelector('[data-testid=stRadio] input:checked'); const selected=selectedInput?.closest('label')?.innerText?.trim() || document.querySelector('[aria-current=page]')?.textContent?.trim() || ''; const prior=window.__evidencePriorBodyText||''; return {at_ms:%d, selected_menu:selected, body_text:mainText, prior_body_visible:Boolean(prior)&&mainText.includes(prior), document_marker:window.__evidenceDocumentMarker||'', sidebar_marker:sidebar?.dataset.evidenceMarker||'', background:getComputedStyle(document.body).backgroundColor, surfaces:{stAppViewContainer:window.__evidenceSurface('[data-testid=stAppViewContainer]'), stSidebar:window.__evidenceSurface('[data-testid=stSidebar]')}, loading:/๋ถ๋ฌ์ค๋ ์ค/.test(text)}; })()""" % (index * interval_ms)
+ result = await cdp.call("Runtime.evaluate", {"expression": expression, "returnByValue": True})
+ frame = result.get("result", {}).get("value", {})
+ frame["body_text_hash"] = hashlib.sha256(frame.get("body_text", "").encode()).hexdigest()
+ frame.pop("body_text", None)
+ observations.append(frame)
+ if index == 0:
+ await cdp.call("Runtime.evaluate", {"expression": click, "returnByValue": True})
+ index += 1
+ await asyncio.sleep(interval_ms / 1000)
+ final = await cdp.call("Runtime.evaluate", {"expression": "(() => { const main=document.querySelector('[data-testid=stMain]')||document.body; const sidebar=document.querySelector('[data-testid=stSidebar]'); const selectedInput=document.querySelector('[data-testid=stRadio] input:checked'); return {url:location.href, body:main.innerText||'', selected:selectedInput?.closest('label')?.innerText?.trim() || document.querySelector('[aria-current=page]')?.textContent?.trim() || '', document_marker:window.__evidenceDocumentMarker||'', sidebar_marker:sidebar?.dataset.evidenceMarker||'', background:getComputedStyle(document.body).backgroundColor, surfaces:{stAppViewContainer:window.__evidenceSurface('[data-testid=stAppViewContainer]'), stSidebar:window.__evidenceSurface('[data-testid=stSidebar]')}, history_events:window.__evidenceEvents||[], browser_user_agent:navigator.userAgent}; })()", "returnByValue": True})
+ data = final.get("result", {}).get("value", {})
+ html = await cdp.call("Runtime.evaluate", {"expression": "document.documentElement.outerHTML", "returnByValue": True})
+ html_text = html.get("result", {}).get("value", "")
+ snapshot.write_text(redact_secrets(html_text, (admin_id, admin_password)), encoding="utf-8")
+ image = await cdp.call("Page.captureScreenshot", {"format":"png", "fromSurface":True, "captureBeyondViewport":True})
+ screenshot.write_bytes(base64.b64decode(image["data"]))
+ document_events = [{"type": event.get("method"), "params": event.get("params", {})} for event in cdp.events if event.get("method") in ("Page.frameNavigated", "Page.loadEventFired")]
+ pre = observations[0] if observations else {}
+ loading_frames = [frame for frame in observations if frame.get("loading")]
+ return {"frames": observations, "document_navigation_events": document_events, "history_events": data.get("history_events", []), "stable_shell": {"document_marker": {"pre": pre.get("document_marker"), "post": data.get("document_marker")}, "sidebar_marker": {"pre": pre.get("sidebar_marker"), "post": data.get("sidebar_marker")}, "background": {"pre": pre.get("background"), "post": data.get("background")}, "surfaces": {"pre": pre.get("surfaces"), "post": data.get("surfaces")}}, "loading_observed": bool(loading_frames), "prior_body_removed": bool(loading_frames) and all(frame.get("prior_body_visible") is False for frame in loading_frames), "target_page": to_page, "final_agreement": data.get("selected") == to_page and to_page in data.get("body", ""), "final_url": data.get("url", ""), "browser_user_agent": data.get("browser_user_agent", "")}
+
+
+def transition_capture(chrome: str, base_url: str, from_page: str, to_page: str, output_dir: Path, devtools_port: int,
+ interval_ms: int, frame_count: int,
+ admin_id: str, admin_password: str) -> dict[str, Any]:
+ profile_dir = output_dir / "chrome-profile-transition"
+ screenshot, snapshot = output_dir / "transition.png", output_dir / "transition.dom.html"
+ chrome_cmd = [chrome, "--headless=new", "--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors", f"--remote-debugging-port={devtools_port}", f"--user-data-dir={profile_dir}", "--window-size=1440,1400", "about:blank"]
+ proc = subprocess.Popen(chrome_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
+ try:
+ ws_url = wait_for_devtools(devtools_port)
+ return asyncio.run(cdp_transition(ws_url, base_url, from_page, to_page, screenshot, snapshot, interval_ms, frame_count, admin_id, admin_password))
+ finally:
+ if proc.poll() is None:
+ proc.terminate()
+ try: proc.wait(timeout=8)
+ except subprocess.TimeoutExpired: proc.kill(); proc.wait(timeout=5)
+ remove_tree_with_retries(profile_dir)
+
+
def main() -> int:
args = parse_args()
chrome = require_executable(args.chrome, "Chrome")
- streamlit = require_executable(args.streamlit, "Streamlit")
+ streamlit = None if args.mode == "transition" else require_executable(args.streamlit, "Streamlit")
run_id = datetime.now().strftime("run-%Y%m%d-%H%M%S")
output_dir = Path(args.output_dir) if args.output_dir else DEFAULT_OUTPUT_ROOT / run_id
output_dir.mkdir(parents=True, exist_ok=True)
@@ -308,56 +548,105 @@ def main() -> int:
env = os.environ.copy()
env["JARCA_LEDGER_DB_PATH"] = str(db_path.resolve())
env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
- base_url = f"http://{args.host}:{args.port}"
- server_cmd = [
- streamlit,
- "run",
- "dashboard/app.py",
- "--server.address",
- args.host,
- "--server.port",
- str(args.port),
- "--server.headless",
- "true",
- "--browser.gatherUsageStats",
- "false",
- ]
+ transition_secrets: tuple[str, ...] = ()
+ if args.mode == "transition":
+ if not args.cert_file or not args.key_file:
+ raise SystemExit("transition mode requires --cert-file and --key-file")
+ env["JARCA_DASHBOARD_ADMIN_ID"] = secrets.token_urlsafe(18)
+ env["JARCA_DASHBOARD_ADMIN_PASSWORD"] = secrets.token_urlsafe(32)
+ env["JARCA_DASHBOARD_SIGNING_SECRET"] = secrets.token_hex(32)
+ transition_secrets = (env["JARCA_DASHBOARD_ADMIN_ID"], env["JARCA_DASHBOARD_ADMIN_PASSWORD"], env["JARCA_DASHBOARD_SIGNING_SECRET"])
+ scheme = "https" if args.mode == "transition" else "http"
+ base_url = f"{scheme}://{args.host}:{args.port}"
+ if args.mode == "transition":
+ server_cmd = [sys.executable, "-m", "uvicorn", "dashboard.server:app", "--host", args.host, "--port", str(args.port), "--ssl-certfile", args.cert_file, "--ssl-keyfile", args.key_file]
+ else:
+ server_cmd = [
+ streamlit, "run", "dashboard/app.py", "--server.address", args.host,
+ "--server.port", str(args.port), "--server.headless", "true",
+ "--browser.gatherUsageStats", "false",
+ ]
server = subprocess.Popen(server_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, start_new_session=True)
manifest: dict[str, Any] = {
"success": False,
+ "mode": args.mode,
"base_url": base_url,
"output_dir": str(output_dir.resolve()),
- "fixture": fixture,
+ "fixture": {"db_path": fixture["db_path"]},
"chrome": chrome,
"streamlit": streamlit,
"pages": [],
"server_command": server_cmd,
}
+ error: str | None = None
try:
- wait_for_http(base_url)
- for index, page in enumerate(args.pages or DEFAULT_PAGES):
- manifest["pages"].append(capture_page(chrome, base_url, page, output_dir, devtools_port=19222 + index))
- manifest["success"] = True
- print(json.dumps(manifest, ensure_ascii=False, indent=2))
- return 0
+ wait_for_http(base_url, ssl_context=ssl._create_unverified_context() if args.mode == "transition" else None)
+ if args.mode == "transition":
+ observations = transition_capture(chrome, base_url, args.from_page, args.to_page, output_dir, 19222, args.interval_ms, args.frames, env["JARCA_DASHBOARD_ADMIN_ID"], env["JARCA_DASHBOARD_ADMIN_PASSWORD"])
+ source_paths = (
+ "dashboard/app.py",
+ "dashboard/common.py",
+ "scripts/streamlit_browser_evidence.py",
+ )
+ manifest.update(
+ {
+ "observations": observations,
+ "runtime": {
+ "viewport": {"width": 1440, "height": 1400},
+ "interval_ms": args.interval_ms,
+ "frame_count": args.frames,
+ "host_architecture": platform.machine(),
+ "python_version": platform.python_version(),
+ "browser_user_agent": observations.get("browser_user_agent", ""),
+ },
+ "source_revision": current_git_revision(),
+ "source_revision_kind": "git-head-base",
+ "source_worktree_dirty": git_worktree_dirty(source_paths),
+ "source_files": source_file_digests(source_paths),
+ "evidence_command": ["scripts/streamlit_browser_evidence.py", *sys.argv[1:]],
+ "limitations": ["TLS uses caller-provided local certificate"],
+ }
+ )
+ else:
+ for index, page in enumerate(args.pages or DEFAULT_PAGES):
+ manifest["pages"].append(capture_page(chrome, base_url, page, output_dir, devtools_port=19222 + index))
+ except Exception as exc: # noqa: BLE001 - failure is recorded in redacted evidence
+ error = str(exc)
finally:
- log_tail = ""
- if server.stdout is not None:
- try:
- time.sleep(0.5)
- if server.poll() is not None:
- log_tail = server.stdout.read()[-4000:]
- except Exception:
- log_tail = ""
- manifest["server_log_tail"] = log_tail
- (output_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
if not args.keep_server and server.poll() is None:
os.killpg(server.pid, signal.SIGTERM)
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(server.pid, signal.SIGKILL)
+ server.wait(timeout=5)
+ returncode = server.poll()
+ teardown = "complete" if not args.keep_server and returncode is not None else "incomplete"
+ effective_exit = 0 if returncode in (0, -signal.SIGTERM) else returncode
+ manifest["server_log_tail"] = ""
+ manifest["command"] = {"exit_code": effective_exit, "teardown": teardown}
+ if error:
+ manifest["error"] = error
+ if args.mode == "transition" and error is None and teardown == "complete" and effective_exit == 0:
+ try:
+ manifest["success"] = True
+ validate_transition_manifest(manifest, transition_secrets)
+ except Exception as exc: # noqa: BLE001 - preserve failed manifest
+ manifest["success"] = False
+ manifest["error"] = str(exc)
+ elif args.mode == "capture" and error is None and (
+ (args.keep_server and returncode is None)
+ or (teardown == "complete" and effective_exit == 0)
+ ):
+ manifest["success"] = True
+ safe_manifest = redact_secrets(manifest, transition_secrets)
+ manifest_path = output_dir / "manifest.json"
+ temporary_path = output_dir / ".manifest.json.tmp"
+ temporary_path.write_text(json.dumps(safe_manifest, ensure_ascii=False, indent=2), encoding="utf-8")
+ temporary_path.replace(manifest_path)
+ print(json.dumps(safe_manifest, ensure_ascii=False, indent=2))
+ return 0 if manifest.get("success") is True else 1
if __name__ == "__main__":
diff --git a/tests/test_dashboard_menu_transition.py b/tests/test_dashboard_menu_transition.py
new file mode 100644
index 0000000..9fb3d79
--- /dev/null
+++ b/tests/test_dashboard_menu_transition.py
@@ -0,0 +1,219 @@
+from __future__ import annotations
+
+from pathlib import Path
+import sys
+
+import pytest
+from streamlit.testing.v1 import AppTest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from dashboard import app, authentication, common
+from scripts.ci_fixture_db import create_ci_fixture_db
+
+
+PAGES = ["์์ ์ฐ", "์์ฝ", "์์ฐ/๋ง๊ฐ", "๊ฑฐ๋๋ด์ญ", "ํ์ธํ์ ํ", "๋ฏธ๋ถ๋ฅ", "๋ถ๋ฅ ๊ท์น", "์นดํ
๊ณ ๋ฆฌ", "๊ณ์ข", "Import"]
+
+
+def test_dashboard_menu_labels_and_order_are_preserved(monkeypatch):
+ calls = {}
+
+ def radio(label, options, **kwargs):
+ calls.update(label=label, options=list(options), kwargs=kwargs)
+ return "์์ฝ"
+
+ monkeypatch.setattr(common.st, "radio", radio)
+ assert common.render_sidebar_nav(PAGES, "์์ฝ") == "์์ฝ"
+ assert calls["options"] == PAGES
+ assert calls["kwargs"]["key"] == "dashboard_page"
+ assert calls["kwargs"]["label_visibility"] == "collapsed"
+
+
+def test_sidebar_selection_updates_only_page_query_parameter(monkeypatch):
+ query = {"page": "์์ฝ", "month": "2026-06", "account_id": "checking"}
+ state = {"dashboard_page": "๊ฑฐ๋๋ด์ญ"}
+ callback = {}
+
+ monkeypatch.setattr(common.st, "query_params", query)
+ monkeypatch.setattr(common.st, "session_state", state)
+ monkeypatch.setattr(common.st, "radio", lambda *args, **kwargs: callback.setdefault("fn", kwargs["on_change"]) or state["dashboard_page"])
+ common.render_sidebar_nav(PAGES, "์์ฝ")
+ callback["fn"]()
+
+ assert query == {"page": "๊ฑฐ๋๋ด์ญ", "month": "2026-06", "account_id": "checking"}
+
+
+def test_missing_or_unknown_page_falls_back_to_overview():
+ assert common.validate_dashboard_page(None, PAGES) == "์์ฝ"
+ assert common.validate_dashboard_page("not-a-page", PAGES) == "์์ฝ"
+ assert common.validate_dashboard_page("๊ฑฐ๋๋ด์ญ", PAGES) == "๊ฑฐ๋๋ด์ญ"
+
+
+def test_direct_page_query_access_renders_requested_page(monkeypatch):
+ selected = {}
+ monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True)
+ monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3")
+ monkeypatch.setattr(app, "inject_app_css", lambda: None)
+ monkeypatch.setattr(app.st, "query_params", {"page": "๊ณ์ข"})
+ monkeypatch.setattr(app.st, "session_state", {"dashboard_page": "์์ฝ"})
+
+ def capture_sidebar_page(pages, page):
+ selected["sidebar_page"] = page
+ selected["session_page"] = app.st.session_state["dashboard_page"]
+ return page
+
+ monkeypatch.setattr(app, "render_sidebar_nav", capture_sidebar_page)
+ monkeypatch.setattr(app.st, "sidebar", pytest.importorskip("contextlib").nullcontext())
+ monkeypatch.setattr(app.st, "button", lambda *args, **kwargs: False)
+ monkeypatch.setattr(app, "page_accounts", lambda db: selected.setdefault("db", db))
+ for name in PAGES:
+ if name != "๊ณ์ข":
+ monkeypatch.setattr(app, {"์์ ์ฐ": "page_monthly_settlement", "์์ฝ": "page_overview", "์์ฐ/๋ง๊ฐ": "page_assets", "๊ฑฐ๋๋ด์ญ": "page_transactions", "ํ์ธํ์ ํ": "page_cross_transfer_review", "๋ฏธ๋ถ๋ฅ": "page_uncategorized", "๋ถ๋ฅ ๊ท์น": "page_rules", "์นดํ
๊ณ ๋ฆฌ": "page_categories", "Import": "page_imports"}[name], lambda db: None)
+ monkeypatch.setattr(app, "render_dashboard_body", lambda page, renderer: renderer())
+
+ app.main()
+ assert selected == {
+ "sidebar_page": "๊ณ์ข",
+ "session_page": "๊ณ์ข",
+ "db": "fixture.sqlite3",
+ }
+
+
+def test_streamlit_menu_click_updates_body_and_preserves_page_query_siblings(tmp_path, monkeypatch):
+ fixture = tmp_path / "fixture.sqlite3"
+ create_ci_fixture_db(fixture)
+ monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
+ monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin")
+ monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password")
+ monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64)
+ settings = authentication.load_auth_settings()
+ assert settings is not None
+ token = authentication.issue_persistent_token(settings, remember_me=False).value
+ monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
+
+ app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
+ at = AppTest.from_file(str(app_path), default_timeout=10)
+ at.query_params["page"] = "์์ฝ"
+ at.query_params["month"] = "2026-06"
+ at.run()
+
+ at.radio(key="dashboard_page").set_value("๋ถ๋ฅ ๊ท์น").run()
+
+ assert not at.exception
+ assert at.query_params["page"] == ["๋ถ๋ฅ ๊ท์น"]
+ assert at.query_params["month"] == ["2026-06"]
+ body_text = "\n".join(str(item.value) for item in list(at.text) + list(at.markdown) + list(at.caption))
+ assert "ํ์ฌ ๋ถ๋ฅ ๊ท์น" in body_text
+
+
+def test_slow_transition_clears_previous_body_and_shows_target_loading(monkeypatch):
+ events = []
+ monkeypatch.setattr(common.st, "empty", lambda: _FakePlaceholder(events))
+ monkeypatch.setattr(common.st, "container", lambda **kwargs: _FakeKeyedContainer(events, kwargs["key"]))
+ monkeypatch.setattr(common.st, "info", lambda message: events.append(("info", message)))
+ common.render_dashboard_body("๊ฑฐ๋๋ด์ญ", lambda: events.append(("render", "๊ฑฐ๋๋ด์ญ")))
+ assert events[0] == ("empty", "placeholder")
+ assert ("open", "dashboard_body") in events
+ assert ("open", "dashboard_loading") in events
+ assert ("open", "dashboard_content") in events
+ assert events.index(("info", "๊ฑฐ๋๋ด์ญ ๋ถ๋ฌ์ค๋ ์คโฆ")) < events.index(("render", "๊ฑฐ๋๋ด์ญ"))
+ assert events.index(("render", "๊ฑฐ๋๋ด์ญ")) < len(events) - 1
+ assert events.count(("empty", "placeholder")) >= 2
+
+
+def test_body_error_is_local_and_retry_rerenders_target(monkeypatch):
+ events = []
+ monkeypatch.setattr(common.st, "empty", lambda: _FakePlaceholder(events))
+ monkeypatch.setattr(common.st, "container", lambda **kwargs: _FakeKeyedContainer(events, kwargs["key"]))
+ monkeypatch.setattr(common.st, "info", lambda message: events.append(("info", message)))
+ monkeypatch.setattr(common.st, "error", lambda message: events.append(("error", message)))
+ monkeypatch.setattr(common.st, "button", lambda *args, **kwargs: events.append(("retry", args[0])) or True)
+ monkeypatch.setattr(common.st, "rerun", lambda: events.append(("rerun", "target")))
+ common.render_dashboard_body("์์ฝ", lambda: (_ for _ in ()).throw(RuntimeError("secret details")))
+ assert ("error", "ํ์ด์ง๋ฅผ ๋ถ๋ฌ์ค์ง ๋ชปํ์ต๋๋ค. ๋ค์ ์๋ํด ์ฃผ์ธ์.") in events
+ assert ("rerun", "target") in events
+ assert all("secret details" not in str(event) for event in events)
+
+
+def test_rapid_menu_clicks_commit_latest_selection_only(monkeypatch):
+ query = {"page": "์์ฝ", "filter": "pending"}
+ state = {"dashboard_page": "์์ฝ"}
+ callback = {}
+ monkeypatch.setattr(common.st, "query_params", query)
+ monkeypatch.setattr(common.st, "session_state", state)
+ monkeypatch.setattr(common.st, "radio", lambda *args, **kwargs: callback.setdefault("fn", kwargs["on_change"]) or state["dashboard_page"])
+ common.render_sidebar_nav(PAGES, "์์ฝ")
+ for page in ("๊ฑฐ๋๋ด์ญ", "์์ฐ/๋ง๊ฐ", "Import"):
+ state["dashboard_page"] = page
+ callback["fn"]()
+ assert query == {"page": "Import", "filter": "pending"}
+
+
+def test_refresh_control_preserves_cache_clear_contract(monkeypatch):
+ events = []
+
+ class ExpectedRerun(BaseException):
+ pass
+
+ monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True)
+ monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3")
+ monkeypatch.setattr(app, "inject_app_css", lambda: None)
+ monkeypatch.setattr(app.st, "query_params", {"page": "์์ฝ"})
+ monkeypatch.setattr(app, "render_sidebar_nav", lambda pages, page: page)
+ monkeypatch.setattr(app.st, "sidebar", pytest.importorskip("contextlib").nullcontext())
+ monkeypatch.setattr(app.st, "button", lambda *args, **kwargs: True)
+ monkeypatch.setattr(app, "clear_cache", lambda: events.append("clear_cache"))
+
+ def rerun():
+ events.append("rerun")
+ raise ExpectedRerun
+
+ monkeypatch.setattr(app.st, "rerun", rerun)
+
+ with pytest.raises(ExpectedRerun):
+ app.main()
+ assert events == ["clear_cache", "rerun"]
+
+
+class _FakePlaceholder:
+ def __init__(self, events):
+ self.events = events
+
+ def container(self):
+ return _FakeContainer(self.events)
+
+ def info(self, message):
+ self.events.append(("info", message))
+
+ def empty(self):
+ self.events.append(("empty", "placeholder"))
+
+
+class _FakeContainer:
+ def __init__(self, events):
+ self.events = events
+
+ def __enter__(self):
+ self.events.append(("container", "open"))
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ self.events.append(("container", "close"))
+ return False
+
+
+class _FakeKeyedContainer:
+ def __init__(self, events, key):
+ self.events = events
+ self.key = key
+
+ def __enter__(self):
+ self.events.append(("open", self.key))
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ self.events.append(("close", self.key))
+ return False
+
+ def empty(self):
+ self.events.append(("empty", self.key))
diff --git a/tests/test_streamlit_browser_evidence.py b/tests/test_streamlit_browser_evidence.py
new file mode 100644
index 0000000..b413fa4
--- /dev/null
+++ b/tests/test_streamlit_browser_evidence.py
@@ -0,0 +1,229 @@
+from __future__ import annotations
+
+import json
+import ssl
+import sys
+from pathlib import Path
+from typing import Any
+
+from unittest.mock import patch
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+import streamlit_browser_evidence as evidence
+
+
+SECRET_VALUES = ("synthetic-password", "a" * 64, "cookie-value")
+SURFACE_STYLE = {
+ "backgroundImage": "linear-gradient(rgb(1, 2, 3), rgb(4, 5, 6))",
+ "backgroundColor": "rgb(255, 255, 255)",
+ "visibility": "visible",
+ "display": "block",
+ "opacity": "1",
+ "rendered": True,
+}
+
+
+def surface_records() -> dict[str, dict[str, object]]:
+ return {
+ "stAppViewContainer": dict(SURFACE_STYLE),
+ "stSidebar": dict(SURFACE_STYLE),
+ }
+
+
+def test_redact_secrets_removes_values_from_nested_manifest() -> None:
+ payload = {"url": "https://127.0.0.1/?token=cookie-value", "nested": ["synthetic-password", {"key": "a" * 64}]}
+ redacted = evidence.redact_secrets(payload, SECRET_VALUES)
+ serialized = json.dumps(redacted)
+ assert all(secret not in serialized for secret in SECRET_VALUES)
+ assert redacted["nested"][0] == "[REDACTED]"
+
+
+def test_transition_requires_synthetic_fixture_database(tmp_path: Path) -> None:
+ with pytest.raises(SystemExit, match="fixture.sqlite3"):
+ evidence.assert_synthetic_db_path(tmp_path / "operating.sqlite3")
+
+
+def test_validate_transition_manifest_requires_observations() -> None:
+ manifest = {
+ "mode": "transition",
+ "success": True,
+ "observations": {"frames": [], "navigation_events": []},
+ }
+ with pytest.raises(ValueError, match="frames"):
+ evidence.validate_transition_manifest(manifest)
+
+
+def complete_manifest() -> dict[str, Any]:
+ return {
+ "mode": "transition",
+ "success": True,
+ "observations": {
+ "frames": [
+ {
+ "at_ms": 0,
+ "selected_menu": "์์ฝ",
+ "body_text_hash": "hash",
+ "loading": False,
+ "prior_body_visible": True,
+ "surfaces": surface_records(),
+ },
+ {
+ "at_ms": 100,
+ "selected_menu": "๋ถ๋ฅ ๊ท์น",
+ "body_text_hash": "hash2",
+ "loading": True,
+ "prior_body_visible": False,
+ "surfaces": surface_records(),
+ },
+ ],
+ "document_navigation_events": [],
+ "history_events": [{"type": "pushState", "url": "/?page=rules"}],
+ "stable_shell": {
+ "document_marker": {"pre": "doc-1", "post": "doc-1"},
+ "sidebar_marker": {"pre": "side-1", "post": "side-1"},
+ "background": {"pre": "rgb(255, 255, 255)", "post": "rgb(255, 255, 255)"},
+ "surfaces": {"pre": surface_records(), "post": surface_records()},
+ },
+ "loading_observed": True,
+ "prior_body_removed": True,
+ "target_page": "๋ถ๋ฅ ๊ท์น",
+ "final_agreement": True,
+ },
+ "runtime": {
+ "viewport": {"width": 1440, "height": 1400},
+ "interval_ms": 50,
+ "host_architecture": "aarch64",
+ "browser_user_agent": "Synthetic Browser",
+ },
+ "source_revision": evidence.current_git_revision(),
+ "source_revision_kind": "git-head-base",
+ "source_worktree_dirty": evidence.git_worktree_dirty(("dashboard/app.py", "dashboard/common.py")),
+ "source_files": evidence.source_file_digests(("dashboard/app.py", "dashboard/common.py")),
+ "evidence_command": ["scripts/streamlit_browser_evidence.py", "--mode", "transition"],
+ "command": {"exit_code": 0, "teardown": "complete"},
+ "limitations": [],
+ }
+
+
+def test_validate_transition_manifest_accepts_complete_secret_free_shape() -> None:
+ evidence.validate_transition_manifest(complete_manifest())
+
+
+def test_wait_for_http_uses_verified_tls_by_default_and_fixture_context_opt_in() -> None:
+ response = type("Response", (), {"status": 200, "__enter__": lambda self: self, "__exit__": lambda *args: None})()
+ with patch.object(evidence.urllib.request, "urlopen", return_value=response) as urlopen:
+ evidence.wait_for_http("https://example.test")
+ assert urlopen.call_args.kwargs["context"] is None
+ fixture_context = ssl._create_unverified_context()
+ with patch.object(evidence.urllib.request, "urlopen", return_value=response) as urlopen:
+ evidence.wait_for_http("https://127.0.0.1", ssl_context=fixture_context)
+ assert urlopen.call_args.kwargs["context"] is fixture_context
+
+
+def test_validate_transition_manifest_rejects_fabricated_navigation_event() -> None:
+ manifest = complete_manifest()
+ manifest["observations"]["document_navigation_events"] = [{"type": "load", "url": "/"}]
+ with pytest.raises(ValueError, match="document navigation"):
+ evidence.validate_transition_manifest(manifest)
+
+
+def test_validate_transition_manifest_requires_observed_loading_without_prior_body() -> None:
+ no_loading = complete_manifest()
+ no_loading["observations"]["loading_observed"] = False
+ with pytest.raises(ValueError, match="loading"):
+ evidence.validate_transition_manifest(no_loading)
+
+ stale_body = complete_manifest()
+ stale_body["observations"]["frames"][1]["prior_body_visible"] = True
+ with pytest.raises(ValueError, match="prior body"):
+ evidence.validate_transition_manifest(stale_body)
+
+
+def test_validate_transition_manifest_requires_pre_post_shell_identity() -> None:
+ manifest = complete_manifest()
+ manifest["observations"]["stable_shell"] = {"sidebar": True, "background": "#fff"}
+ with pytest.raises(ValueError, match="shell"):
+ evidence.validate_transition_manifest(manifest)
+
+
+def test_validate_transition_manifest_rejects_unselected_target_body_agreement() -> None:
+ manifest = complete_manifest()
+ manifest["observations"]["final_agreement"] = False
+ with pytest.raises(ValueError, match="agreement"):
+ evidence.validate_transition_manifest(manifest)
+
+
+def test_validate_transition_manifest_rejects_attestation_before_teardown() -> None:
+ manifest = complete_manifest()
+ manifest["command"] = {"exit_code": 0, "teardown": "running"}
+ with pytest.raises(ValueError, match="teardown"):
+ evidence.validate_transition_manifest(manifest)
+
+
+def test_validate_transition_manifest_requires_exact_source_and_runtime_identity() -> None:
+ manifest = complete_manifest()
+ manifest.pop("source_files")
+ with pytest.raises(ValueError, match="source files"):
+ evidence.validate_transition_manifest(manifest)
+
+ manifest = complete_manifest()
+ manifest["runtime"].pop("browser_user_agent")
+ with pytest.raises(ValueError, match="runtime identity"):
+ evidence.validate_transition_manifest(manifest)
+
+ manifest = complete_manifest()
+ manifest["source_files"]["dashboard/app.py"] = "0" * 64
+ with pytest.raises(ValueError, match="source file digests"):
+ evidence.validate_transition_manifest(manifest)
+
+
+def test_transition_manifest_rejects_secret_text() -> None:
+ manifest = {"mode": "transition", "success": False, "error": "synthetic-password"}
+ with pytest.raises(ValueError, match="secret"):
+ evidence.validate_transition_manifest(manifest, SECRET_VALUES)
+
+
+def test_validate_transition_manifest_requires_surface_style_evidence_on_every_frame() -> None:
+ manifest = complete_manifest()
+ manifest["observations"]["frames"][1]["surfaces"].pop("stSidebar")
+ with pytest.raises(ValueError, match="surface"):
+ evidence.validate_transition_manifest(manifest)
+
+
+def test_validate_transition_manifest_requires_stable_surface_style_evidence() -> None:
+ manifest = complete_manifest()
+ manifest["observations"]["stable_shell"]["surfaces"]["post"]["stSidebar"]["opacity"] = "0"
+ with pytest.raises(ValueError, match="surface"):
+ evidence.validate_transition_manifest(manifest)
+
+
+@pytest.mark.parametrize(
+ ("field", "value"),
+ (
+ ("backgroundImage", "none"),
+ ("backgroundColor", ""),
+ ("visibility", "hidden"),
+ ("display", "none"),
+ ("opacity", "0"),
+ ("opacity", "NaN"),
+ ("rendered", False),
+ ),
+)
+def test_validate_transition_manifest_rejects_non_visible_surface_styles(field: str, value: object) -> None:
+ manifest = complete_manifest()
+ observations = manifest["observations"]
+ for frame in observations["frames"]:
+ frame["surfaces"]["stAppViewContainer"][field] = value
+ observations["stable_shell"]["surfaces"]["pre"]["stAppViewContainer"][field] = value
+ observations["stable_shell"]["surfaces"]["post"]["stAppViewContainer"][field] = value
+ with pytest.raises(ValueError, match="surface"):
+ evidence.validate_transition_manifest(manifest)
+
+
+def test_validate_transition_manifest_rejects_missing_stable_shell_markers() -> None:
+ manifest = complete_manifest()
+ manifest["observations"]["stable_shell"]["document_marker"] = {"pre": None, "post": None}
+ with pytest.raises(ValueError, match="shell"):
+ evidence.validate_transition_manifest(manifest)