diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml
index 62a0755d8..cc680be13 100644
--- a/.github/workflows/app-ci.yml
+++ b/.github/workflows/app-ci.yml
@@ -113,3 +113,13 @@ jobs:
run: |
rm -f ~/package-lock.json
cd frontend && pnpm run build
+
+ - name: Install Playwright Chromium
+ run: cd frontend && pnpm exec playwright install --with-deps chromium
+
+ - name: Run full product smoke
+ env:
+ NEXT_TELEMETRY_DISABLED: "1"
+ NARUON_FULL_PRODUCT_BASE_URL: "http://127.0.0.1:3001"
+ NARUON_FULL_PRODUCT_SCREENSHOT_DIR: "/tmp/naruon-full-product-smoke"
+ run: cd frontend && pnpm run full:smoke
diff --git a/backend/api/security.py b/backend/api/security.py
index e70b2f210..7925284b3 100644
--- a/backend/api/security.py
+++ b/backend/api/security.py
@@ -2,7 +2,7 @@
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,6 +26,20 @@
SourceType = Literal["caldav_source", "carddav_source", "webdav_repository"]
ScopeKind = Literal["organization", "personal"]
+PermissionDraftDecision = Literal[
+ "allow_writeback",
+ "deny_external_write",
+ "deny_workspace_write",
+ "deny_region_export",
+ "deny_missing_consent",
+]
+PermissionResourceType = Literal[
+ "caldav_source",
+ "carddav_source",
+ "data_export",
+ "webdav_repository",
+ "provider_secret",
+]
DecisionReason = Literal[
"allowed",
"organization_denied",
@@ -98,6 +112,28 @@ class SecurityAccessSurfaceResponse(BaseModel):
policy_order: list[PolicyOrderStep]
+class PermissionChangeIntentRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ decision: PermissionDraftDecision
+ resource_type: PermissionResourceType
+
+
+class PermissionChangeIntentResponse(BaseModel):
+ decision: PermissionDraftDecision
+ resource_type: PermissionResourceType
+ allowed: bool
+ reason: DecisionReason
+ evidence_label: str
+ audit_event: Literal["security.permission_change_intent"]
+ provider_write_executed: Literal[False]
+ denial_result: Literal[
+ "approval_required_before_external_write",
+ "provider_denied_by_policy",
+ ]
+ observed_at: str
+
+
def _datetime_to_utc_iso(value: datetime) -> str:
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
@@ -428,6 +464,47 @@ def _require_authoritative_workspace_scope(auth_context: AuthContext) -> None:
)
+def _permission_change_resource(
+ request: PermissionChangeIntentRequest,
+ auth_context: AuthContext,
+) -> ResourcePolicy:
+ organization_id = auth_context.organization_id
+ workspace_id = auth_context.workspace_id
+ data_region = settings.DATA_REGION
+ required_consent_scopes: tuple[str, ...] = ()
+
+ if request.decision == "deny_external_write":
+ organization_id = "org-outside-scope"
+ elif request.decision == "deny_workspace_write":
+ workspace_id = "workspace-outside-scope"
+ elif request.decision == "deny_region_export":
+ data_region = settings.SECONDARY_DATA_REGION
+ elif request.decision == "deny_missing_consent":
+ required_consent_scopes = ("provider.write",)
+
+ return ResourcePolicy(
+ owner_id=auth_context.user_id,
+ organization_id=organization_id,
+ permitted_roles=("tenant_admin", "organization_admin"),
+ permitted_group_ids=auth_context.group_ids,
+ data_region=data_region,
+ required_consent_scopes=required_consent_scopes,
+ workspace_id=workspace_id,
+ )
+
+
+def _permission_detail_text(
+ request: PermissionChangeIntentRequest,
+ reason: str,
+) -> str:
+ return (
+ f"permission_intent={request.decision}; "
+ f"resource_type={request.resource_type}; "
+ f"policy_reason={reason}; "
+ "provider_write_executed=false"
+ )
+
+
@router.get("/access-surface", response_model=SecurityAccessSurfaceResponse)
async def get_access_surface(
auth_context: AuthContext = Depends(get_auth_context),
@@ -471,3 +548,56 @@ async def get_access_surface(
external_share_reviews=_share_reviews(sources),
policy_order=_policy_order(),
)
+
+
+@router.post(
+ "/permission-change-intent",
+ response_model=PermissionChangeIntentResponse,
+)
+async def create_permission_change_intent(
+ request: PermissionChangeIntentRequest,
+ auth_context: AuthContext = Depends(get_auth_context),
+ db: AsyncSession = Depends(get_db),
+) -> PermissionChangeIntentResponse:
+ _require_authoritative_workspace_scope(auth_context)
+ if not is_admin_role(auth_context.role):
+ raise HTTPException(
+ status_code=403,
+ detail="Admin role is required for security permission changes",
+ )
+
+ decision = evaluate_access(
+ _access_request(auth_context),
+ _permission_change_resource(request, auth_context),
+ )
+ observed_at = datetime.now(timezone.utc)
+ audit_event = SecurityAuditEvent(
+ actor_user_id=auth_context.user_id,
+ actor_role=auth_context.role,
+ organization_id=auth_context.organization_id,
+ workspace_id=auth_context.workspace_id,
+ event_action="permission_change_intent",
+ resource_type=request.resource_type,
+ resource_uid=None,
+ evidence_source="api.security.permission_change_intent",
+ detail_text=_permission_detail_text(request, decision.reason),
+ observed_at=observed_at,
+ )
+ db.add(audit_event)
+ await db.commit()
+
+ return PermissionChangeIntentResponse(
+ decision=request.decision,
+ resource_type=request.resource_type,
+ allowed=decision.allowed,
+ reason=decision.reason,
+ evidence_label="policy_engine_evidence",
+ audit_event="security.permission_change_intent",
+ provider_write_executed=False,
+ denial_result=(
+ "approval_required_before_external_write"
+ if decision.allowed
+ else "provider_denied_by_policy"
+ ),
+ observed_at=_datetime_to_utc_iso(observed_at),
+ )
diff --git a/backend/services/email_client.py b/backend/services/email_client.py
index 8a8fa28b6..f36b5d023 100644
--- a/backend/services/email_client.py
+++ b/backend/services/email_client.py
@@ -412,6 +412,12 @@ class _PinnedImplicitTlsSMTP(aiosmtplib.SMTP):
def __init__(self, *, tls_server_hostname: str, **kwargs):
self._tls_server_hostname = tls_server_hostname
+ # aiosmtplib validates that a socket-backed implicit-TLS client has a
+ # hostname (it raises "If using a socket with TLS, hostname is
+ # required"). The pinned client connects an already-resolved socket and
+ # supplies SNI via ``tls_server_hostname`` in ``_create_connection``, so
+ # mirror that value into ``hostname`` to satisfy validation without a
+ # second DNS lookup.
if (
kwargs.get("sock") is not None
and kwargs.get("use_tls")
diff --git a/backend/tests/test_release_governance.py b/backend/tests/test_release_governance.py
index d69953526..82ef2ca15 100644
--- a/backend/tests/test_release_governance.py
+++ b/backend/tests/test_release_governance.py
@@ -967,6 +967,69 @@ def test_pr_governance_uses_metadata_only_events_without_checkout_or_admin_merge
assert "dismiss" not in combined.lower()
+def test_20b_kpi_roi_claim_gate_separates_measurements_from_assumptions() -> None:
+ kpi_report = read_repo_text(
+ "docs/superpowers/reports/2026-07-02-naruon-kpi-validation.md"
+ )
+ buyer_package = read_repo_text(
+ "docs/superpowers/reports/2026-07-02-naruon-20b-buyer-package.md"
+ )
+ security_questionnaire = read_repo_text(
+ "docs/superpowers/reports/2026-07-02-naruon-20b-security-questionnaire.md"
+ )
+ readiness_plan = read_repo_text(
+ "docs/superpowers/plans/2026-07-02-naruon-20b-full-product-commercial-readiness.md"
+ )
+
+ assert "### ROI Model And Claim Gate" in kpi_report
+ assert "estimated_period_value_krw" in kpi_report
+ for model_input in (
+ "time_saved_per_user_per_week_hours",
+ "fully_loaded_hourly_cost_krw",
+ "weekly_active_users",
+ "evidence_open_rate",
+ "decision_to_action_conversion_rate",
+ "pilot_period_weeks",
+ "risk_reduction_adjustment",
+ ):
+ assert model_input in kpi_report
+
+ assert "Measured value unavailable in this branch" in kpi_report
+ assert "must not be presented as a proven value" in kpi_report
+ assert "Naruon has proven a 20B KRW ROI" in kpi_report
+ assert "No live ROI number should be claimed" in buyer_package
+ assert "live measured data" in buyer_package
+ assert "live measured data is required before ROI claims" in security_questionnaire
+ assert "- [x] **Step 2: Define ROI model**" in readiness_plan
+ assert "- [ ] **Step 2: Define ROI model**" not in readiness_plan
+
+
+def test_20b_buyer_package_rejects_final_procurement_claim_language() -> None:
+ buyer_package = read_repo_text(
+ "docs/superpowers/reports/2026-07-02-naruon-20b-buyer-package.md"
+ )
+ demo_script = read_repo_text(
+ "docs/superpowers/reports/2026-07-02-naruon-20b-demo-script.md"
+ )
+ telemetry_report = read_repo_text(
+ "docs/superpowers/reports/2026-07-02-naruon-design-to-code-telemetry-qa.md"
+ )
+
+ assert "Accepted buyer-review language:" in buyer_package
+ assert "Rejected language:" in buyer_package
+ assert "## Do Not Say" in demo_script
+ for rejected_claim in (
+ "Naruon is public-launch ready.",
+ "Live ROI has been proven.",
+ "All provider writes are production-proven.",
+ ):
+ assert rejected_claim in demo_script
+
+ assert "controlled enterprise buyer technical review" in buyer_package
+ assert "not a final public-launch or contract-close claim" in buyer_package
+ assert "not a claim that Naruon is ready for public SaaS launch" in telemetry_report
+
+
def test_coderabbit_approval_is_decoupled_from_github_checks() -> None:
config = read_repo_text(".coderabbit.yaml")
policy = read_repo_text("docs/development/merge-gate-policy.md")
diff --git a/backend/tests/test_security_api.py b/backend/tests/test_security_api.py
index f69356881..cba1342de 100644
--- a/backend/tests/test_security_api.py
+++ b/backend/tests/test_security_api.py
@@ -57,12 +57,20 @@ def __init__(
connector_events or [],
]
self.execute_calls = 0
+ self.added: list[object] = []
+ self.committed = False
async def execute(self, query):
result = self.results[self.execute_calls]
self.execute_calls += 1
return MockResult(_scope_rows_for_query(query, result))
+ def add(self, obj):
+ self.added.append(obj)
+
+ async def commit(self):
+ self.committed = True
+
def _scope_rows_for_query(query, rows):
compiled = query.compile()
@@ -413,6 +421,164 @@ def test_access_surface_redacts_sequential_ids_and_credentials(admin_client):
assert forbidden not in serialized
+def test_permission_change_intent_records_provider_denial_without_external_write(
+ admin_client,
+ mock_db,
+):
+ response = admin_client.post(
+ "/api/security/permission-change-intent",
+ json={"decision": "deny_external_write", "resource_type": "provider_secret"},
+ )
+
+ assert response.status_code == 200, response.text
+ body = response.json()
+ assert body == {
+ "decision": "deny_external_write",
+ "resource_type": "provider_secret",
+ "allowed": False,
+ "reason": "organization_denied",
+ "evidence_label": "policy_engine_evidence",
+ "audit_event": "security.permission_change_intent",
+ "provider_write_executed": False,
+ "denial_result": "provider_denied_by_policy",
+ "observed_at": body["observed_at"],
+ }
+ assert body["observed_at"].endswith("Z")
+ assert mock_db.committed is True
+ assert len(mock_db.added) == 1
+ audit_event = mock_db.added[0]
+ assert isinstance(audit_event, SecurityAuditEvent)
+ assert audit_event.event_action == "permission_change_intent"
+ assert audit_event.resource_type == "provider_secret"
+ assert audit_event.actor_user_id == "admin"
+ assert audit_event.actor_role == "tenant_admin"
+ assert audit_event.organization_id == "org-acme"
+ assert audit_event.workspace_id == "workspace-org-acme"
+ assert audit_event.evidence_source == "api.security.permission_change_intent"
+ assert audit_event.detail_text == (
+ "permission_intent=deny_external_write; "
+ "resource_type=provider_secret; "
+ "policy_reason=organization_denied; "
+ "provider_write_executed=false"
+ )
+ assert "sk-" not in response.text
+ assert "credential" not in response.text
+ assert "org-outside-scope" not in response.text
+
+
+@pytest.mark.parametrize(
+ ("request_body", "expected_allowed", "expected_reason", "expected_denial_result"),
+ [
+ (
+ {"decision": "allow_writeback", "resource_type": "webdav_repository"},
+ True,
+ "allowed",
+ "approval_required_before_external_write",
+ ),
+ (
+ {"decision": "deny_workspace_write", "resource_type": "webdav_repository"},
+ False,
+ "workspace_denied",
+ "provider_denied_by_policy",
+ ),
+ (
+ {"decision": "deny_region_export", "resource_type": "data_export"},
+ False,
+ "data_region_denied",
+ "provider_denied_by_policy",
+ ),
+ (
+ {"decision": "deny_missing_consent", "resource_type": "caldav_source"},
+ False,
+ "consent_denied",
+ "provider_denied_by_policy",
+ ),
+ ],
+)
+def test_permission_change_intent_records_policy_variants_without_provider_write(
+ admin_client,
+ mock_db,
+ request_body,
+ expected_allowed,
+ expected_reason,
+ expected_denial_result,
+):
+ response = admin_client.post(
+ "/api/security/permission-change-intent",
+ json=request_body,
+ )
+
+ assert response.status_code == 200, response.text
+ body = response.json()
+ assert body["decision"] == request_body["decision"]
+ assert body["resource_type"] == request_body["resource_type"]
+ assert body["allowed"] is expected_allowed
+ assert body["reason"] == expected_reason
+ assert body["provider_write_executed"] is False
+ assert body["denial_result"] == expected_denial_result
+ assert mock_db.committed is True
+ assert len(mock_db.added) == 1
+ audit_event = mock_db.added[0]
+ assert isinstance(audit_event, SecurityAuditEvent)
+ assert audit_event.event_action == "permission_change_intent"
+ assert audit_event.resource_type == request_body["resource_type"]
+ assert audit_event.detail_text == (
+ f"permission_intent={request_body['decision']}; "
+ f"resource_type={request_body['resource_type']}; "
+ f"policy_reason={expected_reason}; "
+ "provider_write_executed=false"
+ )
+ assert "workspace-outside-scope" not in response.text
+ assert settings.SECONDARY_DATA_REGION not in response.text
+ assert "provider.write" not in response.text
+
+
+def test_permission_change_intent_requires_admin_role(mock_db):
+ async def override_get_db():
+ yield mock_db
+
+ app.dependency_overrides[get_db] = override_get_db
+ try:
+ with TestClient(
+ app,
+ headers={
+ "X-User-Id": "member",
+ "X-User-Role": "member",
+ "X-Organization-Id": "org-acme",
+ },
+ ) as client:
+ response = client.post(
+ "/api/security/permission-change-intent",
+ json={
+ "decision": "allow_writeback",
+ "resource_type": "webdav_repository",
+ },
+ )
+ finally:
+ app.dependency_overrides.pop(get_db, None)
+
+ assert response.status_code == 403
+ assert "Admin role is required" in response.text
+ assert mock_db.added == []
+ assert mock_db.committed is False
+
+
+def test_permission_change_intent_rejects_extra_client_controlled_fields(
+ admin_client,
+):
+ response = admin_client.post(
+ "/api/security/permission-change-intent",
+ json={
+ "decision": "deny_external_write",
+ "resource_type": "provider_secret",
+ "x-user-id": "attacker",
+ },
+ )
+
+ assert response.status_code == 422
+ assert "extra_forbidden" in response.text
+
+
def test_member_surface_only_returns_owned_sources(mock_db):
async def override_get_db():
yield MockAsyncSession(
diff --git a/design-qa.md b/design-qa.md
new file mode 100644
index 000000000..8bfb09e0c
--- /dev/null
+++ b/design-qa.md
@@ -0,0 +1,85 @@
+# Naruon Design QA
+
+source visual truth path:
+
+- `docs/ui-ux/mockups/mockup_36.png`
+- `docs/ui-ux/mockups/mockup_40.png`
+
+implementation screenshot path:
+
+- `docs/superpowers/artifacts/naruon-figma-package/qa/figma-desktop-mail-detail.png`
+- `docs/superpowers/artifacts/naruon-figma-package/qa/figma-mobile-context-sheet.png`
+- `docs/superpowers/artifacts/naruon-figma-package/qa/figma-interaction-states.png`
+
+viewport:
+
+- Desktop Figma frame: 1440 x 1024
+- Mobile Figma frame: 390 x 844
+
+state:
+
+- Desktop: `Desktop / Mail Detail / Evidence Review` (`3:157`)
+- Mobile: `Mobile / Context Synthesis Bottom Sheet` (`3:273`)
+- Interaction follow-up cluster: `Naruon Interaction States / 2026-07-02` (`14:2`)
+- Source drawer state: `Desktop / Interaction / Source Drawer Open` (`14:3`)
+- Draft review state: `Desktop / Interaction / Draft Reply Review` (`14:41`)
+- Schedule confirmation state: `Desktop / Interaction / Schedule Confirmation` (`14:78`)
+- Prototype notes: `Desktop / Interaction / Prototype Notes` (`14:118`)
+
+full-view comparison evidence:
+
+- `docs/superpowers/artifacts/naruon-figma-package/qa/compare-desktop-mockup36-vs-figma.png`
+- `docs/superpowers/artifacts/naruon-figma-package/qa/compare-mobile-mockup40-vs-figma.png`
+
+focused region comparison evidence:
+
+- Focused region was not separated into additional crops because the compared frames are readable at full-view scale and the current deliverable is a first vertical slice, not a pixel-perfect clone. The full-view comparison covers the critical regions: mail thread, `맥락 종합`, `판단 포인트`, source chips, confidence badge, action buttons, and mobile bottom sheet.
+
+**Findings**
+
+- No actionable P0/P1/P2 findings remain for the first vertical slice.
+ Location: desktop and mobile Figma frames.
+ Evidence: the Figma screenshots render the mail detail, context synthesis, decision point, source chips, confidence badge, and execution actions without visible overlap, clipped Korean UI labels, blank placeholder frames, or shimmer placeholders.
+ Impact: the design package is usable as a design handoff for the scoped slice.
+ Fix: none required before handoff.
+
+**Open Questions**
+
+- The Figma slice intentionally simplifies the density of `mockup_36.png` and `mockup_40.png`. It is aligned to the Product Design scope as a handoff slice, not a full pixel clone of every source screen.
+- Live product analytics data is unavailable, so KPI claims remain framework-level only.
+
+**Implementation Checklist**
+
+- Figma pages exist: `Source Map`, `Foundations`, `Components`, `Desktop Screens`, `Mobile Screens`, `QA Notes`.
+- Source mockups are uploaded on the Figma `Source Map` page as `mockup_19`, `mockup_30`, `mockup_31`, `mockup_36`, `mockup_37`, `mockup_40`, and `mockup_41`.
+- First desktop slice exists at node `3:157`.
+- First mobile slice exists at node `3:273`.
+- Component page includes table row and source drawer coverage.
+- Desktop page includes expansion roadmap node `11:17`.
+- Figma Code Connect was not used.
+
+**Follow-up Polish**
+
+- Add higher-density desktop variants for the participant list, attachment file rail, and meeting proposal panel from `mockup_36.png`.
+- Add more mobile states for reply review, source drawer, and schedule confirmation.
+- Replace local minimal components with an official team library if a newer Naruon Figma source appears.
+
+patches made since the previous QA pass:
+
+- Created the Figma file `https://www.figma.com/design/68b5XB58w8nwT2LYOOnikK`.
+- Created required Figma pages and first vertical slice frames.
+- Uploaded source mockups into the Figma `Source Map` page.
+- Captured Figma desktop/mobile screenshots and generated source-vs-Figma comparison boards.
+- Added table row and source drawer components after completion audit.
+- Added expansion roadmap for Home, Calendar, Data, AI Hub, Security, and Settings.
+- Added a follow-up interaction-state cluster for source-chip opening, evidence drawer, draft reply review, and schedule confirmation.
+- Added typed product-event contract and event dictionary for `context_synthesis_viewed`, `source_chip_opened`, `calendar_reflected`, draft-reply events, context-search events, and guardrail events.
+- Added privacy-safe local dispatcher and actual call sites in `EmailDetail.tsx` and `SearchLayout.tsx`.
+- Added `SourceDrawer.tsx` and connected `근거 원본 보기` to an accessible source evidence drawer.
+- Added Vitest coverage for product-event validation, mail-detail source drawer behavior, mail-detail action events, and context-search events.
+- Added a controlled paid-pilot readiness spec and repeatable `pnpm --dir frontend pilot:smoke` browser gate for `/mail` and `/search`.
+- Verified the pilot smoke path with no console errors or warnings and 1440 x 1024 screenshots at `/tmp/naruon-pilot-mail.png` and `/tmp/naruon-pilot-search.png`.
+- Added a 20B KRW enterprise sale readiness plan for the implemented frontend slice, with explicit separation between buyer-reviewable product evidence and unresolved public-launch/procurement requirements.
+- Hardened the pilot smoke gate to localhost-only targets, bounded browser-local product-event history, normalized fallback event IDs, and generated unique `SourceDrawer` ARIA IDs.
+
+final result: passed for the scoped design-to-code and controlled paid-pilot demo slice; buyer-reviewable for the implemented frontend slice after release gates pass, but not a public-launch or final procurement-complete claim
diff --git a/docs/superpowers/artifacts/naruon-figma-package/index.html b/docs/superpowers/artifacts/naruon-figma-package/index.html
new file mode 100644
index 000000000..a972aa1a0
--- /dev/null
+++ b/docs/superpowers/artifacts/naruon-figma-package/index.html
@@ -0,0 +1,898 @@
+
+
+
+
+
+ Naruon Figma Product Design Analytics Package
+
+
+
+
+
+
+
+
+
+
Figma-ready handoff · Code Connect excluded
+
메일 맥락을 근거 기반 판단과 실행으로 연결하는 Naruon 제품/디자인/분석 패키지
+
+ 이 보드는 `docs/ui-ux`의 canonical mockup을 기반으로 Figma 산출물의 페이지 구조,
+ 핵심 사용자 흐름, 컴포넌트 범위, KPI 측정 체계, QA 기준을 한 화면에서 검토하도록 만든 정적 패키지입니다.
+
+
+ develop: 00e9c15
+ 41 canonical mockups
+ 45 reference images
+ Figma planKey 필요
+
+
+
+
+

+
+
+
첫 수직 슬라이스
+
Mail detail -> `맥락 종합` -> `판단 포인트` -> `실행 항목` / `답장 초안` / `일정 반영`.
+
+ source chips
+ confidence
+ evidence panel
+ action cards
+
+
+
+
+
+
+ 41canonical mockups under `docs/ui-ux/mockups`
+ 45durable reference images with SHA-256 rows
+ 945individual crop rows for detailed component review
+ 0Code Connect dependencies in this package
+
+
+
+
+
+
Source Map
+
Figma의 `Source Map` 페이지로 옮길 핵심 이미지입니다. 새 시각 자료를 만들지 않고 repo mockup을 그대로 근거로 사용합니다.
+
+
docs/ui-ux is authoritative
+
+
+
+
+ mockup_19Full Mail screen: selected thread, AI `맥락 종합`, `판단 포인트`, related schedule, action panel.
+
+
+
+ mockup_31AI decision components: synthesis, decision point, action item, confidence, source labels.
+
+
+
+ mockup_37Applied `맥락 검색`: query, filters, result groupings, evidence/action panel.
+
+
+
+ mockup_40Mobile context synthesis, quick action sheet, compact judgment-to-action flow.
+
+
+
+
+
+
+
+
Figma File Structure
+
실제 Figma 파일에는 아래 6개 페이지를 생성합니다. `create_new_file`에는 `planKey`가 필요하므로 현재는 Figma-ready 구조로 보존합니다.
+
+
fileKey or planKey required
+
+
+
Source MapMockup paths, SHA-256 refs, aliases
+
FoundationsLogo, color, type, spacing, radii
+
ComponentsShell, cards, chips, badges, tables
+
Desktop ScreensMail, Home, Context Search, Calendar
+
Mobile ScreensInbox, thread, synthesis, quick actions
+
QA NotesScreenshot checks and backlog
+
+
+
+
+
+
+
Mail Detail -> Judgment -> Execution
+
첫 Figma vertical slice의 정보 구조를 HTML로 재현했습니다. 실제 제품 화면이 아니라 Figma 제작을 위한 구조 보드입니다.
+
+
minimum useful slice
+
+
+
+
Naruon
+
메일
+
맥락 검색
+
일정
+
실행 항목
+
프로젝트
+
AI 허브
+
보안
+
설정
+
+
+
+ 받은편지함
+ 프로젝트 메일
+ 첨부 있음
+
+
+
Q2 출시 계획09:18
+
우선순위 조정과 고객 미팅 일정 확인
+
첨부, 일정 후보, 이전 결정 로그가 함께 연결된 스레드입니다.
+
+
+
파트너 계약어제
+
보안 검토 요청 및 공유 범위 확인
+
정책 승인과 외부 공유 리스크가 포함되어 있습니다.
+
+
+
선택된 스레드
+
고객사 일정 후보, 제품 출시 우선순위, 첨부 문서 검토 요청이 한 스레드에 섞여 있습니다.
+
사용자는 AI 결론을 바로 수락하지 않고, 연결된 근거와 신뢰도를 검토한 뒤 실행합니다.
+
+
+
+
+
+
+
+
+
+
+
Component Inventory
+
Figma `Components` 페이지에서 우선 컴포넌트화할 항목입니다. repo mockup의 반복 요소를 먼저 재사용합니다.
+
+
+
+
+ Shell
+
+ - Top GNB
+ - Local sidebar
+ - Primary work area
+ - Evidence/action panel
+
+
+
+ Evidence
+
+ - Source chip
+ - Confidence badge
+ - Relation card
+ - Timeline item
+
+
+
+ Execution
+
+ - `실행 항목` card
+ - `답장 초안` card
+ - Schedule candidate
+ - Confirm/cancel actions
+
+
+
+
+
+
+
+
+
Data Analytics Measurement Plan
+
현재 live analytics source는 없으므로 성과 수치가 아니라 측정 체계입니다. baseline 수집 전까지 target은 provisional로 유지합니다.
+
+
no live analytics data
+
+
+
+ Primary KPIs
+
+ - Context synthesis usage
+ - Decision-to-action conversion
+
+
+
+ Driver Metrics
+
+ - Evidence/source-chip interaction
+ - Context search success
+ - Draft reply acceptance
+ - Calendar/task conversion
+
+
+
+ Guardrails
+
+ - Model quality
+ - P50/P95 latency
+ - Trust/safety policy events
+
+
+
+
+
+
+
+
+
QA Notes
+
이 보드 자체와 향후 Figma 파일 모두 같은 기준으로 검증합니다.
+
+
+
+
+
Acceptance Checks
+
+ - 6개 Figma page 구조가 명시되어야 합니다.
+ - Mail vertical slice가 판단과 실행으로 이어져야 합니다.
+ - 필수 한국어 용어가 화면과 문서에 반영되어야 합니다.
+ - 텍스트 clipping, overlap, blank image, placeholder가 없어야 합니다.
+ - Code Connect는 생성하지도 의존하지도 않아야 합니다.
+
+
+
+
Current External Gap
+
+ `create_new_file` 도구는 사용 가능하지만 Figma `planKey`가 필수입니다.
+ 현재 세션에는 plan 조회 도구나 사용자 제공 Figma URL이 없어 실제 Figma 파일 생성은 대기 상태입니다.
+
+
+ needs fileKey
+ or planKey
+ local package ready
+
+
+
+
+
+
+
+
diff --git a/docs/superpowers/artifacts/naruon-figma-package/qa/compare-desktop-mockup36-vs-figma.png b/docs/superpowers/artifacts/naruon-figma-package/qa/compare-desktop-mockup36-vs-figma.png
new file mode 100644
index 000000000..5d8a022b8
Binary files /dev/null and b/docs/superpowers/artifacts/naruon-figma-package/qa/compare-desktop-mockup36-vs-figma.png differ
diff --git a/docs/superpowers/artifacts/naruon-figma-package/qa/compare-mobile-mockup40-vs-figma.png b/docs/superpowers/artifacts/naruon-figma-package/qa/compare-mobile-mockup40-vs-figma.png
new file mode 100644
index 000000000..6d4ebdf8d
Binary files /dev/null and b/docs/superpowers/artifacts/naruon-figma-package/qa/compare-mobile-mockup40-vs-figma.png differ
diff --git a/docs/superpowers/artifacts/naruon-figma-package/qa/desktop-1440-cdp.png b/docs/superpowers/artifacts/naruon-figma-package/qa/desktop-1440-cdp.png
new file mode 100644
index 000000000..b0cb770eb
Binary files /dev/null and b/docs/superpowers/artifacts/naruon-figma-package/qa/desktop-1440-cdp.png differ
diff --git a/docs/superpowers/artifacts/naruon-figma-package/qa/desktop-1440.png b/docs/superpowers/artifacts/naruon-figma-package/qa/desktop-1440.png
new file mode 100644
index 000000000..4764a3a89
Binary files /dev/null and b/docs/superpowers/artifacts/naruon-figma-package/qa/desktop-1440.png differ
diff --git a/docs/superpowers/artifacts/naruon-figma-package/qa/figma-desktop-mail-detail.png b/docs/superpowers/artifacts/naruon-figma-package/qa/figma-desktop-mail-detail.png
new file mode 100644
index 000000000..63b56c249
Binary files /dev/null and b/docs/superpowers/artifacts/naruon-figma-package/qa/figma-desktop-mail-detail.png differ
diff --git a/docs/superpowers/artifacts/naruon-figma-package/qa/figma-interaction-states.png b/docs/superpowers/artifacts/naruon-figma-package/qa/figma-interaction-states.png
new file mode 100644
index 000000000..6a83935bf
Binary files /dev/null and b/docs/superpowers/artifacts/naruon-figma-package/qa/figma-interaction-states.png differ
diff --git a/docs/superpowers/artifacts/naruon-figma-package/qa/figma-mobile-context-sheet.png b/docs/superpowers/artifacts/naruon-figma-package/qa/figma-mobile-context-sheet.png
new file mode 100644
index 000000000..f88fc9dcd
Binary files /dev/null and b/docs/superpowers/artifacts/naruon-figma-package/qa/figma-mobile-context-sheet.png differ
diff --git a/docs/superpowers/artifacts/naruon-figma-package/qa/mobile-390-cdp.png b/docs/superpowers/artifacts/naruon-figma-package/qa/mobile-390-cdp.png
new file mode 100644
index 000000000..886490527
Binary files /dev/null and b/docs/superpowers/artifacts/naruon-figma-package/qa/mobile-390-cdp.png differ
diff --git a/docs/superpowers/artifacts/naruon-figma-package/qa/mobile-390-v2.png b/docs/superpowers/artifacts/naruon-figma-package/qa/mobile-390-v2.png
new file mode 100644
index 000000000..e77fa2e1b
Binary files /dev/null and b/docs/superpowers/artifacts/naruon-figma-package/qa/mobile-390-v2.png differ
diff --git a/docs/superpowers/artifacts/naruon-figma-package/qa/mobile-390-v3.png b/docs/superpowers/artifacts/naruon-figma-package/qa/mobile-390-v3.png
new file mode 100644
index 000000000..9c79418a2
Binary files /dev/null and b/docs/superpowers/artifacts/naruon-figma-package/qa/mobile-390-v3.png differ
diff --git a/docs/superpowers/artifacts/naruon-figma-package/qa/mobile-390.png b/docs/superpowers/artifacts/naruon-figma-package/qa/mobile-390.png
new file mode 100644
index 000000000..e77fa2e1b
Binary files /dev/null and b/docs/superpowers/artifacts/naruon-figma-package/qa/mobile-390.png differ
diff --git a/docs/superpowers/plans/2026-07-02-naruon-20b-enterprise-sale-readiness.md b/docs/superpowers/plans/2026-07-02-naruon-20b-enterprise-sale-readiness.md
new file mode 100644
index 000000000..7a1656c55
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-02-naruon-20b-enterprise-sale-readiness.md
@@ -0,0 +1,81 @@
+# Naruon 20B KRW Enterprise Sale Readiness Plan
+
+> Scope: This plan raises PR #893 from a controlled paid-pilot demo toward a buyer-reviewable enterprise sale package. It does not claim public SaaS launch readiness or guaranteed contract value.
+
+## Goal
+
+Prepare the current Naruon frontend slice so it can withstand a 2,000,000,000 KRW enterprise buyer technical review for the implemented `/mail` and `/search` workflows.
+
+The acceptable outcome is not "the whole company can publicly launch Naruon." The acceptable outcome is "the reviewed slice is technically coherent, repeatably demonstrable, privacy-safe for the mocked pilot path, and honest about missing production-operating prerequisites."
+
+## Completion Standard
+
+- `/mail` proves context synthesis, source evidence review, draft generation, simulated send, task creation, and calendar intent without runtime failure.
+- `/search` proves query submission, result detail, and sender relationship capture without runtime failure.
+- Product events are local-only, contract-checked, and free of raw email body, raw draft body, and raw query text.
+- Smoke tests cannot accidentally target staging or production; `NARUON_PILOT_BASE_URL` must remain localhost-only.
+- `SourceDrawer` accessibility remains stable under single and multiple component instances.
+- Local product-event history is bounded, so long-running sessions do not grow memory without limit.
+- `event_id` generation is stable and does not double-prefix fallback IDs.
+- Release gates pass: `test`, `typecheck`, `build`, `pilot:smoke`, `git diff --check`, required-event scan, and placeholder/launch-claim scan.
+- PR #893 is updated with the hardening changes, while `.Jules/palette.md` and `.Jules/sentinel.md` remain untouched user changes.
+
+## Implementation Tasks
+
+### Task 1: Harden Runtime Safety
+
+- [x] Restrict `frontend/scripts/pilot-ui-smoke.mjs` to localhost targets only.
+- [x] Add a regression test for localhost-only smoke targets.
+- [x] Keep deterministic mocked local APIs for `/auth/session`, `/api/emails`, `/api/llm/*`, `/api/search`, `/api/ontology/*`, task, calendar, and WebDAV endpoints.
+- [x] Make source drawer smoke selection resilient to duplicate text matches in the mail layout.
+
+### Task 2: Harden Product Events
+
+- [x] Reject raw sensitive fields and non-contract payload keys.
+- [x] Keep events browser-local; do not add an external analytics destination.
+- [x] Cap local product-event buffer to the most recent 200 events.
+- [x] Normalize fallback event IDs so callers own the prefix exactly once.
+- [x] Add regression coverage for local event buffer capping.
+
+### Task 3: Harden Evidence Drawer Accessibility
+
+- [x] Keep `role="dialog"` and `aria-modal="true"`.
+- [x] Keep open focus on the close button, Escape close, backdrop/mouse close, scroll lock, and focus restore.
+- [x] Generate per-instance title and description IDs with `useId()` to avoid duplicate ARIA targets.
+
+### Task 4: Preserve The Sales Boundary
+
+- [x] State that the current work supports buyer-reviewed controlled pilots, not public launch.
+- [x] Keep public-launch blockers explicit: hosted deployment, tenant auth/audit, provider send, live mailbox integration, analytics governance, billing/legal, SLA, support, incident response, and data-processing terms.
+- [x] Avoid live KPI or revenue claims unless backed by measured production data.
+
+### Task 5: Gate And Publish
+
+- [x] Run `pnpm --dir frontend test`.
+- [x] Run `pnpm --dir frontend typecheck`.
+- [x] Run `pnpm --dir frontend build`.
+- [x] Run `pnpm --dir frontend pilot:smoke`.
+- [x] Run `git diff --check`.
+- [x] Run required-event and placeholder scans.
+- [x] Commit only product changes, excluding `.Jules/*`.
+- [x] Push `sellable-pilot-hardening-2026-07-02` and update PR #893.
+- [ ] Re-check PR #893 status after push.
+
+## Latest Gate Evidence
+
+Executed on 2026-07-02 KST from branch `sellable-pilot-hardening-2026-07-02`:
+
+- `pnpm --dir frontend test`: passed, 44 test files and 322 tests.
+- `pnpm --dir frontend typecheck`: passed.
+- `pnpm --dir frontend build`: passed, optimized Next 16 production build.
+- `pnpm --dir frontend pilot:smoke`: passed, mail/search flows exercised, screenshots saved at `/tmp/naruon-pilot-mail.png` and `/tmp/naruon-pilot-search.png`.
+- `git diff --check`: passed.
+- required-event scan: passed; required product-event names appear in code/docs.
+- placeholder/launch-claim scan: returned only guarded caveat language about live KPI and public-launch claims.
+- screenshot file check: both pilot screenshots are 1440 x 1024 PNGs.
+
+## Current Sale Readiness Position
+
+The implemented slice can be presented as a controlled enterprise pilot for technical review when the gates above pass. It is not yet a full 20B KRW enterprise contract package without commercial artifacts, procurement terms, security questionnaire answers, DPA/legal review, production deployment, tenant isolation audit, provider-send verification, customer support motion, and SLA/incident process.
+
+That distinction is part of the sellable package: the code slice demonstrates product value and engineering discipline; the remaining items are operating-company and deployment prerequisites.
diff --git a/docs/superpowers/plans/2026-07-02-naruon-20b-full-product-commercial-readiness.md b/docs/superpowers/plans/2026-07-02-naruon-20b-full-product-commercial-readiness.md
new file mode 100644
index 000000000..0600dcff5
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-02-naruon-20b-full-product-commercial-readiness.md
@@ -0,0 +1,716 @@
+# Naruon 20B Full Product Commercial Readiness Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Convert Naruon from a controlled frontend pilot slice into a buyer-reviewable full product package for a 2,000,000,000 KRW enterprise sale review.
+
+**Architecture:** Keep the current monorepo as the source of truth. Extend existing frontend, backend, connector, Figma, and docs boundaries before introducing any new package. Separate proof artifacts from product code: implementation changes live in existing source modules, while sales-readiness evidence lives under `docs/superpowers/`.
+
+**Tech Stack:** Next 16, React 19, TypeScript 6, Vitest, Playwright, FastAPI, Python 3.14 CI target, PostgreSQL, Docker Compose, Figma MCP (`get_metadata`, `search_design_system`, `use_figma`, `get_screenshot` with Code Connect excluded), GitHub app/CLI evidence, Product Design screenshot audit, Data Analytics KPI framework.
+
+## Global Constraints
+
+- Do not use Figma Code Connect.
+- Do not add a git submodule in the first pass.
+- Do not split a separately versioned library until at least three independent consumers or independent release ownership exists.
+- Do not add a new frontend dependency for work already covered by React, Next, CSS, existing UI components, Vitest, or Playwright.
+- Do not claim public launch readiness from PR #893.
+- Do not claim live KPI values without a live analytics source.
+- Do not send raw email body, raw draft body, raw search query, credentials, provider usernames, or raw mailbox paths to analytics.
+- Preserve existing user changes in `.Jules/palette.md` and `.Jules/sentinel.md`.
+- Treat review-process delay as non-blocking; treat actual failing checks or reproducible product defects as blocking.
+
+---
+
+## File Structure
+
+- Create: `docs/superpowers/specs/2026-07-02-naruon-20b-full-commercial-readiness-design.md`
+ - Owns the 20B KRW full-product completion standard.
+- Create: `docs/superpowers/plans/2026-07-02-naruon-20b-full-product-commercial-readiness.md`
+ - Owns this implementation plan.
+- Create: `docs/superpowers/reports/2026-07-02-naruon-20b-current-state-audit.md`
+ - Captures current evidence, gaps, and non-blockers.
+- Modify later: `docs/superpowers/reports/2026-07-02-naruon-event-dictionary.md`
+ - Add new events only after the corresponding UI actions exist.
+- Modify later: `frontend/src/components/*`
+ - Extend UI surfaces area by area.
+- Modify later: `backend/api/*`, `backend/services/*`, `backend/tests/*`
+ - Add production-path evidence for provider integration, authorization, audit, and data quality.
+- Modify later: Figma file `68b5XB58w8nwT2LYOOnikK`
+ - Repair or recreate required pages without Code Connect.
+
+## Task 1: Lock Current Evidence
+
+**Files:**
+- Create: `docs/superpowers/reports/2026-07-02-naruon-20b-current-state-audit.md`
+
+**Interfaces:**
+- Consumes: GitHub PR #893, issue #634, Figma metadata, Product Design context preflight, repo tree, existing superpowers docs.
+- Produces: A stable baseline used by every later task.
+
+- [x] **Step 1: Record branch and dirty state**
+
+Run:
+
+```bash
+git -C naruon status --short --branch
+git -C naruon log --oneline -5
+```
+
+Expected:
+
+```text
+## sellable-pilot-hardening-2026-07-02
+ M .Jules/palette.md
+ M .Jules/sentinel.md
+```
+
+- [x] **Step 2: Record PR #893 status**
+
+Run:
+
+```bash
+gh -R ContextualWisdomLab/naruon pr view 893 --json number,title,url,headRefName,headRefOid,baseRefName,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup
+```
+
+Expected evidence:
+
+- head SHA is `a970b78c5eb6664e844b48ce15689feb0c27bda2`.
+- `opencode-review` may be `IN_PROGRESS`.
+- Passing app/security/image checks are product evidence.
+- `CHANGES_REQUESTED` and review delay are tracked separately from actual product blockers.
+
+- [x] **Step 3: Record Figma file reality**
+
+Use Figma tools:
+
+```text
+get_metadata(fileKey="68b5XB58w8nwT2LYOOnikK")
+search_design_system(fileKey="68b5XB58w8nwT2LYOOnikK", query="naruon dashboard mail search calendar security settings evidence drawer", disableCodeConnect=true)
+```
+
+Expected evidence:
+
+- Current metadata exposes top-level page `Source Map`.
+- Design-system search returns no components, variables, or styles.
+- Required follow-up is Figma file structure repair or verified replacement.
+
+- [x] **Step 4: Record GitHub issue #634**
+
+Use GitHub app:
+
+```text
+fetch_issue(repository_full_name="ContextualWisdomLab/naruon", issue_number=634)
+```
+
+Expected evidence:
+
+- Issue is open.
+- The issue tracks a post-merge security gate governance gap, not a current PR #893 code failure.
+
+## Task 2: Define Full-Product Commercial Standard
+
+**Files:**
+- Create: `docs/superpowers/specs/2026-07-02-naruon-20b-full-commercial-readiness-design.md`
+
+**Interfaces:**
+- Consumes: `docs/ui-ux/naruon-ui-ux-mapping.md`, existing commercial pilot spec, current PR evidence.
+- Produces: Product, design, analytics, security, ops, and commercial gates.
+
+- [x] **Step 1: Separate pilot slice from full sale readiness**
+
+Write this rule into the spec:
+
+```text
+PR #893 proves a controlled frontend pilot slice. It does not prove final enterprise procurement or public launch readiness.
+```
+
+- [x] **Step 2: Define ten-area product coverage**
+
+Use these areas exactly:
+
+```text
+Home, Mail, Calendar, Tasks, Projects, Context Search, Data, AI Hub, Security, Settings
+```
+
+Each area must have buyer-visible UI coverage, runnable test coverage, and either live integration evidence or explicit caveat language.
+
+- [x] **Step 3: Define non-negotiable gates**
+
+Include gates for:
+
+```text
+buyer-visible product coverage
+real integration boundaries
+security and compliance
+production operations
+Figma and Product Design evidence
+Data Analytics and ROI
+commercial handoff
+```
+
+## Task 3: Decide Library/Submodule Boundary
+
+**Files:**
+- Create: `docs/superpowers/specs/2026-07-02-naruon-20b-full-commercial-readiness-design.md`
+- Create: `docs/superpowers/reports/2026-07-02-naruon-20b-current-state-audit.md`
+
+**Interfaces:**
+- Consumes: current repo layout, `frontend/package.json`, `backend/`, `connector/`.
+- Produces: A boundary decision for future implementation.
+
+- [x] **Step 1: Reject submodule for the first pass**
+
+Decision:
+
+```text
+No git submodule now. The work has one product repo, one active frontend package, one backend, one connector, and no independent external consumer that justifies submodule overhead.
+```
+
+- [x] **Step 2: Reject separate published library for the first pass**
+
+Decision:
+
+```text
+No separately versioned library now. Keep shared code under existing frontend/backend boundaries until there are at least three consumers, independent release ownership, or a public API contract.
+```
+
+- [x] **Step 3: Allow a future internal workspace package only with evidence**
+
+Allowed future condition:
+
+```text
+Create an internal workspace package only if product events, design tokens, or shared UI primitives are reused by frontend, a separate admin console, and buyer demo tooling.
+```
+
+## Task 4: Repair Or Replace Figma Structure
+
+**Files:**
+- Figma file: `68b5XB58w8nwT2LYOOnikK`
+- Update after work: `docs/superpowers/reports/2026-07-02-naruon-20b-current-state-audit.md`
+
+**Interfaces:**
+- Consumes: Figma `Source Map`, canonical mockups under `docs/ui-ux/mockups/`.
+- Produces: Required Figma pages and page metadata evidence.
+
+- [x] **Step 1: Create missing pages without Code Connect**
+
+Use `use_figma` with `skillNames="figma-use"` and this JavaScript:
+
+```js
+const requiredPages = [
+ "Source Map",
+ "Foundations",
+ "Components",
+ "Desktop Screens",
+ "Mobile Screens",
+ "Interaction States",
+ "Sales Demo",
+ "QA Notes",
+];
+
+const existing = new Set(figma.root.children.map((page) => page.name));
+const createdNodeIds = [];
+
+for (const name of requiredPages) {
+ if (!existing.has(name)) {
+ const page = figma.createPage();
+ page.name = name;
+ createdNodeIds.push(page.id);
+ }
+}
+
+return {
+ existingPages: Array.from(existing),
+ requiredPages,
+ createdNodeIds,
+ allPages: figma.root.children.map((page) => ({ id: page.id, name: page.name })),
+};
+```
+
+Expected:
+
+```text
+Figma returns createdNodeIds for missing pages and allPages includes all eight required pages.
+```
+
+- [x] **Step 2: Verify page metadata**
+
+Use:
+
+```text
+get_metadata(fileKey="68b5XB58w8nwT2LYOOnikK")
+```
+
+Expected:
+
+```text
+Top-level pages include Source Map, Foundations, Components, Desktop Screens, Mobile Screens, Interaction States, Sales Demo, QA Notes.
+```
+
+Actual 2026-07-02 KST evidence:
+
+```text
+use_figma returned allPages with all eight required pages.
+get_metadata without nodeId still listed only Source Map, so direct page metadata was also checked.
+get_metadata(nodeId="15:3") confirmed the Sales Demo page.
+```
+
+- [x] **Step 3: Add a Sales Demo frame**
+
+Create a `Sales Demo / 20B Enterprise Review Flow` frame on the `Sales Demo` page. It must list:
+
+```text
+1. Home decision dashboard
+2. Mail evidence-to-action
+3. Context search to relation capture
+4. Calendar/task provider intent
+5. Data quality and document materialization
+6. AI Hub workflow run evidence
+7. Security access/audit proof
+8. Settings members/accounts/billing/developer package
+```
+
+Expected:
+
+```text
+The frame is visible in Figma and has no placeholder shimmer left enabled.
+```
+
+Actual 2026-07-02 KST evidence:
+
+```text
+Sales Demo page id: 15:3
+Sales Demo frame id: 16:2
+Frame name: Sales Demo / 20B Enterprise Review Flow
+Final screenshot downloaded to /tmp/naruon-20b-sales-demo-final.png
+PNG size: 1080 x 608
+Visual inspection: no text overlap, no clipped bottom copy, no placeholder shimmer.
+```
+
+## Task 5: Build Product Design Audit Package
+
+**Files:**
+- Create later: `docs/superpowers/reports/2026-07-02-naruon-20b-product-design-audit.md`
+- Create later: `docs/superpowers/artifacts/naruon-20b-product-design-audit/`
+
+**Interfaces:**
+- Consumes: local running app, Figma, existing mockups.
+- Produces: screenshot-backed UX and accessibility findings.
+
+- [ ] **Step 1: Capture buyer flows**
+
+Capture these routes at desktop 1440 x 1024 and mobile 390 x 844:
+
+```text
+/
+/mail
+/search
+/calendar
+/tasks
+/projects
+/data
+/ai-hub
+/security
+/settings
+```
+
+Expected:
+
+```text
+Every screenshot is saved locally and inspected before being accepted.
+```
+
+- [ ] **Step 2: Write audit findings**
+
+Each finding must include:
+
+```text
+severity
+flow step
+screenshot path
+what failed or passed
+user impact
+concrete fix
+```
+
+Expected:
+
+```text
+No finding relies on memory or uninspected screenshots.
+```
+
+## Task 6: Expand Data Analytics Readiness
+
+**Files:**
+- Modify: `docs/superpowers/reports/2026-07-02-naruon-event-dictionary.md`
+- Create later: `docs/superpowers/reports/2026-07-02-naruon-20b-kpi-roi-model.md`
+
+**Interfaces:**
+- Consumes: existing event dictionary, product-event implementation, buyer sale target.
+- Produces: KPI/ROI model with measurement caveats.
+
+- [ ] **Step 1: Add full-product event candidates only after UI actions exist**
+
+Candidate events:
+
+```text
+home_decision_opened
+calendar_candidate_confirmed
+task_status_changed
+project_decision_logged
+data_quality_check_requested
+ai_workflow_run_started
+security_permission_changed
+settings_member_invited
+provider_write_intent_created
+provider_write_executed
+```
+
+Expected:
+
+```text
+No event is marked implemented unless code emits it and tests cover privacy-safe payloads.
+```
+
+- [x] **Step 2: Define ROI model**
+
+Minimum model:
+
+```text
+time_saved_per_user_per_week_hours
+fully_loaded_hourly_cost_krw
+weekly_active_users
+evidence_open_rate
+decision_to_action_conversion_rate
+pilot_period_weeks
+risk_reduction_adjustment
+```
+
+Formula:
+
+```text
+estimated_period_value_krw =
+ time_saved_per_user_per_week_hours
+ * fully_loaded_hourly_cost_krw
+ * weekly_active_users
+ * pilot_period_weeks
+ * risk_reduction_adjustment
+```
+
+Expected:
+
+```text
+The report labels all unmeasured values as assumptions and does not claim live performance.
+```
+
+Evidence:
+
+- `docs/superpowers/reports/2026-07-02-naruon-kpi-validation.md` now includes `ROI Model And Claim Gate`, the `estimated_period_value_krw` formula, measured-vs-assumption status for every model input, and rejected ROI/procurement claims.
+- `backend/tests/test_release_governance.py` now keeps the KPI/ROI caveat contract under CI.
+
+## Task 7: Extend Full Product Smoke Gates
+
+**Files:**
+- Modify: `.github/workflows/app-ci.yml`
+- Modify later: `frontend/scripts/pilot-ui-smoke.mjs`
+- Create: `frontend/scripts/full-product-ui-smoke.mjs`
+- Create: `frontend/scripts/full-product-ui-smoke.test.mjs`
+- Modify: `frontend/package.json`
+
+**Interfaces:**
+- Consumes: existing localhost-only pilot smoke.
+- Produces: full-product browser smoke that stays localhost-only.
+
+- [x] **Step 1: Add localhost-only resolver test**
+
+Use the existing `resolvePilotBaseUrl()` pattern. The full-product smoke must reject:
+
+```text
+https://staging.example.com
+https://naruon.example.com
+http://192.168.0.10:3000
+```
+
+Expected:
+
+```text
+The smoke script can only target localhost, 127.0.0.1, ::1, or [::1].
+```
+
+- [x] **Step 2: Exercise all ten routes**
+
+The script must visit:
+
+```text
+/
+/mail
+/search
+/calendar
+/tasks
+/projects
+/data
+/ai-hub
+/security
+/settings
+```
+
+Expected:
+
+```text
+Every route renders without console errors and saves a screenshot.
+```
+
+Actual implementation:
+
+```text
+Added frontend/scripts/full-product-ui-smoke.mjs.
+Added frontend/scripts/full-product-ui-smoke.test.mjs.
+Added pnpm --dir frontend full:smoke.
+The route smoke covers /, /mail, /search, /calendar, /tasks, /projects, /data, /ai-hub, /security, /settings.
+```
+
+- [x] **Step 3: Wire full-product smoke into CI**
+
+Add the deterministic full-product smoke to the frontend job after production build. Install Chromium explicitly before the smoke run so CI does not rely on an implicit browser cache.
+
+Expected:
+
+```text
+Application CI frontend runs pnpm run full:smoke after build.
+The full-product smoke remains localhost-only.
+```
+
+Actual implementation:
+
+```text
+.github/workflows/app-ci.yml installs Playwright Chromium and runs pnpm run full:smoke with NARUON_FULL_PRODUCT_BASE_URL=http://127.0.0.1:3001.
+```
+
+Validation evidence captured on 2026-07-02 KST:
+
+```text
+pnpm --dir frontend test scripts/full-product-ui-smoke.test.mjs
+Test Files 1 passed (1)
+Tests 3 passed (3)
+
+pnpm --dir frontend full:smoke
+Naruon full-product route smoke passed.
+Routes: /, /mail, /search, /calendar, /tasks, /projects, /data, /ai-hub, /security, /settings
+Screenshots: /tmp/naruon-full-product-smoke/home.png, /tmp/naruon-full-product-smoke/mail.png, /tmp/naruon-full-product-smoke/search.png, /tmp/naruon-full-product-smoke/calendar.png, /tmp/naruon-full-product-smoke/tasks.png, /tmp/naruon-full-product-smoke/projects.png, /tmp/naruon-full-product-smoke/data.png, /tmp/naruon-full-product-smoke/ai-hub.png, /tmp/naruon-full-product-smoke/security.png, /tmp/naruon-full-product-smoke/settings.png
+```
+
+## Task 8: Close Security Governance Gaps
+
+**Files:**
+- Modify: `scripts/ci/pr_governance_gate.sh`
+- Modify: `scripts/ci/test_pr_governance_gate.sh`
+- Create: `docs/superpowers/reports/2026-07-02-naruon-security-governance-followup.md`
+
+**Interfaces:**
+- Consumes: issue #634.
+- Produces: proof that future request-changes or missing required-check metadata cannot pass as green governance.
+
+- [x] **Step 1: Reproduce issue #634 condition**
+
+Read issue #634 and inspect current governance script behavior.
+
+Expected:
+
+```text
+The report identifies whether the green governance check can still occur when blocker metadata is unreadable.
+```
+
+- [x] **Step 2: Patch the smallest policy path**
+
+Ponytail rule:
+
+```text
+Patch one central gate, not every PR workflow.
+```
+
+Expected:
+
+```text
+The gate exits non-zero when it emits a blocker comment about unreadable required-check metadata.
+```
+
+Actual implementation:
+
+```text
+scripts/ci/pr_governance_gate.sh exits 1 after posting/updating a blocker comment.
+scripts/ci/test_pr_governance_gate.sh now records and asserts blocker, wait-state, and pass exit codes.
+No `.github` workflow change was required because the trusted workflow already runs the central script.
+```
+
+Validation evidence captured on 2026-07-02 KST:
+
+```text
+bash scripts/ci/test_pr_governance_gate.sh
+test_pr_governance_gate: PASS
+
+cd backend && python3 -m pytest tests/test_release_governance.py -q
+29 passed in 0.21s
+```
+
+## Task 9: Build Commercial Handoff Package
+
+**Files:**
+- Create: `docs/superpowers/reports/2026-07-02-naruon-20b-buyer-package.md`
+- Create: `docs/superpowers/reports/2026-07-02-naruon-20b-demo-script.md`
+- Create: `docs/superpowers/reports/2026-07-02-naruon-20b-security-questionnaire.md`
+- Create: `docs/superpowers/reports/2026-07-02-naruon-20b-sla-support-draft.md`
+
+**Interfaces:**
+- Consumes: verified product flows, security reports, analytics model, Figma screenshots.
+- Produces: buyer-facing artifacts.
+
+- [x] **Step 1: Write buyer package index**
+
+Sections:
+
+```text
+product overview
+demo script
+architecture
+deployment
+security
+privacy
+analytics
+SLA/support
+pilot acceptance
+known caveats
+evidence links
+```
+
+Expected:
+
+```text
+Every claim links to a repo file, PR, test, screenshot, Figma node, or report.
+```
+
+Actual implementation:
+
+```text
+Created buyer package index, buyer demo script, security questionnaire draft, and SLA/support draft.
+The documents explicitly reject public-launch, guaranteed 20B ROI, complete provider-write proof, and issue #634 closure claims.
+```
+
+## Task 10: Final Verification And Completion Decision
+
+**Files:**
+- Update: all reports above.
+
+**Interfaces:**
+- Consumes: all implementation and evidence artifacts.
+- Produces: final completion or explicit not-complete decision.
+
+- [x] **Step 1: Run local gates**
+
+Run:
+
+```bash
+pnpm --dir frontend test
+pnpm --dir frontend typecheck
+pnpm --dir frontend build
+pnpm --dir frontend pilot:smoke
+git diff --check
+```
+
+Expected:
+
+```text
+Every command exits 0 before claiming local frontend readiness.
+```
+
+Actual local frontend evidence captured on 2026-07-02 KST:
+
+```text
+pnpm --dir frontend test
+Test Files 45 passed (45)
+Tests 325 passed (325)
+
+pnpm --dir frontend typecheck
+tsc --noEmit
+
+pnpm --dir frontend build
+Compiled successfully
+
+pnpm --dir frontend lint
+eslint
+
+pnpm --dir frontend pilot:smoke
+Naruon pilot smoke passed.
+
+pnpm --dir frontend full:smoke
+Naruon full-product route smoke passed.
+
+git diff --check
+```
+
+- [x] **Step 2: Run backend gates**
+
+Run:
+
+```bash
+cd naruon/backend
+pytest
+```
+
+Expected:
+
+```text
+Backend tests pass before claiming production-path readiness.
+```
+
+Actual backend evidence captured on 2026-07-02 KST:
+
+```text
+cd backend && python3 -m ruff check .
+All checks passed!
+
+cd backend && python3 -m pytest -q
+1107 passed, 15 skipped in 11.29s
+```
+
+- [x] **Step 3: Run evidence scans**
+
+Run:
+
+```bash
+rg -n "Figma Code Connect|Code Connect" docs/superpowers frontend/src backend | cat
+rg -n "public launch ready|live KPI|20B complete|20억 완성" docs/superpowers frontend/src backend | cat
+```
+
+Expected:
+
+```text
+Matches are either explicit exclusions or guarded caveats, not unsupported success claims.
+```
+
+Actual scan interpretation on 2026-07-02 KST:
+
+```text
+Figma Code Connect matches are exclusion statements or evidence that Code Connect was not used.
+Public-launch/live-KPI/20B-ROI matches are caveats, rejected-language examples, or explicit no-claim statements.
+```
+
+- [ ] **Step 4: Decide completion**
+
+Completion is allowed only when:
+
+```text
+all ten IA surfaces have buyer-visible UI proof
+Figma required pages and screenshots exist
+Product Design audit has no P0/P1/P2 blockers
+analytics/ROI reports separate assumptions from measurements
+production/security/ops/commercial reports exist
+local and remote gates pass
+open governance risks are either closed or buyer-facing caveats
+```
+
+Expected:
+
+```text
+If any item is missing, keep the Goal active and continue work.
+```
diff --git a/docs/superpowers/plans/2026-07-02-naruon-commercial-pilot-readiness.md b/docs/superpowers/plans/2026-07-02-naruon-commercial-pilot-readiness.md
new file mode 100644
index 000000000..dcab3adba
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-02-naruon-commercial-pilot-readiness.md
@@ -0,0 +1,190 @@
+# Naruon Commercial Pilot Readiness Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make the current Naruon frontend slice repeatably demonstrable as a paid pilot product without claiming public launch readiness.
+
+**Architecture:** Keep the existing Next frontend as the product surface. Add a committed pilot smoke harness that exercises `/mail` and `/search` against deterministic mocked local API responses while preserving the privacy-safe local product-event dispatcher. Document the commercial-pilot line separately from public-launch caveats.
+
+**Tech Stack:** Next 16, React 19, TypeScript, Vitest, Playwright, local Next dev/preview server, Markdown.
+
+---
+
+### Task 1: Lock The Commercial Pilot Definition
+
+**Files:**
+- Create: `docs/superpowers/specs/2026-07-02-naruon-commercial-pilot-readiness-design.md`
+- Create: `docs/superpowers/plans/2026-07-02-naruon-commercial-pilot-readiness.md`
+- Modify: `docs/superpowers/reports/2026-07-02-naruon-design-to-code-telemetry-qa.md`
+
+- [x] **Step 1: Define pilot-ready versus public-launch-ready**
+
+ Record that this slice may be sold as a controlled paid pilot only when the local frontend demo, privacy-safe events, tests, build, and browser smoke pass.
+
+- [x] **Step 2: List explicit public-launch caveats**
+
+ Caveats must include hosted deployment, real auth/tenant audit, provider send, live backend/private mailbox integration, external analytics governance, billing/legal review, SLA, and support process.
+
+- [x] **Step 3: Update QA report with commercial-pilot wording**
+
+ Add a “Commercial Pilot Readiness” section that says the slice is pilot-demo ready after all gates pass, but not public-launch ready.
+
+### Task 2: Add Repeatable Browser Smoke Harness
+
+**Files:**
+- Create: `frontend/scripts/pilot-ui-smoke.mjs`
+- Modify: `frontend/package.json`
+
+- [x] **Step 1: Add `pilot:smoke` npm script**
+
+ Add this script:
+
+ ```json
+ {
+ "pilot:smoke": "node scripts/pilot-ui-smoke.mjs"
+ }
+ ```
+
+- [x] **Step 2: Implement deterministic API mocks**
+
+ The smoke harness must intercept:
+
+ - `/auth/session`
+ - `/api/network/graph`
+ - `/api/emails`
+ - `/api/emails/23`
+ - `/api/emails/thread/source-thread`
+ - `/api/llm/summarize`
+ - `/api/llm/draft`
+ - `/api/emails/send`
+ - `/api/tasks/from-email`
+ - `/api/calendar/writeback-intent`
+ - `/api/search`
+ - `/api/ontology/relationships`
+ - `/api/ontology/relationships/capture-source`
+ - `/api/tasks`
+ - `/api/calendar/writeback-sources`
+ - `/api/webdav/folders`
+
+- [x] **Step 3: Exercise the mail flow**
+
+ The harness must verify:
+
+ - inbox item visible
+ - detail opens
+ - source drawer opens
+ - close button and Escape close work
+ - draft generation fills `답장 초안`
+ - simulated send clears draft and shows send status
+ - task creation shows task status
+ - calendar writeback intent shows calendar status
+ - required mail events exist
+ - event JSON does not include the sensitive mail body or draft body
+
+- [x] **Step 4: Exercise the search flow**
+
+ The harness must verify:
+
+ - default result opens
+ - query submit opens a new result
+ - relationship capture creates a relationship card
+ - required search events exist
+ - event JSON does not include the raw query
+
+- [x] **Step 5: Save screenshots outside the repo**
+
+ Save:
+
+ - `/tmp/naruon-pilot-mail.png`
+ - `/tmp/naruon-pilot-search.png`
+
+### Task 3: Tighten Component Tests For Pilot Flow
+
+**Files:**
+- Modify: `frontend/src/components/EmailDetail.test.tsx`
+- Modify: `frontend/src/components/SearchLayout.test.tsx`
+- Modify: `frontend/src/lib/product-events.test.ts`
+
+- [x] **Step 1: Ensure mail tests cover drawer, draft, send, task, calendar events**
+
+ The existing test file must assert each event is emitted at least once and no raw sensitive text appears in recorded product events.
+
+- [x] **Step 2: Ensure search tests cover submit, result open, action create**
+
+ The existing search test must assert no raw query appears in recorded product events.
+
+- [x] **Step 3: Ensure product-event tests reject non-contract fields**
+
+ `recordProductEvent` must reject arbitrary fields that are not in the selected event contract.
+
+### Task 4: Run Release Gates
+
+**Files:**
+- Read: `frontend/package.json`
+- Read: smoke screenshots in `/tmp`
+
+- [x] **Step 1: Run unit/component tests**
+
+ ```bash
+ pnpm --dir frontend test
+ ```
+
+ Expected: all tests pass.
+
+- [x] **Step 2: Run typecheck**
+
+ ```bash
+ pnpm --dir frontend typecheck
+ ```
+
+ Expected: `tsc --noEmit` exits 0.
+
+- [x] **Step 3: Run production build**
+
+ ```bash
+ pnpm --dir frontend build
+ ```
+
+ Expected: optimized Next build succeeds.
+
+- [x] **Step 4: Run pilot browser smoke**
+
+ ```bash
+ pnpm --dir frontend pilot:smoke
+ ```
+
+ Expected: mail/search smoke passes, no console errors or warnings, screenshots are written under `/tmp`.
+
+- [x] **Step 5: Run diff and event scans**
+
+ ```bash
+ git diff --check
+ rg -n "context_synthesis_viewed|source_chip_opened|action_item_created|calendar_reflected|draft_reply_generated|draft_reply_inserted|draft_reply_sent|context_search_submitted|context_search_result_opened|context_search_result_action_created|latency_guardrail_recorded|model_quality_guardrail_recorded|trust_safety_guardrail_triggered" frontend/src docs/superpowers design-qa.md
+ rg -n "TBD|TODO|FIXME|contract only|proposed only|not instrumented|public launch ready|live KPI" docs/superpowers design-qa.md frontend/src || true
+ ```
+
+ Expected: diff check passes, required events are present, and placeholder scan has no unqualified launch claims.
+
+### Task 5: Completion Audit And Handoff
+
+**Files:**
+- Read: `git status --short --branch`
+- Read: test command outputs
+- Read: `/tmp/naruon-pilot-mail.png`
+- Read: `/tmp/naruon-pilot-search.png`
+
+- [x] **Step 1: Verify branch and preserved user changes**
+
+ Confirm work is on `sellable-pilot-hardening-2026-07-02` and `.Jules/palette.md`, `.Jules/sentinel.md` remain preserved.
+
+- [x] **Step 2: Summarize pilot-ready versus public-launch caveats**
+
+ Final response must not claim live KPI values or public launch readiness.
+
+- [x] **Step 3: Present finishing options**
+
+ Present PR/merge/keep/discard options after all gates pass.
+
+ Autonomous default chosen for this run: push branch and create PR
+ https://github.com/ContextualWisdomLab/naruon/pull/893. `.Jules/palette.md`
+ and `.Jules/sentinel.md` remained unstaged/uncommitted user changes.
diff --git a/docs/superpowers/plans/2026-07-02-naruon-design-to-code-and-telemetry.md b/docs/superpowers/plans/2026-07-02-naruon-design-to-code-and-telemetry.md
new file mode 100644
index 000000000..3ed888ec8
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-02-naruon-design-to-code-and-telemetry.md
@@ -0,0 +1,403 @@
+# Naruon Design-To-Code And Telemetry Follow-Up Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to execute this plan task-by-task. Keep this file current by changing each checkbox from `[ ]` to `[x]` only after the step is verified.
+
+**Goal:** Extend the completed Figma/Product Design/Data Analytics package into implementation-ready product code without using Figma Code Connect, without adding a new frontend library, without sending live analytics, and without claiming live KPI values.
+
+**Architecture:** Treat the existing Next frontend as the implementation anchor. `EmailDetail.tsx`, `SearchLayout.tsx`, and the new `SourceDrawer.tsx` cover the first vertical slice. `frontend/src/lib/product-events.ts` provides a privacy-safe local dispatcher that validates the event contract, records events in memory, dispatches a browser-local `naruon:product-event` custom event, and does not send data to a network destination.
+
+**Tech Stack:** Next 16, React 19, TypeScript, Vitest, Markdown, Figma MCP (`use_figma`, `get_metadata`, `get_screenshot`, `search_design_system` with `disableCodeConnect=true`).
+
+## Global Constraints
+
+- Do not use Figma Code Connect.
+- Do not call Code Connect suggestion or mapping tools.
+- Do not introduce a new analytics SDK or external event destination.
+- Do not send raw email body, raw draft body, or raw search query text in product events.
+- Preserve existing `.Jules/palette.md` and `.Jules/sentinel.md` modifications.
+- Keep changes scoped to `docs/superpowers/`, `design-qa.md`, and the frontend event/source-drawer implementation.
+- State clearly that live KPI values are unavailable.
+
+## Task 1: Confirm Current Implementation Anchor
+
+**Files:**
+- Read: `frontend/src/components/EmailDetail.tsx`
+- Read: `frontend/src/components/DecisionPointCard.tsx`
+- Read: `frontend/src/lib/api-client.ts`
+- Read: `frontend/package.json`
+
+**Interfaces:**
+- Consumes: existing mail-detail UI, action handlers, confidence utility, API client.
+- Produces: implementation-scope decision for the follow-up package.
+
+- [x] **Step 1: Verify branch and dirty state**
+
+ Evidence:
+
+ - Current worktree: `/Users/seonghobae/Documents/Codex/2026-07-02/https-github-com-contextualwisdomlab-naruon-figma/naruon`
+ - Branch baseline: `develop`
+ - Existing unrelated modifications: `.Jules/palette.md`, `.Jules/sentinel.md`
+ - New package artifacts already under `docs/superpowers/` and `design-qa.md`
+
+- [x] **Step 2: Verify CodeGraph availability**
+
+ Evidence:
+
+ - Deferred CodeGraph tools are not exposed in this session.
+ - `.codegraph/` is not present in the worktree.
+ - Because the user asked for autonomous execution, proceed with native repo reads and do not initialize CodeGraph without explicit confirmation.
+
+- [x] **Step 3: Confirm first-slice UI coverage**
+
+ Evidence from `EmailDetail.tsx` and `DecisionPointCard.tsx`:
+
+ - `맥락 종합` card exists.
+ - `실행 항목` card exists.
+ - `답장 초안` flow exists.
+ - `일정 반영` flow exists.
+ - Confidence badge exists through `toConfidencePercent`.
+ - Provenance/source chip pattern exists through `provenance` and `근거 원본 보기`.
+ - Loading, empty, and error states exist.
+
+- [x] **Step 4: Choose implementation slice**
+
+ Decision:
+
+ - Do not rebuild the UI.
+ - Add a typed event contract and local dispatcher so the existing UI can be instrumented safely now.
+ - Wire the first vertical slice in `EmailDetail.tsx` and `SearchLayout.tsx` without external analytics transport.
+ - Use Figma for interaction-state frames and Product Design notes, not Code Connect.
+
+## Task 2: Add Typed Product Event Contract
+
+**Files:**
+- Create: `frontend/src/lib/product-events.ts`
+- Create: `frontend/src/lib/product-events.test.ts`
+
+**Interfaces:**
+- Consumes: event names from KPI validation and first vertical slice.
+- Produces: strongly typed event names, contract metadata, payload fields, denominator grain, timezone, owner, privacy class, and quality caveat.
+
+- [x] **Step 1: Define required event names**
+
+ Required product events:
+
+ - `context_synthesis_viewed`
+ - `decision_point_viewed`
+ - `source_chip_opened`
+ - `action_item_created`
+ - `calendar_reflected`
+ - `draft_reply_generated`
+ - `draft_reply_inserted`
+ - `draft_reply_sent`
+ - `context_search_submitted`
+ - `context_search_result_opened`
+ - `context_search_result_action_created`
+ - `latency_guardrail_recorded`
+ - `model_quality_guardrail_recorded`
+ - `trust_safety_guardrail_triggered`
+
+- [x] **Step 2: Define shared payload baseline**
+
+ Every event contract must include:
+
+ - `event_id`
+ - `occurred_at`
+ - `workspace_id`
+ - `actor_user_id`
+ - `surface`
+
+- [x] **Step 3: Define per-event payload fields**
+
+ Each event must define:
+
+ - owner
+ - trigger
+ - denominator grain
+ - timezone
+ - privacy class
+ - entity IDs
+ - required/optional payload fields
+ - quality caveat
+
+- [x] **Step 4: Add test coverage**
+
+ Tests must verify:
+
+ - event names are exact and unique
+ - timezone/owner/denominator/payload/caveat exists for every event
+ - raw body and raw query fields are not allowed
+ - `recordProductEvent` validates required fields before recording
+ - local browser dispatch uses `CustomEvent("naruon:product-event")`
+ - bucket helpers avoid raw body, draft, and query text
+
+## Task 2A: Wire Product Events Into Current UI
+
+**Files:**
+- Modify: `frontend/src/components/EmailDetail.tsx`
+- Modify: `frontend/src/components/SearchLayout.tsx`
+- Modify: `frontend/src/components/EmailDetail.test.tsx`
+- Create: `frontend/src/components/SearchLayout.test.tsx`
+
+**Interfaces:**
+- Consumes: product-event contract and current mail/search actions.
+- Produces: privacy-safe local event records tied to real user actions.
+
+- [x] **Step 1: Wire mail-detail events**
+
+ Implemented in `EmailDetail.tsx`:
+
+ - `context_synthesis_viewed`
+ - `source_chip_opened`
+ - `action_item_created`
+ - `calendar_reflected`
+ - `draft_reply_generated`
+ - `draft_reply_inserted`
+ - `draft_reply_sent`
+ - `latency_guardrail_recorded`
+ - `model_quality_guardrail_recorded`
+
+- [x] **Step 2: Wire context-search events**
+
+ Implemented in `SearchLayout.tsx` because current results expose stable IDs through `result.id` and source-message IDs:
+
+ - `context_search_submitted`
+ - `context_search_result_opened`
+ - `context_search_result_action_created`
+ - `latency_guardrail_recorded`
+
+- [x] **Step 3: Preserve privacy constraints**
+
+ Implemented by contract validation and tests:
+
+ - raw email body keys are rejected
+ - raw draft body keys are rejected
+ - raw search query keys are rejected
+ - query and draft contents are represented by buckets and IDs only
+
+## Task 2B: Implement Source Drawer
+
+**Files:**
+- Create: `frontend/src/components/SourceDrawer.tsx`
+- Modify: `frontend/src/components/EmailDetail.tsx`
+- Modify: `frontend/src/components/EmailDetail.test.tsx`
+
+**Interfaces:**
+- Consumes: Figma interaction state `Desktop / Interaction / Source Drawer Open` (`14:3`) and current source-chip affordance.
+- Produces: accessible source evidence drawer.
+
+- [x] **Step 1: Replace anchor-only source opening**
+
+ `근거 원본 보기` now opens `SourceDrawer` instead of only navigating to a message anchor.
+
+- [x] **Step 2: Add accessible drawer behavior**
+
+ Implemented:
+
+ - `role="dialog"`
+ - `aria-modal="true"`
+ - `aria-labelledby`
+ - `aria-describedby`
+ - close button focus on open
+ - Escape close
+ - mouse close
+ - focus restore
+ - body scroll lock
+
+- [x] **Step 3: Add interaction tests**
+
+ `EmailDetail.test.tsx` verifies:
+
+ - source button click opens the drawer
+ - dialog aria attributes are present
+ - initial focus lands on the close control
+ - close button closes the drawer
+ - Escape closes the drawer
+ - `source_chip_opened` is recorded without raw email body
+
+## Task 3: Produce Design-To-Code Backlog
+
+**Files:**
+- Create: `docs/superpowers/reports/2026-07-02-naruon-design-to-code-backlog.md`
+
+**Interfaces:**
+- Consumes: Figma file, existing frontend components, first-slice source mockups.
+- Produces: product-design-to-frontend backlog with exact component anchors.
+
+- [x] **Step 1: Map Figma components to current code**
+
+ Include mappings for:
+
+ - navigation shell
+ - evidence/action panel
+ - confidence badge
+ - source chip
+ - table row
+ - source drawer
+ - reply draft controls
+ - action item controls
+ - calendar controls
+
+- [x] **Step 2: Mark implementation status**
+
+ Use statuses:
+
+ - implemented in existing UI
+ - contract added
+ - backlog only
+ - blocked by analytics destination
+
+- [x] **Step 3: Define next PR sequence**
+
+ Sequence:
+
+ 1. Wire product-event dispatcher behind the contract.
+ 2. Attach events to `EmailDetail` handlers.
+ 3. Add source drawer as an accessible component.
+ 4. Extend context-search result actions.
+ 5. Add dashboard only after telemetry destination is confirmed.
+
+## Task 4: Produce Event Dictionary Report
+
+**Files:**
+- Create: `docs/superpowers/reports/2026-07-02-naruon-event-dictionary.md`
+
+**Interfaces:**
+- Consumes: `frontend/src/lib/product-events.ts`.
+- Produces: stakeholder-readable Data Analytics handoff.
+
+- [x] **Step 1: State caveats**
+
+ Required caveats:
+
+ - No live event warehouse was available.
+ - No KPI values are measured product performance.
+ - Event owner and destination must be confirmed before dashboards.
+ - Timezone is fixed to `Asia/Seoul` for the initial contract.
+
+- [x] **Step 2: Document every event**
+
+ For each event, include:
+
+ - trigger
+ - denominator grain
+ - entity IDs
+ - required payload
+ - optional payload
+ - owner
+ - quality caveat
+
+- [x] **Step 3: Document guardrails**
+
+ Include:
+
+ - latency guardrail
+ - model-quality guardrail
+ - trust/safety guardrail
+
+## Task 5: Add Figma Interaction States
+
+**Figma File:**
+- URL: https://www.figma.com/design/68b5XB58w8nwT2LYOOnikK
+- File key: `68b5XB58w8nwT2LYOOnikK`
+
+**Allowed tools:**
+- `search_design_system` with `disableCodeConnect=true`
+- `get_metadata`
+- `use_figma`
+- `get_screenshot`
+
+**Forbidden tools:**
+- Code Connect suggestion tools
+- Code Connect mapping tools
+
+- [x] **Step 1: Search design system without Code Connect**
+
+ Search for source chip, drawer, button, badge, and card patterns with `disableCodeConnect=true`.
+
+- [x] **Step 2: Add visible state frames**
+
+ Add these state frames near the existing desktop slice:
+
+ - `Desktop / Interaction / Source Drawer Open`
+ - `Desktop / Interaction / Draft Reply Review`
+ - `Desktop / Interaction / Schedule Confirmation`
+
+ Created Figma nodes:
+
+ - `Naruon Interaction States / 2026-07-02` (`14:2`)
+ - `Desktop / Interaction / Source Drawer Open` (`14:3`)
+ - `Desktop / Interaction / Draft Reply Review` (`14:41`)
+ - `Desktop / Interaction / Schedule Confirmation` (`14:78`)
+
+- [x] **Step 3: Add prototype notes**
+
+ Add a compact notes frame that describes:
+
+ - source-chip opening
+ - evidence drawer
+ - draft reply review
+ - schedule confirmation
+ - event names tied to each transition
+
+ Created node:
+
+ - `Desktop / Interaction / Prototype Notes` (`14:118`)
+
+- [x] **Step 4: Screenshot verify**
+
+ Capture screenshot evidence for the new interaction cluster and save it under:
+
+ - `docs/superpowers/artifacts/naruon-figma-package/qa/figma-interaction-states.png`
+
+ Verified:
+
+ - PNG image data, 2400 x 1252, RGBA.
+
+## Task 6: Verify
+
+**Files and commands:**
+- `git status --short`
+- `test -f frontend/src/lib/product-events.ts`
+- `test -f frontend/src/lib/product-events.test.ts`
+- `test -f docs/superpowers/reports/2026-07-02-naruon-design-to-code-backlog.md`
+- `test -f docs/superpowers/reports/2026-07-02-naruon-event-dictionary.md`
+- `rg -n "context_synthesis_viewed|source_chip_opened|calendar_reflected|trust_safety_guardrail_triggered" frontend/src/lib docs/superpowers`
+- `pnpm --dir frontend test src/lib/product-events.test.ts`
+
+- [x] **Step 1: Verify required artifacts**
+
+ Confirm all new files exist.
+
+- [x] **Step 2: Verify event references**
+
+ Confirm all required event names appear in code and docs.
+
+- [x] **Step 3: Run frontend test if dependencies exist**
+
+ If `frontend/node_modules` is absent, document that the test was not run because dependencies are not installed in this worktree.
+
+ Actual result:
+
+ - `pnpm --dir frontend test src/components/EmailDetail.test.tsx src/components/SearchLayout.test.tsx src/lib/product-events.test.ts` passed: 3 files, 33 tests.
+ - `pnpm --dir frontend test` passed: 43 files, 319 tests.
+ - `pnpm --dir frontend typecheck` passed.
+ - Browser QA with local mocked APIs passed for `/mail` source drawer interaction and `/search` submit-result-action flow.
+ - Browser QA screenshots: `/tmp/naruon-mail-source-drawer.png`, `/tmp/naruon-search-event-flow.png`.
+ - `pnpm` installed ignored `frontend/node_modules/` because dependencies were absent at the start of the run.
+
+- [x] **Step 4: Verify Figma**
+
+ Confirm new Figma frame IDs and screenshot path.
+
+## Completion Criteria
+
+- New Goal-specific plan exists and is updated.
+- Product-event contract and privacy-safe local dispatcher exist in frontend code.
+- Mail-detail and context-search call sites are wired.
+- Source drawer exists as working UI with accessibility tests.
+- Event dictionary report exists with payload, denominator grain, timezone, owner, and caveat.
+- Design-to-code backlog exists with exact current-code anchors.
+- Figma file has visible interaction state frames and screenshot evidence.
+- Verification output is recorded in the final response.
+- External analytics destination remains explicitly blocked until owner, retention, consent, and warehouse schema are confirmed.
diff --git a/docs/superpowers/plans/2026-07-02-naruon-figma-product-design-analytics.md b/docs/superpowers/plans/2026-07-02-naruon-figma-product-design-analytics.md
new file mode 100644
index 000000000..fb23fddc1
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-02-naruon-figma-product-design-analytics.md
@@ -0,0 +1,475 @@
+# Naruon Figma Product Design Analytics Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a Figma-free-or-Figma-ready product design and analytics handoff for Naruon using Figma, Product Design, Superpowers, Ponytail, and Data Analytics, with Figma Code Connect excluded.
+
+**Architecture:** Treat `docs/ui-ux` on `develop` as the source of truth, produce a local design spec plus implementation plan, then create or update Figma only when a target fileKey or Figma planKey is available. The first visual slice is Mail detail to evidence-backed execution, and all broader IA work hangs off that slice.
+
+**Tech Stack:** GitHub repo sources, Markdown docs, Figma MCP tools (`create_new_file`, `use_figma`, `get_libraries`, `search_design_system`, `get_metadata`, `get_screenshot`, optional `generate_figma_design`), Product Design workflow, Data Analytics KPI design, Ponytail scope control.
+
+## Global Constraints
+
+- Do not use Figma Code Connect.
+- Do not create Code Connect mappings or files.
+- Do not claim live measured analytics without a real data source.
+- Preserve canonical source paths under `docs/ui-ux`.
+- Use `develop` as the branch baseline.
+- Prioritize the first vertical slice before expanding every screen.
+- Reuse repo mockups and manifests before generating new visual material.
+
+---
+
+## File Structure
+
+- Created: `docs/superpowers/specs/2026-07-02-naruon-figma-product-design-analytics-design.md`
+ - Product definition, source evidence, IA, Figma deliverable, user stories, KPI framework, QA criteria.
+- Created: `docs/superpowers/plans/2026-07-02-naruon-figma-product-design-analytics.md`
+ - Task-by-task implementation and verification plan.
+- Created Figma file:
+ - URL: https://www.figma.com/design/68b5XB58w8nwT2LYOOnikK
+ - File key: `68b5XB58w8nwT2LYOOnikK`
+ - `Source Map`
+ - `Foundations`
+ - `Components`
+ - `Desktop Screens`
+ - `Mobile Screens`
+ - `QA Notes`
+
+## Task 1: Verify Source Baseline
+
+**Files:**
+- Read: `docs/ui-ux/README.md`
+- Read: `docs/ui-ux/naruon-ui-ux-mapping.md`
+- Read: `docs/ui-ux/asset-overviews-2026-06-21/manifest.tsv`
+- Read: `docs/ui-ux/individual-assets-2026-06-22/manifest.tsv`
+- Read: `docs/ui-ux/reference-set-2026-06-18/sources.tsv`
+
+**Interfaces:**
+- Consumes: live repo state on `develop`
+- Produces: evidence baseline for all later design and analytics work
+
+- [x] **Step 1: Confirm repo and branch**
+
+Run:
+
+```bash
+gh repo view ContextualWisdomLab/naruon --json nameWithOwner,description,defaultBranchRef,isPrivate,url,pushedAt,primaryLanguage
+git rev-parse HEAD
+git branch --show-current
+```
+
+Expected:
+
+- `defaultBranchRef.name` is `develop`
+- local branch is `develop`
+- checked commit is recorded in the spec
+
+- [x] **Step 2: Count canonical assets**
+
+Run:
+
+```bash
+find docs/ui-ux/mockups -maxdepth 1 -type f -name 'mockup_*.png' | wc -l
+find docs/ui-ux/reference-set-2026-06-18/images -maxdepth 1 -type f -name '*.png' | wc -l
+wc -l docs/ui-ux/asset-overviews-2026-06-21/manifest.tsv docs/ui-ux/individual-assets-2026-06-22/manifest.tsv docs/ui-ux/reference-set-2026-06-18/sources.tsv
+```
+
+Expected:
+
+- 41 mockup PNG files
+- 45 reference PNG files
+- manifest row counts recorded for traceability
+
+- [x] **Step 3: Confirm visual source instructions**
+
+Read `docs/ui-ux/README.md` and verify it says to start with `naruon-ui-ux-mapping.md`, then open matching original images directly.
+
+## Task 2: Produce Product Design Brief
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-07-02-naruon-figma-product-design-analytics-design.md`
+
+**Interfaces:**
+- Consumes: Task 1 source baseline
+- Produces: product definition, IA, terminology, and user stories
+
+- [x] **Step 1: Define product promise**
+
+Write the product definition:
+
+```text
+Naruon is an evidence-based AI email workspace. It connects email, attachments, images, calendars, relationships, tasks, projects, and source evidence so a user can move from fragmented information to judgment and execution.
+```
+
+- [x] **Step 2: Lock mandatory terminology**
+
+Include the term mapping:
+
+- `AI Summary` -> `맥락 종합`
+- `Insight` -> `판단 포인트`
+- `Todo` -> `실행 항목`
+- `Smart Reply` -> `답장 초안`
+- `Search` -> `맥락 검색`
+- `Network Graph` -> `관계 맥락`
+- `Calendar Sync` -> `일정 반영`
+- `AI Assistant` -> `판단 보조`
+
+- [x] **Step 3: Define IA**
+
+Document the 10 GNB areas: 홈, 메일, 일정, 작업, 프로젝트, 맥락 검색, 데이터, AI 허브, 보안, 설정.
+
+- [x] **Step 4: Define user stories**
+
+Write acceptance criteria for:
+
+- Evidence-backed thread synthesis
+- Decision point extraction
+- Action item creation
+- Calendar reflection
+- Context search follow-through
+- Mobile context synthesis
+
+## Task 3: Define Figma Deliverable
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-07-02-naruon-figma-product-design-analytics-design.md`
+
+**Interfaces:**
+- Consumes: Product Design brief and repo mockup map
+- Produces: Figma page structure, allowed tools, and first-slice screen plan
+
+- [x] **Step 1: Lock page structure**
+
+Use these exact page names:
+
+- `Source Map`
+- `Foundations`
+- `Components`
+- `Desktop Screens`
+- `Mobile Screens`
+- `QA Notes`
+
+- [x] **Step 2: Define first vertical slice**
+
+Use this flow:
+
+```text
+Mail detail -> 맥락 종합 -> 판단 포인트 -> 실행 항목 / 답장 초안 / 일정 반영 / 관계 맥락
+```
+
+Use these source images:
+
+- `docs/ui-ux/mockups/mockup_19.png`
+- `docs/ui-ux/mockups/mockup_30.png`
+- `docs/ui-ux/mockups/mockup_31.png`
+- `docs/ui-ux/mockups/mockup_36.png`
+- `docs/ui-ux/mockups/mockup_37.png`
+- `docs/ui-ux/mockups/mockup_40.png`
+- `docs/ui-ux/mockups/mockup_41.png`
+
+- [x] **Step 3: Define Figma tool policy**
+
+Allowed:
+
+- `create_new_file`
+- `use_figma`
+- `get_libraries`
+- `search_design_system`
+- `get_metadata`
+- `get_screenshot`
+- `generate_figma_design` only when a rendered web source and target fileKey exist
+
+Forbidden:
+
+- Figma Code Connect generation
+- Code Connect dependency
+- Unsourced replacement visuals
+
+## Task 4: Define Analytics Measurement Framework
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-07-02-naruon-figma-product-design-analytics-design.md`
+
+**Interfaces:**
+- Consumes: product promise and first-slice flow
+- Produces: metric definitions and instrumentation assumptions
+
+- [x] **Step 1: Define primary metrics**
+
+Include:
+
+- Context synthesis usage
+- Decision-to-action conversion
+
+- [x] **Step 2: Define driver metrics**
+
+Include:
+
+- Evidence interaction
+- Context search success
+- Draft reply acceptance
+- Calendar/task conversion
+
+- [x] **Step 3: Define guardrails**
+
+Include:
+
+- Model quality
+- Latency
+- Trust/safety
+
+- [x] **Step 4: Label source gaps**
+
+State that no live analytics source is available in this run and all targets remain provisional until baseline telemetry exists.
+
+## Task 5: Figma File Creation Or Handoff
+
+**Files:**
+- Update when available: Figma design file
+- Record state in: `docs/superpowers/specs/2026-07-02-naruon-figma-product-design-analytics-design.md`
+
+**Interfaces:**
+- Consumes: Figma fileKey or Figma planKey
+- Produces: editable Figma file or explicit blocked state
+
+- [x] **Step 1: If the user provides a Figma URL**
+
+Extract `fileKey` from a `/design/` URL and run:
+
+```text
+get_metadata(fileKey)
+get_libraries(fileKey)
+search_design_system(fileKey, query="button", disableCodeConnect=true)
+```
+
+Expected:
+
+- Existing pages and available libraries are known when a URL is provided.
+- Searches disable Code Connect.
+
+Current result:
+
+- No pre-existing user Figma URL was provided.
+- New file URL is now available after authenticated plan discovery: https://www.figma.com/design/68b5XB58w8nwT2LYOOnikK
+
+- [x] **Step 2: If the user provides a Figma planKey**
+
+Create the file:
+
+```text
+create_new_file(editorType="design", fileName="Naruon Product Design System - 2026-07-02", planKey="")
+```
+
+Expected:
+
+- A new Figma design URL and fileKey are returned.
+
+Current result:
+
+- `whoami` exposed one authenticated plan: `Seongho Bae's team` (`team::1408252278989737675`).
+- `create_new_file(editorType="design", fileName="Naruon Product Design System - 2026-07-02", planKey="team::1408252278989737675")` returned file key `68b5XB58w8nwT2LYOOnikK`.
+
+- [x] **Step 3: If no Figma target is available**
+
+Historical fallback, no longer current:
+
+```text
+If no Figma URL, planKey, or plan-discovery tool is available, record the missing input and continue with the local package.
+```
+
+Current state:
+
+- This earlier blocker was resolved after deferred Figma tools exposed `whoami`.
+- The blocker state is retained here as historical context only; it is no longer current.
+
+## Task 6: Figma Build Steps Once FileKey Exists
+
+**Files:**
+- Figma design file
+
+**Interfaces:**
+- Consumes: fileKey and source images
+- Produces: Figma source map, foundations, components, screens, QA notes
+
+- [x] **Step 1: Inspect target file**
+
+Run:
+
+```text
+get_metadata(fileKey)
+get_libraries(fileKey)
+```
+
+Expected:
+
+- Page list and available libraries are known.
+
+Current result:
+
+- Initial metadata showed a blank file.
+- `get_libraries` returned Material 3 Design Kit and Simple Design System as added libraries.
+- `search_design_system(fileKey, query="button", disableCodeConnect=true)` returned no directly usable assets, so local minimal components were created.
+
+- [x] **Step 2: Create page skeleton**
+
+Use `use_figma` with `skillNames="figma-use"` to create pages named:
+
+- `Source Map`
+- `Foundations`
+- `Components`
+- `Desktop Screens`
+- `Mobile Screens`
+- `QA Notes`
+
+Return all created page IDs.
+
+Current page IDs:
+
+- `Source Map`: `0:1`
+- `Foundations`: `3:2`
+- `Components`: `3:3`
+- `Desktop Screens`: `3:4`
+- `Mobile Screens`: `3:5`
+- `QA Notes`: `3:6`
+
+- [x] **Step 3: Search reusable design system assets**
+
+Run searches with `disableCodeConnect=true`:
+
+```text
+button, input, card, table, chip, badge, navigation, sidebar, drawer, modal, calendar, source, confidence
+```
+
+Expected:
+
+- Available components, variables, and styles are known before any manual drawing.
+
+Current result:
+
+- `get_libraries` checked available libraries.
+- `search_design_system` was called with `disableCodeConnect=true`.
+- No matching component/style/variable results were returned for the checked query, so local minimal foundations/components were created.
+
+- [x] **Step 4: Build foundations**
+
+Create or import reusable tokens for:
+
+- Background, surface, text, border, accent, semantic status colors
+- Spacing scale
+- Radius scale
+- Text styles
+- Effect styles
+
+- [x] **Step 5: Build first vertical slice**
+
+Build these frames:
+
+- `Desktop / Mail Detail / Evidence Review`
+- `Desktop / Mail Detail / Decision Actions`
+- `Desktop / Context Search / Related Evidence`
+- `Mobile / Context Synthesis Bottom Sheet`
+
+Expected:
+
+- The slice visibly supports the flow from mail to judgment to action.
+
+Current result:
+
+- `Desktop / Mail Detail / Evidence Review`: `3:157`
+- `Desktop / Context Search / Related Evidence`: `3:246`
+- `Mobile / Context Synthesis Bottom Sheet`: `3:273`
+- `Desktop / Expansion Roadmap`: `11:17`
+- `Component / Table Row` and `Component / Source Drawer` added to the Components page.
+- Source mockups uploaded to Source Map: `3:315` through `3:321`.
+
+- [x] **Step 6: Validate screenshots**
+
+Use `get_screenshot` for every completed major frame.
+
+Reject and fix frames with:
+
+- clipped text
+- overlapping UI
+- blank image placeholders
+- wrong font family
+- unbound or inconsistent component instances
+- leftover placeholder shimmer or placeholder labels
+
+Current screenshot evidence:
+
+- `docs/superpowers/artifacts/naruon-figma-package/qa/figma-desktop-mail-detail.png`
+- `docs/superpowers/artifacts/naruon-figma-package/qa/figma-mobile-context-sheet.png`
+- `docs/superpowers/artifacts/naruon-figma-package/qa/compare-desktop-mockup36-vs-figma.png`
+- `docs/superpowers/artifacts/naruon-figma-package/qa/compare-mobile-mockup40-vs-figma.png`
+
+## Task 7: QA Completion Audit
+
+**Files:**
+- Read: `docs/superpowers/specs/2026-07-02-naruon-figma-product-design-analytics-design.md`
+- Read: `docs/superpowers/plans/2026-07-02-naruon-figma-product-design-analytics.md`
+- Optional: Figma file metadata and screenshots
+
+**Interfaces:**
+- Consumes: all produced artifacts
+- Produces: completion status and missing-gaps list
+
+- [x] **Step 1: Verify local package artifacts**
+
+Run:
+
+```bash
+test -f docs/superpowers/specs/2026-07-02-naruon-figma-product-design-analytics-design.md
+test -f docs/superpowers/plans/2026-07-02-naruon-figma-product-design-analytics.md
+rg -n "Figma Code Connect|맥락 종합|판단 포인트|실행 항목|Context synthesis usage|Decision-to-action conversion" docs/superpowers
+```
+
+Expected:
+
+- Both files exist.
+- Required terminology, Code Connect exclusion, and KPI terms are present.
+
+- [x] **Step 2: Verify Figma artifact when fileKey exists**
+
+Run:
+
+```text
+get_metadata(fileKey)
+get_screenshot(fileKey, nodeId="")
+```
+
+Expected:
+
+- Required pages exist.
+- First vertical slice is visible.
+- Screenshot QA passes.
+
+- [x] **Step 3: Record incomplete external dependency**
+
+Current external gap:
+
+- No current Figma creation gap remains.
+- Live analytics data remains unavailable; KPI outputs are definitions and validation caveats, not measured product performance.
+
+## Current Status
+
+Completed in this run:
+
+- Live repo and `develop` baseline checked.
+- Canonical UI/UX source assets counted and inspected.
+- Product/design/KPI design spec created.
+- Superpowers-style implementation plan created.
+- Figma Code Connect exclusion recorded.
+- Figma file created: https://www.figma.com/design/68b5XB58w8nwT2LYOOnikK
+- Required Figma pages created.
+- Source Map populated with 7 uploaded source mockup frames.
+- Local minimal foundations, components, desktop vertical slice, mobile context synthesis, and QA notes created.
+- Component coverage includes source chip, confidence badge, decision card, action item card, reply draft card, evidence panel, nav item, primary button, table row, and source drawer.
+- Expansion roadmap added for Home, Calendar, Data, AI Hub, Security, and Settings.
+- Figma screenshots captured and compared against source mockups.
+- Product Design QA and Data Analytics validation reports added.
+
+Still required to fully complete the active goal:
+
+- Wire live telemetry before claiming measured KPI performance.
+- Continue future design-to-code work from the Figma file and this package.
diff --git a/docs/superpowers/reports/2026-07-02-naruon-20b-buyer-package.md b/docs/superpowers/reports/2026-07-02-naruon-20b-buyer-package.md
new file mode 100644
index 000000000..c1fed8850
--- /dev/null
+++ b/docs/superpowers/reports/2026-07-02-naruon-20b-buyer-package.md
@@ -0,0 +1,150 @@
+# Naruon 20B Buyer Package Index
+
+Date: 2026-07-02 KST
+
+## Positioning
+
+Naruon is a customer-owned AI workspace for mail, calendar, files, task follow-up, search, and governance surfaces. This package supports a controlled enterprise buyer technical review. It is not a final public-launch or contract-close claim.
+
+Current package status:
+
+- Buyer-reviewable frontend pilot evidence exists for `/mail` and `/search`.
+- A full-product localhost route smoke now covers `/`, `/mail`, `/search`, `/calendar`, `/tasks`, `/projects`, `/data`, `/ai-hub`, `/security`, and `/settings`.
+- Figma Code Connect is not used.
+- The Figma `Sales Demo / 20B Enterprise Review Flow` frame exists in file `68b5XB58w8nwT2LYOOnikK`, page `15:3`, frame `16:2`.
+- PR #893 is the current implementation vehicle: `https://github.com/ContextualWisdomLab/naruon/pull/893`.
+
+## Evidence Map
+
+| Area | Current evidence | Status |
+| --- | --- | --- |
+| Product scope | `docs/superpowers/specs/2026-07-02-naruon-20b-full-commercial-readiness-design.md` | Defined, not complete |
+| Current audit | `docs/superpowers/reports/2026-07-02-naruon-20b-current-state-audit.md` | Current caveats listed |
+| Full route smoke | `frontend/scripts/full-product-ui-smoke.mjs` and `frontend/scripts/full-product-ui-smoke.test.mjs` | Localhost pass |
+| Pilot smoke | `frontend/scripts/pilot-ui-smoke.mjs` and `frontend/scripts/pilot-ui-smoke.test.mjs` | Localhost pilot flow pass |
+| Product events | `frontend/src/lib/product-events.ts` and `docs/superpowers/reports/2026-07-02-naruon-event-dictionary.md` | Mail/search covered |
+| Design evidence | Figma file `68b5XB58w8nwT2LYOOnikK`, frame `16:2`; `docs/ui-ux/naruon-ui-ux-mapping.md` | First sales frame ready |
+| Analytics | `docs/superpowers/reports/2026-07-02-naruon-kpi-validation.md` | Framework only, no live KPI claim |
+| Security governance | `docs/superpowers/reports/2026-07-02-naruon-security-governance-followup.md` | Branch fix, merge evidence pending |
+| Commercial caveats | `docs/superpowers/specs/2026-07-02-naruon-commercial-pilot-readiness-design.md` | Explicit |
+
+## Buyer Demo Flow
+
+Use `docs/superpowers/reports/2026-07-02-naruon-20b-demo-script.md`.
+
+Primary story:
+
+1. Open Naruon home and show context synthesis.
+2. Review mail evidence and source drawer behavior.
+3. Generate a reply draft without leaking private text to analytics payloads.
+4. Convert source mail into an action item.
+5. Search a buyer context and capture sender relationship evidence.
+6. Show tasks, data, AI Hub, security, and settings as full-product surfaces.
+7. Close with governance caveats and the acceptance plan.
+
+## Architecture Summary
+
+Evidence anchors:
+
+- Frontend: `frontend/src/components/*Layout.tsx`
+- API proxy: `frontend/src/app/api/[...path]/route.ts`
+- Backend API: `backend/api/`
+- Security and RBAC: `backend/core/rbac.py`, `backend/api/auth.py`
+- Provider and writeback boundaries: `backend/services/`, `backend/api/calendar.py`, `backend/api/webdav.py`
+- Operational docs: `docs/operations/`
+
+Architecture posture:
+
+- Customer-owned source systems remain the system of record.
+- Naruon acts as a control plane and workflow surface.
+- Provider-write actions must remain explicit and audited.
+- Private text must not be copied into product analytics payloads.
+
+## Deployment Status
+
+Current evidence:
+
+- CI workflows exist under `.github/workflows/`.
+- Docker image validation jobs exist in `.github/workflows/docker-publish.yml`.
+- Render and operations notes exist under `docs/operations/`.
+
+Not yet complete:
+
+- Production deployment proof is not packaged.
+- Rollback evidence is not packaged.
+- Live tenant environment acceptance evidence is not packaged.
+
+## Security And Privacy Status
+
+Use `docs/superpowers/reports/2026-07-02-naruon-20b-security-questionnaire.md`.
+
+Current strengths:
+
+- Signed session boundary exists in backend auth.
+- RBAC/ABAC test coverage exists.
+- Security dashboard surface exists.
+- PR governance fail-closed patch exists in this branch.
+- Product event contract blocks sensitive raw text fields.
+
+Open caveats:
+
+- Issue #634 remains open until trusted-base remote evidence proves the governance patch.
+- Live provider-send and provider-write evidence is incomplete.
+- Formal DPA, incident response, and support terms are drafts, not signed artifacts.
+
+## Analytics And ROI Status
+
+Use `docs/superpowers/reports/2026-07-02-naruon-kpi-validation.md`.
+
+Current status:
+
+- KPI definitions exist.
+- Event dictionary exists.
+- Local product events exist for mail/search.
+- Full-product event coverage is not complete.
+- No live ROI number should be claimed.
+
+Accepted buyer-review language:
+
+```text
+Naruon has a measurement framework and local privacy-safe event contract for the pilot surfaces. Live KPI and ROI evidence require a measured pilot.
+```
+
+Rejected language:
+
+```text
+Naruon has proven a 20B KRW ROI.
+Naruon is public-launch ready.
+All provider integrations are production-proven.
+```
+
+## SLA And Support
+
+Use `docs/superpowers/reports/2026-07-02-naruon-20b-sla-support-draft.md`.
+
+Current status:
+
+- Support/SLA package is a draft.
+- Incident response and escalation policy still need owner approval.
+- Buyer-specific uptime, RTO, RPO, and support hours must be negotiated.
+
+## Pilot Acceptance Criteria
+
+A buyer technical pilot can be accepted only if:
+
+- Local and remote CI gates pass on the current head.
+- Buyer demo script is executed without manual source edits.
+- Browser smoke screenshots are produced for all ten routes.
+- Security caveats are presented before procurement review.
+- Live tenant/provider tests are either completed or explicitly excluded from the pilot scope.
+
+## Known Caveats
+
+This package is not complete for final procurement until:
+
+- Production deployment and rollback evidence is attached.
+- Live provider-send and provider-write paths are proven or contractually excluded.
+- Security questionnaire, DPA, incident runbook, and SLA terms are buyer-approved.
+- Full responsive Product Design QA is complete with no P0/P1/P2 blockers.
+- ROI evidence comes from live measured data rather than assumptions.
+- Issue #634 is closed after trusted-base remote evidence.
diff --git a/docs/superpowers/reports/2026-07-02-naruon-20b-current-state-audit.md b/docs/superpowers/reports/2026-07-02-naruon-20b-current-state-audit.md
new file mode 100644
index 000000000..7edc06635
--- /dev/null
+++ b/docs/superpowers/reports/2026-07-02-naruon-20b-current-state-audit.md
@@ -0,0 +1,257 @@
+# Naruon 20B Current State Audit
+
+Audit date: 2026-07-02 KST
+
+## Decision Being Supported
+
+Question: can the current Naruon repository be treated as a 2,000,000,000 KRW sale-ready program?
+
+Decision: no. The current state supports a controlled buyer technical review for the implemented frontend pilot slice and now has a localhost-only full-product route smoke gate, but it is still not a full enterprise procurement package. The gap is not only code. The missing work includes full interaction coverage, production deployment proof, live provider integration, security/compliance packaging, operations, analytics/ROI evidence, and buyer handoff material.
+
+## Current Goal Registration
+
+The active Goal is to move `ContextualWisdomLab/naruon` toward a 20B KRW enterprise-sale submission package using Figma, Product Design, Superpowers, Ponytail, and Data Analytics, without Figma Code Connect and without waiting for review-process delays.
+
+## Repository State
+
+Current branch:
+
+```text
+sellable-pilot-hardening-2026-07-02
+```
+
+Recent local hardening commits before this report update:
+
+```text
+51a77ea Cover project evidence edit in full smoke
+c45a38a Expand search graph selection evidence
+8d0be95 Cover search graph detail controls
+4224b10 Cover source drawer focus trap in full smoke
+6b454e4 Cover security permission evidence in full smoke
+0373797 Cover search graph evidence in full smoke
+d20e5a6 Prove settings startup persistence in full smoke
+a62995f Cover provider completion states in full smoke
+9b5d87d Expand workflow-state full product smoke
+```
+
+Working tree:
+
+```text
+ M .Jules/palette.md
+ M .Jules/sentinel.md
+```
+
+Interpretation: `.Jules/*` changes are preserved user changes and are not part of this sale-readiness package.
+
+## PR #893 State
+
+PR:
+
+- URL: `https://github.com/ContextualWisdomLab/naruon/pull/893`
+- Title: `Naruon 상용 파일럿 프론트엔드 준비`
+- Base: `develop`
+- Head branch: `sellable-pilot-hardening-2026-07-02`
+- Head SHA: refreshed from live GitHub after each push; do not rely on this static report for the current SHA.
+- Mergeable: `MERGEABLE`
+- Merge state: `BLOCKED`
+- Review decision: `CHANGES_REQUESTED`
+
+Current check interpretation:
+
+- PR #893 live status must be refreshed after every push with `gh pr view`, GraphQL review-thread state, and current check rollup before any merge or completion claim.
+- Product-relevant checks that were observed successful include frontend, backend, Bandit, Trivy, CodeQL, dependency review, image validation, Strix, scorecard, metadata-only governance, queue scan, and coverage evidence.
+- `opencode-review` was observed `IN_PROGRESS`.
+- The stale review decision and review wait are not blockers under the current user instruction.
+- A failing check with concrete product, security, or build evidence would be a blocker.
+
+## Open GitHub Issue
+
+Open issue:
+
+- `#634 Track post-merge security gate failure on PR #631`
+- URL: `https://github.com/ContextualWisdomLab/naruon/issues/634`
+- Status: open
+- Branch follow-up: `scripts/ci/pr_governance_gate.sh` now exits non-zero after posting or updating a blocker comment, with regression evidence in `docs/superpowers/reports/2026-07-02-naruon-security-governance-followup.md`.
+- Meaning: governance/security gate hardening remains an enterprise-readiness risk until this patch lands on the trusted base branch and issue #634 is closed with remote evidence.
+- Current interpretation: this is not a current PR #893 product-code failure, but it must be resolved or explicitly disclosed before treating the program as final procurement-ready.
+
+## Product Design Context
+
+Product Design saved context preflight:
+
+```json
+{
+ "exists": false,
+ "status": "missing",
+ "entries": []
+}
+```
+
+Interpretation: use repository sources as the authoritative design context. The durable source set is:
+
+- `docs/ui-ux/naruon-ui-ux-mapping.md`
+- `docs/ui-ux/mockups/mockup_01.png` through `mockup_41.png`
+- `docs/ui-ux/reference-set-2026-06-18/images/`
+- `docs/ui-ux/individual-assets-2026-06-22/manifest.tsv`
+
+## Figma State
+
+Current Figma file:
+
+- `https://www.figma.com/design/68b5XB58w8nwT2LYOOnikK`
+- File key: `68b5XB58w8nwT2LYOOnikK`
+
+Observed through Figma metadata:
+
+```text
+Top-level pages:
+- 0:1: Source Map
+```
+
+Observed through `search_design_system` with `disableCodeConnect=true`:
+
+```json
+{
+ "components": [],
+ "variables": [],
+ "styles": []
+}
+```
+
+Interpretation:
+
+- Figma Code Connect was not used.
+- The initial top-level metadata response did not show all pages claimed by older local reports.
+- `use_figma` was used to repair missing required pages without Code Connect.
+- The repair call reported that `Foundations`, `Components`, `Desktop Screens`, `Mobile Screens`, and `QA Notes` already existed and created `Interaction States` (`15:2`) plus `Sales Demo` (`15:3`).
+- Direct metadata for `Sales Demo` (`15:3`) confirmed the new page and frame.
+- The first buyer-demo frame now exists as `Sales Demo / 20B Enterprise Review Flow` (`16:2`).
+- A final screenshot was downloaded to `/tmp/naruon-20b-sales-demo-final.png`; it is a 1080 x 608 PNG and visual inspection found no text overlap, clipped bottom copy, or placeholder shimmer.
+- Responsive Product Design QA evidence now exists in `QA Notes / Naruon 20B Responsive QA Evidence` (`18:3`) with the uploaded contact-sheet image target (`18:7`).
+- Because design system search is empty, the first pass should use local Naruon tokens/components derived from repo mockups, not a pretend external library.
+
+## Existing Product Coverage
+
+Frontend app routes currently exist for:
+
+```text
+/
+/mail
+/search
+/calendar
+/tasks
+/projects
+/data
+/ai-hub
+/security
+/settings
+/tools
+/prompt-studio
+```
+
+Important frontend anchors:
+
+- `frontend/src/components/EmailDetail.tsx`
+- `frontend/src/components/SearchLayout.tsx`
+- `frontend/src/components/SourceDrawer.tsx`
+- `frontend/src/lib/product-events.ts`
+- `frontend/scripts/pilot-ui-smoke.mjs`
+- `frontend/scripts/pilot-ui-smoke.test.mjs`
+- `frontend/scripts/full-product-ui-smoke.mjs`
+- `frontend/scripts/full-product-ui-smoke.test.mjs`
+- `docs/superpowers/reports/2026-07-02-naruon-20b-responsive-product-design-qa.md`
+- `docs/superpowers/reports/assets/2026-07-02-naruon-responsive-qa-contact-sheet.png`
+
+Backend APIs and services exist for:
+
+- auth/session
+- accounts
+- email
+- calendar
+- tasks
+- search
+- ontology/network
+- data
+- AI Hub
+- LLM providers
+- security
+- runtime/tenant config
+- WebDAV/DAV
+- provider writeback retry
+- RBAC and URL validation
+
+Interpretation: the repository has broad product scaffolding. The remaining sale-readiness problem is not route existence. It is buyer-visible completeness, production-path proof, provider-write evidence, security/compliance packaging, and repeatable end-to-end validation.
+
+## Analytics State
+
+Existing local implementation:
+
+- `frontend/src/lib/product-events.ts`
+- `frontend/src/lib/product-events.test.ts`
+- call sites in `EmailDetail.tsx` and `SearchLayout.tsx`
+- event dictionary in `docs/superpowers/reports/2026-07-02-naruon-event-dictionary.md`
+
+Current limitation:
+
+- No live analytics warehouse or destination is confirmed.
+- No live KPI value can be claimed.
+- Product-event dispatch is browser-local and memory-bounded.
+
+Interpretation: analytics is good enough for privacy-safe pilot instrumentation, not final ROI proof.
+
+## Library And Submodule Decision
+
+Decision for current phase:
+
+- No submodule.
+- No separately versioned library.
+- No new dependency for product-event, UI, or smoke-test work unless existing platform tools cannot cover the need.
+
+Reasoning:
+
+- The repo already has `frontend`, `backend`, and `connector` boundaries.
+- `frontend/package.json` is private and already includes the needed UI/test primitives.
+- There is no current independent consumer requiring separate versioning.
+- A submodule would add operational drag without improving buyer evidence.
+
+Acceptable later extraction:
+
+- Internal workspace package under `frontend/packages/` if product events, design tokens, or UI primitives are consumed by at least three internal surfaces.
+- Git submodule only when ownership and release cadence are independent from `ContextualWisdomLab/naruon`.
+
+## Gap Summary
+
+P0 for 20B final sale readiness:
+
+- Figma required pages were repaired through `use_figma`, but full screen coverage and durable QA screenshots for every buyer flow are not complete.
+- Full ten-area Product Design route audit now has desktop/mobile evidence, expanded selected desktop/mobile workflow-state evidence across nine IA routes, mocked provider completion result-state evidence, search graph summary/canvas-label plus relationship detail/node detail/zoom/fit evidence, project WebDAV/thread/document source-attachment plus local evidence edit/save/source-mutation evidence, security source-governance/permission-edit/server-denial-result/permission-audit-persistence/denial-variant matrix/durable-audit/connector-observation evidence, settings embedding/account/startup-view reload persistence evidence, basic automated accessibility evidence, and mail SourceDrawer focus-trap evidence, but live provider completion, full result variants, canvas-direct graph event variants, project live persistence/conflict variants, live connector-enforced security denial evidence, remaining settings persistence variants, and assistive-technology audit are not complete.
+- Production deployment and rollback evidence is not packaged.
+- Live provider-send and provider-write execution evidence is incomplete.
+- Issue #634 has a branch-level fix, but remains open as a governance risk until merged and proven on the trusted base branch.
+- Buyer package, security questionnaire, and SLA/support drafts now exist, but data-processing terms, buyer approval, and production incident evidence remain incomplete.
+- ROI model is not backed by live measured data.
+
+P1 for buyer technical review:
+
+- Full-product smoke covers all ten IA routes on localhost, supports desktop/mobile capture through `NARUON_FULL_PRODUCT_VIEWPORTS=desktop,mobile`, and is wired into the branch CI workflow with desktop default. It now asserts expanded selected desktop/mobile buyer workflow states for mail, search, calendar, tasks, projects, data, AI Hub, security, and settings, including mocked provider-send/provider-write completion states, mail SourceDrawer initial focus/Tab trap/Escape close/focus restore, search graph summary/canvas-label/relationship detail/node detail/zoom/fit states, knowledge WebDAV provider completion/no-retry states, project related-source link, source boundary, WebDAV folder evidence, thread/document source attachments, evidence note edit, source mutation, save-state confirmation, and source-type count, data WebDAV materialization completion and WebDAV/unique-thread intents, AI Hub workflow/evaluation/run-history navigation plus completed run event title/detail/evidence-source, security source-governance/write-boundary/policy-decision/permission-edit/denial-result/permission-save-server-evidence/permission-workspace-denial/permission-region-denial/permission-consent-denial/durable-audit/connector-observation states, security deny samples, settings embedding/account save state, settings embedding/account/startup-view reload persistence, and connector token rotation, plus basic accessibility checks for visible duplicate IDs, visible interactive accessible names, and keyboard Tab focus entry.
+- Mobile Settings startup-view cards were fixed from a 3-column mobile grid to `grid-cols-1 sm:grid-cols-3` after responsive QA found awkward Korean label wrapping.
+- Mobile Search result pane height was capped at `max-h-[34dvh] sm:max-h-[42dvh]` after responsive QA found graph evidence crowding on the 390px mobile viewport.
+- Product events do not yet cover full-product funnels beyond mail/search.
+- External analytics destination, retention, and consent are not approved.
+- Figma `Sales Demo` has current evidence and `QA Notes` now contains the desktop/mobile contact sheet with expanded selected desktop/mobile workflow-state, mocked provider completion result-state, mail SourceDrawer focus-trap proof, search graph summary/canvas-label plus relationship detail/node detail/zoom/fit proof, project WebDAV/thread/document source-attachment plus evidence edit/save/source-mutation proof, security source-governance/permission-edit/server-denial-result/permission-save-server-evidence/denial-variant proof/durable-audit/connector-observation proof, settings embedding/account/startup-view reload persistence, and basic accessibility evidence; live provider completion and assistive-technology evidence remain incomplete.
+
+Non-blockers:
+
+- Review process delay.
+- `opencode-review` in progress.
+- Stale `CHANGES_REQUESTED` when all current review threads are resolved and product checks are passing.
+
+## Next Actions
+
+1. Repair Figma page structure without Code Connect.
+2. Confirm the branch CI full-product smoke result and tune runtime or browser install cost if needed.
+3. Expand smoke assertions from selected actions to complete workflow completion paths and result variants.
+4. Extend analytics and ROI reports without claiming live KPI values.
+5. Merge the issue #634 governance patch and close the issue only after trusted-base remote evidence proves blocker comments fail the governance check.
+6. Review buyer package, demo script, security questionnaire, and SLA/support drafts with buyer/legal/security owners.
+7. Run local and remote verification before any completion claim.
diff --git a/docs/superpowers/reports/2026-07-02-naruon-20b-demo-script.md b/docs/superpowers/reports/2026-07-02-naruon-20b-demo-script.md
new file mode 100644
index 000000000..f902da99a
--- /dev/null
+++ b/docs/superpowers/reports/2026-07-02-naruon-20b-demo-script.md
@@ -0,0 +1,201 @@
+# Naruon 20B Buyer Demo Script
+
+Date: 2026-07-02 KST
+
+## Purpose
+
+This script supports a controlled enterprise buyer technical review. It avoids claiming public-launch readiness, live ROI, or final procurement completion.
+
+## Pre-demo Checks
+
+Run before a buyer session:
+
+```bash
+pnpm --dir frontend test
+pnpm --dir frontend typecheck
+pnpm --dir frontend full:smoke
+```
+
+Optional pilot-depth checks:
+
+```bash
+pnpm --dir frontend test scripts/pilot-ui-smoke.test.mjs
+pnpm --dir frontend pilot:smoke
+```
+
+Required evidence to have open:
+
+- PR #893: `https://github.com/ContextualWisdomLab/naruon/pull/893`
+- Figma file `68b5XB58w8nwT2LYOOnikK`, frame `Sales Demo / 20B Enterprise Review Flow`
+- Current audit: `docs/superpowers/reports/2026-07-02-naruon-20b-current-state-audit.md`
+- Buyer package: `docs/superpowers/reports/2026-07-02-naruon-20b-buyer-package.md`
+
+## Opening Talk Track
+
+```text
+Naruon is being shown as a controlled enterprise technical-review package. The demo proves buyer-visible workflows, privacy-safe local measurement for pilot flows, and governance evidence. It does not claim public SaaS launch readiness or final procurement completion.
+```
+
+## Flow 1: Home Context
+
+Route: `/`
+
+Show:
+
+- Navigation across mail, calendar, tasks, projects, search, data, AI Hub, security, and settings.
+- Context synthesis as the first working surface.
+- The distinction between buyer-review surfaces and production-live evidence still needed.
+
+Evidence:
+
+- `frontend/scripts/full-product-ui-smoke.mjs`
+- Screenshot path produced by smoke: `/tmp/naruon-full-product-smoke/home.png`
+
+## Flow 2: Mail Evidence And Source Review
+
+Route: `/mail`
+
+Show:
+
+- Mail list item.
+- Context synthesis in the mail detail surface.
+- Source evidence drawer.
+- Focus trap, Escape close, and source-grounded review.
+
+Talk track:
+
+```text
+The core value is not generic summarization. The buyer can inspect source evidence before acting.
+```
+
+Evidence:
+
+- `frontend/src/components/EmailDetail.tsx`
+- `frontend/src/components/SourceDrawer.tsx`
+- `frontend/scripts/pilot-ui-smoke.mjs`
+
+## Flow 3: Privacy-safe Draft And Action Creation
+
+Route: `/mail`
+
+Show:
+
+- Draft generation.
+- Development-mode send simulation.
+- Action item creation.
+- Calendar reflection intent.
+
+Required caveat:
+
+```text
+Provider-send and provider-write evidence is not final procurement evidence yet. The current package proves intent handling and UI flow; live provider execution must be proven in a buyer-approved environment.
+```
+
+Evidence:
+
+- `frontend/src/lib/product-events.ts`
+- `docs/superpowers/reports/2026-07-02-naruon-event-dictionary.md`
+- `docs/superpowers/reports/2026-07-02-naruon-kpi-validation.md`
+
+## Flow 4: Search And Relationship Capture
+
+Route: `/search`
+
+Show:
+
+- Context search.
+- Result detail.
+- Sender relationship capture.
+- Action reason displayed from source context.
+
+Evidence:
+
+- `frontend/src/components/SearchLayout.tsx`
+- `frontend/scripts/pilot-ui-smoke.mjs`
+
+## Flow 5: Tasks
+
+Route: `/tasks`
+
+Show:
+
+- Ticket counts.
+- Kanban columns.
+- Source-linked action item.
+- Priority and status changes.
+
+Evidence:
+
+- `frontend/src/components/TasksLayout.tsx`
+- `frontend/scripts/full-product-ui-smoke.mjs`
+- Screenshot path: `/tmp/naruon-full-product-smoke/tasks.png`
+
+## Flow 6: Data And Files
+
+Route: `/data`
+
+Show:
+
+- Data repository surface.
+- File/document evidence state.
+- Pipeline and quality checks.
+- Explicit provider-write boundary language.
+
+Evidence:
+
+- `frontend/src/components/DataLayout.tsx`
+- `backend/api/data.py`
+- Screenshot path: `/tmp/naruon-full-product-smoke/data.png`
+
+## Flow 7: AI Hub
+
+Route: `/ai-hub`
+
+Show:
+
+- Prompt/workflow/agent surfaces.
+- Provider readiness cards.
+- Evaluation metric placeholders.
+
+Required caveat:
+
+```text
+The framework is ready for evaluation reporting, but live evaluation scores and ROI claims require measured pilot data.
+```
+
+Evidence:
+
+- `frontend/src/components/AIHubLayout.tsx`
+- `backend/api/ai_hub.py`
+
+## Flow 8: Security And Settings
+
+Routes: `/security`, `/settings`
+
+Show:
+
+- RBAC/ABAC security governance surface.
+- Audit and policy tabs.
+- Workspace settings.
+- AI model and connector configuration boundaries.
+
+Evidence:
+
+- `frontend/src/components/SecurityLayout.tsx`
+- `frontend/src/components/SettingsLayout.tsx`
+- `backend/api/security.py`
+- `docs/superpowers/reports/2026-07-02-naruon-security-governance-followup.md`
+
+## Closing Talk Track
+
+```text
+This package is ready for a controlled technical review and pilot acceptance discussion after CI gates pass. Final procurement requires production deployment proof, live provider evidence, security/legal approval, support/SLA agreement, and measured ROI.
+```
+
+## Do Not Say
+
+- "Naruon is public-launch ready."
+- "The 20B KRW sale is guaranteed."
+- "Live ROI has been proven."
+- "All provider writes are production-proven."
+- "Issue #634 is closed."
diff --git a/docs/superpowers/reports/2026-07-02-naruon-20b-responsive-product-design-qa.md b/docs/superpowers/reports/2026-07-02-naruon-20b-responsive-product-design-qa.md
new file mode 100644
index 000000000..2748a06ed
--- /dev/null
+++ b/docs/superpowers/reports/2026-07-02-naruon-20b-responsive-product-design-qa.md
@@ -0,0 +1,149 @@
+# Naruon 20B Responsive Product Design QA
+
+Audit date: 2026-07-02 KST
+
+## Scope
+
+Surface audited: Naruon full-product buyer-review IA.
+
+Routes:
+
+```text
+/, /mail, /search, /calendar, /tasks, /projects, /data, /ai-hub, /security, /settings
+```
+
+Viewports:
+
+```text
+desktop: 1440 x 1024
+mobile: 390 x 844
+```
+
+Destination:
+
+- Local screenshot evidence: `/tmp/naruon-full-product-responsive-qa/`
+- Durable contact sheet: `docs/superpowers/reports/assets/2026-07-02-naruon-responsive-qa-contact-sheet.png`
+- Figma destination: `68b5XB58w8nwT2LYOOnikK / QA Notes / Naruon 20B Responsive QA Evidence`
+- Figma board node: `18:3`
+- Figma contact-sheet image target node: `18:7`
+
+## Command
+
+```bash
+NARUON_FULL_PRODUCT_SCREENSHOT_DIR=/tmp/naruon-full-product-responsive-qa \
+NARUON_FULL_PRODUCT_VIEWPORTS=desktop,mobile \
+pnpm --dir frontend run full:smoke
+```
+
+Result:
+
+```text
+Naruon full-product route smoke passed.
+Routes: /, /mail, /search, /calendar, /tasks, /projects, /data, /ai-hub, /security, /settings
+Viewports: desktop(1440x1024), mobile(390x844)
+Critical interactions: desktop:mail:select-message, desktop:mail:open-source-drawer, desktop:mail:verify-source-drawer-initial-focus, desktop:mail:verify-source-drawer-tab-trap, desktop:mail:verify-source-drawer-escape-close, desktop:mail:verify-source-drawer-focus-restore, desktop:mail:generate-reply-draft, desktop:mail:send-simulated-reply, desktop:mail:send-provider-reply, desktop:mail:create-source-linked-task, desktop:search:select-result, desktop:search:open-source-evidence-tab, desktop:search:open-decision-assist-tab, desktop:search:capture-sender-relationship, desktop:search:verify-captured-relationship-state, desktop:search:open-network-graph, desktop:search:verify-network-graph-summary, desktop:search:verify-network-graph-canvas-label, desktop:search:select-network-relationship, desktop:search:verify-network-relationship-detail, desktop:search:zoom-network-graph, desktop:search:fit-network-graph, desktop:search:select-network-second-relationship, desktop:search:verify-network-node-detail, desktop:calendar:create-writeback-intent, desktop:calendar:verify-etag-update-intent, desktop:calendar:request-provider-write, desktop:calendar:verify-provider-completion-state, desktop:calendar:verify-provider-no-retry-state, desktop:tasks:create-reply-sla-followup, desktop:tasks:complete-source-linked-task, desktop:tasks:create-knowledge-webdav-intent, desktop:tasks:request-knowledge-provider-write, desktop:tasks:verify-knowledge-provider-completion-state, desktop:tasks:verify-knowledge-provider-no-retry-state, desktop:projects:open-related-source-link, desktop:projects:open-decision-log, desktop:projects:verify-webdav-folder-evidence, desktop:projects:reopen-project-detail, desktop:projects:verify-source-boundary, desktop:projects:verify-thread-source-attachment, desktop:projects:verify-document-source-attachment, desktop:projects:edit-evidence-note, desktop:projects:mutate-evidence-source, desktop:projects:save-evidence-note, desktop:projects:verify-evidence-save-state, desktop:projects:verify-source-type-count, desktop:data:create-embedding-regeneration-intent, desktop:data:create-hwp-conversion-intent, desktop:data:execute-webdav-materialization, desktop:data:verify-webdav-materialization-completion-state, desktop:data:create-webdav-writeback-intent, desktop:data:create-unique-thread-intent, desktop:data:open-quality-checks, desktop:ai-hub:open-workflow-tab, desktop:ai-hub:open-evaluation-tab, desktop:ai-hub:open-run-history-from-evidence, desktop:ai-hub:open-run-history, desktop:ai-hub:verify-run-event-title, desktop:ai-hub:verify-run-event-completion-state, desktop:ai-hub:verify-run-event-detail, desktop:ai-hub:verify-run-event-evidence-source, desktop:security:verify-access-source-governance, desktop:security:verify-write-capability-boundary, desktop:security:verify-policy-allow-decision, desktop:security:edit-permission-decision, desktop:security:save-permission-decision, desktop:security:verify-denial-result-state, desktop:security:verify-permission-save-server-evidence, desktop:security:verify-permission-workspace-denial, desktop:security:verify-permission-region-denial, desktop:security:verify-permission-consent-denial, desktop:security:return-permission-editor-evidence, desktop:security:open-audit-log, desktop:security:verify-durable-audit-event, desktop:security:verify-connector-observation, desktop:security:open-sharing-review, desktop:security:verify-external-write-block, desktop:security:open-policy-order, desktop:security:verify-deny-sample, desktop:settings:switch-ai-model-tab, desktop:settings:save-embedding-model, desktop:settings:verify-embedding-model-save-state, desktop:settings:save-account-config, desktop:settings:verify-account-save-state, desktop:settings:select-calendar-startup-view, desktop:settings:verify-startup-view-persistence, desktop:settings:verify-embedding-model-reload-persistence, desktop:settings:verify-account-reload-persistence, desktop:settings:rotate-connector-token, mobile:mail:select-message, mobile:mail:open-source-drawer, mobile:mail:verify-source-drawer-initial-focus, mobile:mail:verify-source-drawer-tab-trap, mobile:mail:verify-source-drawer-escape-close, mobile:mail:verify-source-drawer-focus-restore, mobile:mail:generate-reply-draft, mobile:mail:send-simulated-reply, mobile:mail:send-provider-reply, mobile:mail:create-source-linked-task, mobile:search:select-result, mobile:search:open-source-evidence-tab, mobile:search:open-decision-assist-tab, mobile:search:capture-sender-relationship, mobile:search:verify-captured-relationship-state, mobile:search:open-network-graph, mobile:search:verify-network-graph-summary, mobile:search:verify-network-graph-canvas-label, mobile:search:select-network-relationship, mobile:search:verify-network-relationship-detail, mobile:search:zoom-network-graph, mobile:search:fit-network-graph, mobile:search:select-network-second-relationship, mobile:search:verify-network-node-detail, mobile:calendar:create-writeback-intent, mobile:calendar:verify-etag-update-intent, mobile:calendar:request-provider-write, mobile:calendar:verify-provider-completion-state, mobile:calendar:verify-provider-no-retry-state, mobile:tasks:create-reply-sla-followup, mobile:tasks:complete-source-linked-task, mobile:tasks:create-knowledge-webdav-intent, mobile:tasks:request-knowledge-provider-write, mobile:tasks:verify-knowledge-provider-completion-state, mobile:tasks:verify-knowledge-provider-no-retry-state, mobile:projects:open-related-source-link, mobile:projects:open-decision-log, mobile:projects:verify-webdav-folder-evidence, mobile:projects:reopen-project-detail, mobile:projects:verify-source-boundary, mobile:projects:verify-thread-source-attachment, mobile:projects:verify-document-source-attachment, mobile:projects:edit-evidence-note, mobile:projects:mutate-evidence-source, mobile:projects:save-evidence-note, mobile:projects:verify-evidence-save-state, mobile:projects:verify-source-type-count, mobile:data:create-embedding-regeneration-intent, mobile:data:create-hwp-conversion-intent, mobile:data:execute-webdav-materialization, mobile:data:verify-webdav-materialization-completion-state, mobile:data:create-webdav-writeback-intent, mobile:data:create-unique-thread-intent, mobile:data:open-quality-checks, mobile:ai-hub:open-workflow-tab, mobile:ai-hub:open-evaluation-tab, mobile:ai-hub:open-run-history-from-evidence, mobile:ai-hub:open-run-history, mobile:ai-hub:verify-run-event-title, mobile:ai-hub:verify-run-event-completion-state, mobile:ai-hub:verify-run-event-detail, mobile:ai-hub:verify-run-event-evidence-source, mobile:security:verify-access-source-governance, mobile:security:verify-write-capability-boundary, mobile:security:verify-policy-allow-decision, mobile:security:edit-permission-decision, mobile:security:save-permission-decision, mobile:security:verify-denial-result-state, mobile:security:verify-permission-save-server-evidence, mobile:security:verify-permission-workspace-denial, mobile:security:verify-permission-region-denial, mobile:security:verify-permission-consent-denial, mobile:security:return-permission-editor-evidence, mobile:security:open-audit-log, mobile:security:verify-durable-audit-event, mobile:security:verify-connector-observation, mobile:security:open-sharing-review, mobile:security:verify-external-write-block, mobile:security:open-policy-order, mobile:security:verify-deny-sample, mobile:settings:switch-ai-model-tab, mobile:settings:save-embedding-model, mobile:settings:verify-embedding-model-save-state, mobile:settings:save-account-config, mobile:settings:verify-account-save-state, mobile:settings:select-calendar-startup-view, mobile:settings:verify-startup-view-persistence, mobile:settings:verify-embedding-model-reload-persistence, mobile:settings:verify-account-reload-persistence, mobile:settings:rotate-connector-token
+Accessibility checks: home:a11y-basics, mail:a11y-basics, search:a11y-basics, calendar:a11y-basics, tasks:a11y-basics, projects:a11y-basics, data:a11y-basics, ai-hub:a11y-basics, security:a11y-basics, settings:a11y-basics, home:a11y-basics, mail:a11y-basics, search:a11y-basics, calendar:a11y-basics, tasks:a11y-basics, projects:a11y-basics, data:a11y-basics, ai-hub:a11y-basics, security:a11y-basics, settings:a11y-basics
+```
+
+Figma placement result:
+
+```text
+QA Notes board created and verified.
+Contact-sheet upload accepted with response imageHash 9cbbd4836f9ebfd0a7891c2788579454190e12e2.
+Figma node 18:7 verified with image fill hash 9cbbd4836f9ebfd0a7891c2788579454190e12e2 and FIT scale mode.
+Verification screenshot confirmed the contact sheet and notes are visible inside board node 18:3.
+```
+
+## Evidence Files
+
+Captured screenshot files:
+
+```text
+desktop-ai-hub.png
+desktop-calendar.png
+desktop-data.png
+desktop-home.png
+desktop-mail.png
+desktop-projects.png
+desktop-search.png
+desktop-security.png
+desktop-settings.png
+desktop-tasks.png
+mobile-ai-hub.png
+mobile-calendar.png
+mobile-data.png
+mobile-home.png
+mobile-mail.png
+mobile-projects.png
+mobile-search.png
+mobile-security.png
+mobile-settings.png
+mobile-tasks.png
+```
+
+All desktop screenshots were verified at `1440 x 1024`. All mobile screenshots were verified at `390 x 844`.
+
+## Findings
+
+1. Desktop route and expanded selected workflow-state coverage is now stable enough for buyer technical review.
+ - Evidence: all ten IA routes render expected buyer-visible text and produce non-empty screenshots.
+ - Interaction evidence: mail source drawer, reply draft, simulated send, mocked provider-send completion, and source task creation; search evidence/assist tabs, captured relationship state, graph summary, graph canvas label, first and second relationship detail, node detail, graph zoom, and graph fit; calendar create/update/provider-write completion/no-retry state; task completion plus knowledge WebDAV intent and provider-write completion/no-retry state; project related-source link, decision/detail/source boundary, WebDAV folder evidence, thread attachment, document attachment, evidence note edit, source mutation, save-state confirmation, and source-type count; data embedding, HWP, mocked WebDAV materialization completion, writeback, unique-thread, and quality checks; AI Hub workflow/evaluation/run history plus run event title/completion/detail/evidence-source; security source governance, write capability boundary, policy allow decision, permission edit/save, backend audit persistence, denial-result state, workspace/region/consent denial variants, durable audit event, connector observation, external-write block, and deny sample; settings embedding/account save state, embedding/account reload persistence, startup selection reload persistence, and connector token rotation all passed.
+ - Health: pass for route-level and selected interaction smoke.
+
+2. Mobile route and expanded selected workflow-state coverage is now stable enough for buyer technical review.
+ - Evidence: all ten IA routes render expected buyer-visible text and produce mobile screenshots.
+ - Interaction evidence: the same selected buyer-critical flows passed on the mobile viewport.
+ - Health: pass for route-level and selected interaction smoke.
+
+3. Desktop and mobile accessibility basics now have repeatable smoke evidence.
+ - Evidence: all ten IA routes passed visible duplicate-ID, visible interactive accessible-name, and keyboard Tab focus-entry checks on desktop and mobile.
+ - Health: pass for basic automated accessibility smoke.
+
+4. Mail SourceDrawer now has desktop and mobile keyboard containment evidence.
+ - Evidence: full smoke asserts initial focus on the close button, forward and reverse Tab wrapping inside the drawer, Escape close, and focus restoration to `근거 원본 보기`.
+ - Health: pass for the first route-specific drawer focus trap.
+
+5. Mobile Search graph evidence had result-pane crowding before correction.
+ - Evidence: `mobile-search.png` placed the selected result pane high above the graph evidence area after the graph interaction smoke scrolled the detail region.
+ - Fix: `frontend/src/components/SearchLayout.tsx` now caps the base mobile result pane at `max-h-[34dvh]` while keeping `sm:max-h-[42dvh]` and desktop behavior unchanged.
+ - Health: fixed and re-captured.
+
+6. Mobile Settings had a responsive label wrapping defect before correction.
+ - Evidence: `mobile-settings.png` initially showed `일정 관리` split awkwardly inside a narrow 3-column card.
+ - Fix: `frontend/src/components/SettingsLayout.tsx` now switches the startup view selector from `grid-cols-3` to `grid-cols-1 sm:grid-cols-3`.
+ - Health: fixed and re-captured.
+
+7. The smoke gate still does not prove full workflow completion.
+ - Evidence: the command now checks expected route text, console errors, not-found states, screenshot creation, broad selected desktop/mobile interactions across nine IA routes, basic automated accessibility checks, the mail SourceDrawer focus trap, and accessible search graph relationship detail/node detail/zoom/fit controls.
+ - Remaining gap: live provider send/write execution against real connectors, production delivery confirmation, canvas-direct graph event variants beyond accessible graph controls, project live persistence/conflict variants beyond the local evidence editor, live connector-enforced security denial proof beyond the non-mutating permission-intent matrix, remaining settings persistence variants outside AI model/account/startup-view coverage, and remaining route-specific modal variants need workflow-specific assertions before final procurement readiness.
+
+## Accessibility Risks
+
+- The current automated gate proves only three basic checks: no visible duplicate IDs, no visible interactive controls without accessible names, and keyboard Tab entry reaches a focusable element.
+- It also proves the mail SourceDrawer focus trap, Escape close, and trigger focus restoration on desktop and mobile.
+- It does not prove full keyboard order, screen-reader semantics, color contrast, zoom reflow, target size, or assistive-technology robustness.
+- Bottom mobile navigation is visually present across routes; target-size and full focus-order verification remain unproven from screenshots and the basic gate alone.
+
+## Product Design Assessment
+
+The current branch now has repeatable desktop and mobile visual evidence for the ten buyer-review IA routes, expanded selected desktop/mobile workflow-state smoke evidence across nine IA routes, mocked provider completion result-state evidence, search graph summary/canvas-label plus relationship detail/node detail/zoom/fit evidence, project WebDAV/thread/document source-attachment plus evidence edit/save/source-mutation evidence, security source-governance/permission-edit/backend-audit-persistence/denial-result/denial-variant matrix/durable-audit/connector-observation evidence, settings embedding/account/startup-view reload persistence evidence, basic automated accessibility evidence across desktop and mobile, and route-specific mail SourceDrawer focus-trap evidence. This moves the package from route-existence evidence to responsive route-level evidence with materially broader workflow and accessibility proof, but it does not prove full sale readiness.
+
+The remaining Product Design P0/P1 work is:
+
+1. Expand interaction-state coverage from selected actions to complete workflow completion paths:
+ - mail live provider send and delivery-state persistence
+ - search graph canvas-direct selection variants beyond accessible relationship/node detail/zoom/fit proof
+ - calendar live provider write completion and conflict/result variants
+ - task assignment, delegation, and live provider-backed completion variants
+ - project live provider-backed evidence persistence and conflict variants beyond local edit/save/source-mutation proof
+ - data live provider-executed WebDAV materialization and document action failure variants
+ - AI Hub run/log failure, retry, and trace variants beyond completed run event proof
+ - security live connector-enforced denial proof beyond the non-mutating permission-intent matrix
+ - settings persistence variants beyond AI model, account, startup-view, and connector token coverage
+2. Expand accessibility evidence beyond the basic gate:
+ - deterministic focus order for primary workflows
+ - remaining modal focus trapping beyond the mail SourceDrawer
+ - contrast sampling
+ - screen-reader semantics for evidence drawers and workflow panels
+3. Keep placing final screenshot evidence in Figma `QA Notes` after each accepted run.
+4. Avoid claiming public launch or final procurement readiness until live provider, production deployment, rollback, support, security, and measured ROI evidence exist.
diff --git a/docs/superpowers/reports/2026-07-02-naruon-20b-security-questionnaire.md b/docs/superpowers/reports/2026-07-02-naruon-20b-security-questionnaire.md
new file mode 100644
index 000000000..897527662
--- /dev/null
+++ b/docs/superpowers/reports/2026-07-02-naruon-20b-security-questionnaire.md
@@ -0,0 +1,92 @@
+# Naruon 20B Security Questionnaire Draft
+
+Date: 2026-07-02 KST
+
+## Status
+
+Draft for buyer security review. This is not a signed legal, compliance, or DPA response.
+
+## Summary Answers
+
+| Question | Draft answer | Evidence | Status |
+| --- | --- | --- | --- |
+| What is Naruon? | Customer-owned AI workspace and control plane for mail, calendar, files, tasks, search, AI workflows, and governance. | `README.md`, `docs/architecture/naruon-product-spec.md` | Draft |
+| Does Naruon host buyer mailboxes? | Current architecture treats customer-owned mail/file/calendar systems as source systems; Naruon is not positioned as the mailbox host in this package. | `docs/operations/email-relay-proxy-boundary.md`, `docs/superpowers/specs/2026-07-02-naruon-20b-full-commercial-readiness-design.md` | Draft |
+| How is authentication handled? | Backend accepts signed bearer sessions and rejects unsupported critical headers. | `backend/api/auth.py`, `backend/tests/test_auth_real.py` | Implemented, buyer env proof pending |
+| How is authorization handled? | RBAC/ABAC policy logic exists and is tested. | `backend/core/rbac.py`, `backend/tests/test_rbac.py`, `backend/tests/test_access_policy.py` | Implemented, buyer policy mapping pending |
+| Are provider writes automatic? | Provider-write paths must be explicit, intent-based, and audited. The current buyer package does not claim all live provider-write paths are production-proven. | `backend/services/`, `frontend/scripts/full-product-ui-smoke.mjs` | Caveated |
+| Is analytics privacy-safe? | Pilot product events block sensitive raw text fields and are local-only in the current implementation. | `frontend/src/lib/product-events.ts`, `frontend/src/lib/product-events.test.ts` | Mail/search covered |
+| Are live KPIs available? | No. The KPI model is defined, but live measured data is required before ROI claims. | `docs/superpowers/reports/2026-07-02-naruon-kpi-validation.md` | Not complete |
+| Is CI/security gating enforced? | Branch-level PR governance patch now fails closed on blocker comments, but issue #634 remains open until trusted-base remote proof exists. | `docs/superpowers/reports/2026-07-02-naruon-security-governance-followup.md` | Branch fix |
+| Is production deployment proven? | Not in this package. Deployment and rollback proof must be attached before final procurement. | `docs/operations/`, `.github/workflows/` | Not complete |
+| Is incident response documented? | Draft support/SLA package exists; formal incident response requires buyer/operator approval. | `docs/superpowers/reports/2026-07-02-naruon-20b-sla-support-draft.md` | Draft |
+
+## Data Handling Draft
+
+Data categories in scope:
+
+- Mail metadata and message content selected by the customer.
+- Calendar event metadata selected by the customer.
+- File/document metadata and content selected by the customer.
+- Task and project metadata generated from customer-owned sources.
+- Product-event telemetry for pilot flows, without raw sensitive text.
+
+Processing boundaries:
+
+- Use source-grounded displays for buyer-visible actions.
+- Avoid copying raw private body text into product analytics payloads.
+- Treat provider-send and provider-write execution as explicit user or admin actions.
+- Keep customer-owned sources as systems of record unless a buyer contract says otherwise.
+
+Open approvals:
+
+- DPA and data retention schedule.
+- Tenant-specific subprocessors.
+- External analytics destination and retention period.
+- Production log redaction policy.
+- Support access and impersonation policy.
+
+## Access Control Draft
+
+Implemented or present:
+
+- Signed bearer-session boundary in backend auth.
+- RBAC/ABAC policy tests.
+- Security dashboard surface.
+- Provider secret handling UI language in settings.
+
+Needs buyer-specific proof:
+
+- Tenant role matrix.
+- SSO/IdP integration proof.
+- Admin break-glass policy.
+- Support access audit policy.
+- Production audit-log retention and export path.
+
+## Secure Development And CI
+
+Current evidence:
+
+- Application CI.
+- Bandit.
+- Trivy.
+- CodeQL.
+- Scorecard.
+- Dependency review.
+- Strix security scan.
+- PR governance gate.
+
+Important caveat:
+
+Issue #634 showed a historical governance failure mode. This branch patches the central script, but final closure requires trusted-base remote evidence after merge.
+
+## Procurement Caveats
+
+The following must be disclosed before a final 20B KRW procurement claim:
+
+- Production deployment and rollback proof not attached.
+- Live provider-send and provider-write proof incomplete.
+- External analytics governance not approved.
+- DPA, retention, support, and incident terms are drafts.
+- Full responsive Product Design QA is incomplete.
+- Measured ROI is not available until a live pilot.
diff --git a/docs/superpowers/reports/2026-07-02-naruon-20b-sla-support-draft.md b/docs/superpowers/reports/2026-07-02-naruon-20b-sla-support-draft.md
new file mode 100644
index 000000000..0086f9b9e
--- /dev/null
+++ b/docs/superpowers/reports/2026-07-02-naruon-20b-sla-support-draft.md
@@ -0,0 +1,94 @@
+# Naruon 20B SLA And Support Draft
+
+Date: 2026-07-02 KST
+
+## Status
+
+Draft for commercial negotiation. This is not a signed SLA.
+
+## Service Scope
+
+Covered in a controlled pilot:
+
+- Naruon frontend workspace.
+- Backend API for configured tenant environment.
+- Customer-owned connectors that are explicitly enabled for the pilot.
+- Source-grounded mail, search, task, data, AI Hub, security, and settings workflows.
+
+Excluded unless separately contracted:
+
+- Public SaaS multi-tenant launch.
+- Buyer production cutover.
+- Live provider writes outside an approved pilot environment.
+- Custom SSO, DPA, and incident terms that are not yet approved.
+
+## Draft Support Tiers
+
+| Tier | Target response | Scope |
+| --- | --- | --- |
+| P0 critical outage | 1 business hour | Service unavailable, security incident, data exposure suspicion |
+| P1 major degradation | 4 business hours | Core workflow unusable for pilot users |
+| P2 functional defect | 1 business day | Important feature degraded with workaround |
+| P3 question or enhancement | 3 business days | Usage questions, reporting requests, backlog items |
+
+Final response targets must be buyer-approved and tied to staffed support hours.
+
+## Draft Availability Language
+
+For a controlled pilot:
+
+```text
+Availability is measured only for the contracted pilot environment and excludes planned maintenance, buyer-owned source-system outage, identity provider outage, and unsupported provider/network changes.
+```
+
+Do not claim a production uptime target until:
+
+- Production environment exists.
+- Monitoring and alerting are active.
+- On-call owner is assigned.
+- Backup and rollback procedures are tested.
+- Buyer maintenance windows are agreed.
+
+## Draft Incident Process
+
+1. Detect incident through monitoring, user report, or CI/security alert.
+2. Classify P0/P1/P2/P3.
+3. Open an incident record with time, impact, affected tenant, and suspected source.
+4. Assign owner and communication channel.
+5. Mitigate or roll back.
+6. Preserve evidence for security and audit review.
+7. Publish RCA for P0/P1 incidents.
+
+Required production evidence before final procurement:
+
+- Monitoring dashboard.
+- Alert routing.
+- Rollback runbook.
+- Backup/restore proof.
+- Security incident escalation contact.
+- Data exposure notification workflow.
+
+## Draft Acceptance Gates
+
+A buyer pilot should not start until:
+
+- Current PR head has passing required CI.
+- `pnpm --dir frontend full:smoke` passes.
+- Security caveats are disclosed.
+- Live provider-write scope is either disabled, simulated, or explicitly approved.
+- Support owner and escalation channel are named.
+
+Final procurement should not proceed until:
+
+- Production deployment and rollback proof are attached.
+- Security questionnaire is approved.
+- DPA and retention terms are approved.
+- SLA response/availability targets are signed.
+- Measured pilot KPI report is available.
+
+## Evidence Links
+
+- Commercial readiness spec: `docs/superpowers/specs/2026-07-02-naruon-20b-full-commercial-readiness-design.md`
+- Current audit: `docs/superpowers/reports/2026-07-02-naruon-20b-current-state-audit.md`
+- Security questionnaire draft: `docs/superpowers/reports/2026-07-02-naruon-20b-security-questionnaire.md`
+- Buyer package index: `docs/superpowers/reports/2026-07-02-naruon-20b-buyer-package.md`
diff --git a/docs/superpowers/reports/2026-07-02-naruon-design-to-code-backlog.md b/docs/superpowers/reports/2026-07-02-naruon-design-to-code-backlog.md
new file mode 100644
index 000000000..ca4d17d17
--- /dev/null
+++ b/docs/superpowers/reports/2026-07-02-naruon-design-to-code-backlog.md
@@ -0,0 +1,41 @@
+# Naruon Design-To-Code Backlog
+
+This backlog connects the Figma/Product Design package to the current frontend without using Figma Code Connect. The current implementation covers the first vertical slice and now includes local product-event instrumentation, source evidence drawer behavior, and context-search event hooks.
+
+## Current Code Anchors
+
+| Product element | Figma/source anchor | Current code anchor | Status | Next implementation step |
+| --- | --- | --- | --- | --- |
+| Navigation shell | `Components`, desktop slice, 10-area IA | `frontend/src/components/AIHubLayout.tsx`, `DashboardLayout.tsx`, `CalendarLayout.tsx`, `WorkspaceHome.tsx` | implemented in existing UI | Normalize nav labels against the mandatory terminology when each area is touched. |
+| Evidence/action panel | `Desktop / Mail Detail / Evidence Review` | `frontend/src/components/EmailDetail.tsx` | implemented and instrumented | Add correction/discard follow-through only after the product defines those actions. |
+| Confidence badge | `Components` confidence badge | `frontend/src/components/DecisionPointCard.tsx`, `frontend/src/lib/confidence.ts` | implemented in existing UI | Reuse `toConfidencePercent`; emit confidence in event payload only as 0-100 numeric value. |
+| Source chip | source chip in first slice and `근거 원본 보기` | `EmailDetail.tsx`, `SourceDrawer.tsx`, `DecisionPointCard` provenance chip | implemented and instrumented | Add multi-source lists after backend provenance IDs are available. |
+| Table row | `Component / Table Row` | `frontend/src/components/EmailList.tsx`, data/security/task rows | implemented in existing UI | Add shared row-density guidance only if duplication becomes a maintenance issue. |
+| Source drawer | `Component / Source Drawer` | `frontend/src/components/SourceDrawer.tsx` | implemented and tested | Add richer evidence metadata after source provenance IDs are joined to backend synthesis output IDs. |
+| Reply draft controls | `답장 초안` state | `EmailDetail.tsx` `handleDraftReply`, `Textarea`, `handleSendReply` | implemented and instrumented | Add edit-distance or discard metrics only after privacy review. |
+| Action item controls | `실행 항목` state | `EmailDetail.tsx` `handleCreateTask` | implemented and instrumented | Add task completion follow-through later. |
+| Calendar controls | `일정 반영` state | `EmailDetail.tsx` `handleSyncCalendar` | implemented and instrumented | Keep provider write and local intent separated in dashboard definitions. |
+| Context search actions | `Desktop / Context Search / Evidence Action` | `frontend/src/components/SearchLayout.tsx` | implemented and instrumented | Add zero-result, refinement, and filter-change events in a later KPI pass. |
+| Product event contract | Data Analytics KPI validation | `frontend/src/lib/product-events.ts` | implemented as local dispatcher | External dispatch remains blocked until analytics destination, retention, and consent policy are confirmed. |
+
+## PR Sequence
+
+1. Keep the local dispatcher in `frontend/src/lib/product-events.ts`; do not add external transport until privacy and destination ownership are confirmed.
+2. Extend the existing mail-detail instrumentation with correction, discard, and human feedback events after those UI actions exist.
+3. Expand `SourceDrawer` to multiple sources after backend provenance returns stable source arrays per AI output.
+4. Extend `SearchLayout` with zero-result, refinement, filter-change, and search-abandon events after KPI denominator rules are finalized.
+5. Add product dashboard definitions only after the event destination, retention policy, and warehouse schema are confirmed.
+
+## Product Design Notes
+
+- Keep `맥락 종합`, `판단 포인트`, `실행 항목`, `답장 초안`, `맥락 검색`, `관계 맥락`, `일정 반영`, and `판단 보조` as the visible vocabulary.
+- Keep evidence and confidence near AI output; do not move them into tooltip-only UI.
+- Keep raw email body, raw reply body, and raw search query text out of analytics payloads.
+- Treat source opening as both a trust behavior and a possible clarity problem; pair it with discard/correction rates.
+
+## Blockers And Caveats
+
+- CodeGraph is not initialized in this worktree and no CodeGraph tools are exposed in the session.
+- No live analytics destination is confirmed.
+- Product events are local-only records and browser-local custom events; no network export is implemented.
+- Existing `.Jules/*` modifications are preserved and are not part of this package.
diff --git a/docs/superpowers/reports/2026-07-02-naruon-design-to-code-telemetry-qa.md b/docs/superpowers/reports/2026-07-02-naruon-design-to-code-telemetry-qa.md
new file mode 100644
index 000000000..f5b463658
--- /dev/null
+++ b/docs/superpowers/reports/2026-07-02-naruon-design-to-code-telemetry-qa.md
@@ -0,0 +1,81 @@
+# Naruon Design-To-Code Telemetry QA
+
+Question being answered: whether the follow-up package turns the Figma/Product Design/Data Analytics handoff into working product code without using Figma Code Connect, without external analytics dispatch, and without claiming live product metrics.
+
+## Result
+
+Final result: passed.
+
+PR handoff: https://github.com/ContextualWisdomLab/naruon/pull/893
+
+## Commercial Pilot Readiness
+
+This package is suitable for a controlled paid-pilot demonstration after the release gates pass. It is not a public-launch readiness claim: hosted deployment, real tenant authorization review, provider-send email, live private-mailbox integration, external analytics governance, billing/legal review, SLA, and support operations remain outside this slice.
+
+## 20B KRW Enterprise Sale Readiness
+
+This package can support a 2,000,000,000 KRW enterprise buyer technical review for the implemented frontend slice after the release gates pass. The basis is repeatable UI proof for `/mail` and `/search`, privacy-safe local product-event contracts, localhost-only smoke execution, accessible source evidence review, bounded local event history, and explicit public-launch caveats.
+
+This is not a claim that Naruon is ready for public SaaS launch or final enterprise procurement. A final 20B KRW sale package still needs production hosting, tenant isolation and authorization evidence, provider-send verification, live mailbox integration, external analytics governance, billing/legal/procurement artifacts, SLA, support, incident response, and data-processing terms.
+
+## Evidence
+
+- Product-event contract: `frontend/src/lib/product-events.ts`
+- Product-event tests: `frontend/src/lib/product-events.test.ts`
+- Mail-detail instrumentation and source drawer integration: `frontend/src/components/EmailDetail.tsx`
+- Source drawer component: `frontend/src/components/SourceDrawer.tsx`
+- Mail-detail interaction tests: `frontend/src/components/EmailDetail.test.tsx`
+- Context-search instrumentation: `frontend/src/components/SearchLayout.tsx`
+- Context-search product event tests: `frontend/src/components/SearchLayout.test.tsx`
+- Follow-up plan: `docs/superpowers/plans/2026-07-02-naruon-design-to-code-and-telemetry.md`
+- Enterprise sale readiness plan: `docs/superpowers/plans/2026-07-02-naruon-20b-enterprise-sale-readiness.md`
+- Design-to-code backlog: `docs/superpowers/reports/2026-07-02-naruon-design-to-code-backlog.md`
+- Event dictionary: `docs/superpowers/reports/2026-07-02-naruon-event-dictionary.md`
+- Figma file: https://www.figma.com/design/68b5XB58w8nwT2LYOOnikK
+- Figma interaction cluster: `Naruon Interaction States / 2026-07-02` (`14:2`)
+- Figma state frames:
+ - `Desktop / Interaction / Source Drawer Open` (`14:3`)
+ - `Desktop / Interaction / Draft Reply Review` (`14:41`)
+ - `Desktop / Interaction / Schedule Confirmation` (`14:78`)
+ - `Desktop / Interaction / Prototype Notes` (`14:118`)
+- Screenshot evidence: `docs/superpowers/artifacts/naruon-figma-package/qa/figma-interaction-states.png`
+- Local browser QA screenshot: `/tmp/naruon-mail-source-drawer.png`
+- Local browser QA screenshot: `/tmp/naruon-search-event-flow.png`
+- Commercial pilot smoke screenshot: `/tmp/naruon-pilot-mail.png`
+- Commercial pilot smoke screenshot: `/tmp/naruon-pilot-search.png`
+
+## Validation
+
+- `search_design_system` was called with `disableCodeConnect=true`; no linked library components, styles, or variables were returned.
+- `use_figma` added visible state frames and returned node IDs.
+- `get_metadata` confirmed the interaction cluster and child frame structure.
+- `get_screenshot` rendered the cluster; local PNG verification reports 2400 x 1252 RGBA.
+- Required product event names appear in code and docs.
+- `EmailDetail.tsx` emits local events for synthesis view, source opening, task creation, calendar reflection, draft generation, draft insertion, draft send, latency, and low-confidence model-quality guardrails.
+- `SearchLayout.tsx` emits local events for search submit, result open, result action creation, and latency guardrails.
+- `SourceDrawer.tsx` provides `role="dialog"`, `aria-modal`, labelled/described content, focus on open, Escape close, mouse close, body scroll lock, and focus restore.
+- `SourceDrawer.tsx` uses per-instance React IDs for `aria-labelledby` and `aria-describedby`, avoiding duplicate ARIA targets when multiple drawers are rendered.
+- `product-events.ts` caps browser-local product-event history to the most recent 200 events and keeps fallback event IDs single-prefixed.
+- `pilot-ui-smoke.mjs` rejects non-localhost `NARUON_PILOT_BASE_URL` values so the smoke flow cannot accidentally execute against staging or production.
+- `pilot-ui-smoke.test.mjs` verifies localhost-only smoke targets, including IPv6 loopback normalization, and rejects staging/production-like hosts.
+- Browser QA on `http://127.0.0.1:3001/mail` with mocked local APIs opened `근거 원본 보기`, verified the source drawer, focus on `근거 원본 닫기`, close button, Escape close, and local `source_chip_opened`/`context_synthesis_viewed` events without raw email body in event payloads.
+- Browser QA on `http://127.0.0.1:3001/search` with mocked local APIs verified search submit, result open, relation capture, `context_search_result_action_created`, and no raw query text in event payloads.
+- Browser QA completed with no console errors or warnings after the local `/api/network/graph` mock returned the expected `{ nodes, edges }` shape.
+- `pnpm --dir frontend pilot:smoke` is the repeatable browser QA gate for the paid-pilot demo path; it exercises mail source evidence, draft generation/send simulation, task creation, calendar intent, search submit, result open, and relationship capture with local mocked APIs.
+- `pnpm --dir frontend pilot:smoke` passed and saved `/tmp/naruon-pilot-mail.png` plus `/tmp/naruon-pilot-search.png` with no console errors or warnings.
+- Commercial pilot screenshots are non-empty 1440 x 1024 PNGs.
+- `pnpm --dir frontend test src/components/EmailDetail.test.tsx src/components/SearchLayout.test.tsx src/lib/product-events.test.ts` passed: 3 test files, 33 tests.
+- `pnpm --dir frontend test` passed: 44 test files, 322 tests.
+- `pnpm --dir frontend typecheck` passed.
+- `pnpm --dir frontend build` passed with an optimized Next 16 production build.
+- `git diff --check` passed.
+- Placeholder and launch-claim scan returned only guarded caveat language about not claiming live KPI values or public-launch readiness.
+
+## Caveats
+
+- No live analytics warehouse, dashboard, or telemetry destination was available.
+- No KPI values in this package are measured product performance.
+- Product events are local-only records plus browser-local `naruon:product-event` custom events; no network emission was added.
+- Browser QA used mocked local API responses because no backend service was running behind the Next `/api/*` proxy.
+- `frontend/node_modules/` was installed by `pnpm` during the test run and is ignored by git.
+- Existing `.Jules/*` modifications were preserved and are unrelated to this follow-up package.
diff --git a/docs/superpowers/reports/2026-07-02-naruon-event-dictionary.md b/docs/superpowers/reports/2026-07-02-naruon-event-dictionary.md
new file mode 100644
index 000000000..aa648efb2
--- /dev/null
+++ b/docs/superpowers/reports/2026-07-02-naruon-event-dictionary.md
@@ -0,0 +1,60 @@
+# Naruon Product Event Dictionary
+
+This event dictionary is an instrumentation contract and local implementation record, not measured product performance. No live warehouse, dashboard, or product analytics export was available in this run. Event owner, destination, retention, and consent policy must be confirmed before these events are dispatched outside local code.
+
+Timezone for initial dashboard cuts: `Asia/Seoul`.
+
+Shared required fields for every event:
+
+- `event_id`
+- `occurred_at`
+- `workspace_id`
+- `actor_user_id`
+- `surface`
+
+Privacy rule:
+
+- Do not send raw email body.
+- Do not send raw draft body.
+- Do not send raw search query text.
+- Use IDs, source types, length buckets, status values, and derived metrics instead.
+
+## Events
+
+| Event | Owner | Trigger | Denominator grain | Entity IDs | Required additional payload | Optional payload | Quality caveat |
+| --- | --- | --- | --- | --- | --- | --- | --- |
+| `context_synthesis_viewed` | frontend | User opens or consumes `맥락 종합` in mail detail. | `workspace_thread` | `thread_id`, `message_id`, `ai_output_id` | `thread_id`, `message_id`, `view_state` | `ai_output_id`, `confidence`, `source_count` | Adoption only until backend synthesis request IDs and AI output IDs are joined. |
+| `decision_point_viewed` | frontend | A visible `판단 포인트` card is rendered. | `ai_output` | `decision_point_id`, `ai_output_id`, `thread_id` | `decision_point_id`, `ai_output_id` | `thread_id`, `priority`, `confidence` | Conversion rates need one stable view-count rule per AI output and session. |
+| `source_chip_opened` | frontend | User opens a source chip, source drawer, or original-message anchor. | `source_chip` | `source_chip_id`, `ai_output_id`, `source_id` | `source_chip_id`, `ai_output_id`, `source_id`, `source_type`, `opened_from` | none | High open rate can mean trust behavior or unclear evidence labels. |
+| `action_item_created` | frontend | User confirms `실행 항목 생성`. | `action_item` | `action_item_id`, `thread_id`, `decision_point_id` | `action_item_id`, `due_date_present`, `source_backlink_present` | `thread_id`, `decision_point_id`, `assignee_type` | Creation volume needs undo/cancel and task-completion follow-through. |
+| `calendar_reflected` | frontend | User confirms `일정 반영`. | `calendar_candidate` | `calendar_event_id`, `calendar_candidate_id`, `thread_id` | `calendar_candidate_id`, `conflict_state`, `provider_write_executed` | `calendar_event_id`, `thread_id` | Provider write and local intent must be separated before counting success. |
+| `draft_reply_generated` | frontend | User requests `답장 초안 생성` and receives a result. | `draft_reply` | `draft_reply_id`, `thread_id`, `message_id` | `draft_reply_id`, `thread_id`, `message_id`, `instruction_present`, `generation_state` | none | Generation is not acceptance; pair with inserted, sent, edited, and discarded events. |
+| `draft_reply_inserted` | frontend | Draft becomes editable content in the reply composer. | `draft_reply` | `draft_reply_id`, `thread_id`, `message_id` | `draft_reply_id`, `thread_id`, `message_id`, `insert_source` | `character_count_bucket` | Do not store body text; edit distance needs a privacy-reviewed derived metric. |
+| `draft_reply_sent` | frontend | User sends or simulates sending the reviewed reply draft. | `draft_reply` | `draft_reply_id`, `thread_id`, `message_id` | `draft_reply_id`, `thread_id`, `message_id`, `send_mode` | `final_review_duration_ms` | Simulated sends must not be counted as provider-delivered replies. |
+| `context_search_submitted` | frontend | User submits `맥락 검색`. | `context_search_session` | `search_session_id` | `search_session_id`, `query_length_bucket`, `filter_count` | `source_filters` | Search volume alone is weak evidence without result-open or action events. |
+| `context_search_result_opened` | frontend | User opens a `맥락 검색` result detail. | `context_search_result` | `search_session_id`, `result_id` | `search_session_id`, `result_id`, `result_type`, `rank_bucket` | `confidence` | Result-open rate needs zero-result and refinement rates. |
+| `context_search_result_action_created` | frontend | Search result leads to reply, task, calendar, project, approval, or policy action. | `context_search_result` | `search_session_id`, `result_id`, `action_id` | `search_session_id`, `result_id`, `action_id`, `action_type`, `source_backlink_present` | none | Comparable only after result ranking and session rules are stable. |
+| `latency_guardrail_recorded` | analytics | Product-critical request or render path records latency. | `request_trace` | `request_trace_id` | `request_trace_id`, `operation`, `duration_ms`, `status` | `model_provider` | P50/P95 thresholds require baseline capture. |
+| `model_quality_guardrail_recorded` | model-quality | Low-confidence, corrected, discarded, hallucination, or source-missing condition is observed. | `guardrail_evaluation` | `ai_output_id`, `guardrail_evaluation_id` | `guardrail_evaluation_id`, `ai_output_id`, `quality_signal`, `human_feedback_present` | `confidence` | Requires evaluator/audit integration before launch-readiness claims. |
+| `trust_safety_guardrail_triggered` | security | Permission denial, external-share warning, policy block, or audit-sensitive action is triggered. | `guardrail_evaluation` | `guardrail_evaluation_id`, `policy_id`, `source_id` | `guardrail_evaluation_id`, `guardrail_type`, `resolution_state` | `policy_id`, `source_type` | False-positive and override reviews are required before interpreting volume as safety improvement. |
+
+## Dashboard Grain Rules
+
+- Context synthesis usage: numerator `context_synthesis_viewed`; denominator selected `workspace_thread` with a loaded mail detail.
+- Decision-to-action conversion: numerator action events after `decision_point_viewed`; denominator `ai_output` or `decision_point` after a product decision.
+- Evidence interaction: numerator `source_chip_opened`; denominator source chips attached to AI outputs.
+- Context search success: numerator `context_search_result_opened` or `context_search_result_action_created`; denominator `context_search_submitted`.
+- Draft reply acceptance: numerator `draft_reply_inserted` and `draft_reply_sent`; denominator `draft_reply_generated`.
+- Calendar/task conversion: numerator `calendar_reflected` and `action_item_created`; denominator schedule candidates and extracted action items.
+
+## Guardrails
+
+- Latency: track P50/P95 by `operation`, device class, workspace, model provider, and status.
+- Model quality: track low-confidence, source-missing, correction, discard, and human feedback signals by AI output.
+- Trust/safety: track policy blocks, permission denials, external-share warnings, overrides, and accepted warnings.
+
+## Implementation Pointer
+
+The code-level contract and local no-op dispatcher live in `frontend/src/lib/product-events.ts`. Current call sites are wired in `frontend/src/components/EmailDetail.tsx` and `frontend/src/components/SearchLayout.tsx`, and the source evidence UI is implemented in `frontend/src/components/SourceDrawer.tsx`.
+
+The dispatcher records sanitized events in memory and emits browser-local `CustomEvent("naruon:product-event")`; it does not send network analytics. External dispatch remains blocked until analytics destination, retention, consent, and warehouse ownership are confirmed.
diff --git a/docs/superpowers/reports/2026-07-02-naruon-kpi-validation.md b/docs/superpowers/reports/2026-07-02-naruon-kpi-validation.md
new file mode 100644
index 000000000..b2de5751d
--- /dev/null
+++ b/docs/superpowers/reports/2026-07-02-naruon-kpi-validation.md
@@ -0,0 +1,96 @@
+# Validation Report
+
+Question being answered: whether the Naruon KPI and measurement framework in `docs/superpowers/specs/2026-07-02-naruon-figma-product-design-analytics-design.md` is accurate, well-supported, and ready to share as a product analytics handoff.
+
+Data sources and as-of date:
+
+- Repo evidence as of checked commit `00e9c15f559349d21fdf53bffc6c487c323dd4be` on `develop`.
+- Design source evidence from `docs/ui-ux/README.md`, `docs/ui-ux/naruon-ui-ux-mapping.md`, mockups, reference sources, and manifests.
+- No live event warehouse, product analytics table, dashboard, or telemetry export was available in this run.
+
+### Overall Assessment: Share with caveats
+
+The KPI framework is suitable for stakeholder discussion and instrumentation planning. It is not suitable for performance claims, target-setting, or launch-readiness decisions until live telemetry exists.
+
+### Methodology Review
+
+The analysis answers the product question implied by the design package: how to measure whether Naruon moves users from fragmented email context to evidence-backed judgment and execution. The metrics map cleanly to the first vertical slice: thread selected, `맥락 종합` consumed, source evidence opened, `판단 포인트` reviewed, and execution action created.
+
+Definitions are directionally sound, but event names and logging destinations are assumptions. The framework avoids causal claims and explicitly marks targets as provisional.
+
+### Issues Found
+
+1. Severity: Medium. Live analytics schema and destination are not confirmed.
+ Evidence: the repo now contains a local event contract and dispatcher in `frontend/src/lib/product-events.ts`, with call sites in `EmailDetail.tsx` and `SearchLayout.tsx`, but no live warehouse, dashboard, or export destination was available for validation.
+ Impact: engineering can validate local UI instrumentation, but dashboards still cannot be launched without confirming owner, retention, consent, warehouse schema, and transport.
+
+2. Severity: Medium. Denominators need product decisions before implementation.
+ Evidence: conversion metrics need stable denominator rules, such as whether a `판단 포인트` view counts once per user, thread, session, or AI output.
+ Impact: adoption and conversion rates can drift or become non-comparable across teams if denominator grain is not locked.
+
+3. Severity: Low. Guardrails are defined but not thresholded.
+ Evidence: latency, model quality, and trust/safety are named, but alert thresholds and acceptable baselines are not known.
+ Impact: the framework can guide instrumentation, but not operational go/no-go decisions yet.
+
+### Calculation Spot-Checks
+
+- Context synthesis usage: Locally instrumented in mail detail; not verified as a live metric because no event table exists.
+- Decision-to-action conversion: Task creation is locally instrumented; no live conversion table exists.
+- Evidence interaction: Source drawer opening is locally instrumented; backend AI output/source lineage still needs durable provenance IDs.
+- Context search success: Search submit, result open, and relation-capture action are locally instrumented; no live session table exists.
+- Draft reply acceptance: Generated, inserted, and sent events are locally instrumented; discard and privacy-reviewed edit-distance metrics are not implemented.
+- Calendar/task conversion: Calendar reflection and task creation are locally instrumented; no provider-success dashboard exists.
+- Model quality: Not verified - evaluator output and correction feedback are not available.
+- Latency: Not verified - frontend timing and backend trace linkage are not available.
+- Trust/safety: Not verified - audit/security event integration is not available.
+
+### Visualization Review
+
+No live dashboard or chart exists. The recommended dashboard cuts in the spec are presentation-ready as a dashboard brief, but not validated as rendered visuals.
+
+### ROI Model And Claim Gate
+
+The ROI model is defined for buyer-pilot measurement planning only. It separates fields that must come from live measured data from commercial assumptions that require buyer/operator approval. The current branch has no live event warehouse, payroll/cost source, contract source, or measured pilot period; therefore `estimated_period_value_krw` must not be presented as a proven value.
+
+Formula:
+
+```text
+estimated_period_value_krw =
+ time_saved_per_user_per_week_hours
+ * fully_loaded_hourly_cost_krw
+ * weekly_active_users
+ * pilot_period_weeks
+ * risk_reduction_adjustment
+```
+
+| Metric | Current value source | Current status | Allowed use |
+| --- | --- | --- | --- |
+| `time_saved_per_user_per_week_hours` | Live pilot task timing, before/after workflow logs, or reviewed buyer baseline | Measured value unavailable in this branch | Assumption only until pilot measurement exists |
+| `fully_loaded_hourly_cost_krw` | Buyer-approved finance or HR cost model | Measured value unavailable in this branch | Assumption only, buyer-owned |
+| `weekly_active_users` | Live product telemetry with workspace/user denominator rules | Measured value unavailable in this branch | Assumption only until telemetry destination is approved |
+| `evidence_open_rate` | `source_chip_opened` over eligible source chips | Event contract exists; live metric unavailable | Quality/diagnostic assumption only |
+| `decision_to_action_conversion_rate` | `action_item_created` after `decision_point_viewed` | Event contract exists; denominator decision pending | Quality/diagnostic assumption only |
+| `pilot_period_weeks` | Signed pilot plan | Measured value unavailable in this branch | Planning input only |
+| `risk_reduction_adjustment` | Buyer/operator risk review of audit, permission, and provider-write incidents | Measured value unavailable in this branch | Assumption only; cannot be used as proof |
+
+Claim gate:
+
+- Allowed: "Naruon has a KPI and ROI measurement framework for a controlled pilot."
+- Allowed: "Live KPI and ROI evidence require a measured pilot with approved telemetry and buyer-owned cost inputs."
+- Rejected: "Naruon has proven a 20B KRW ROI."
+- Rejected: "Naruon is final-procurement ready based on this KPI report."
+- Rejected: "The current branch contains live ROI evidence."
+
+### Suggested Improvements
+
+1. Add an event dictionary with event name, owner, trigger, entity IDs, timestamp timezone, required payload fields, and privacy classification.
+2. Define denominator grain for each rate: user-thread, workspace-thread, AI output, search session, draft, schedule candidate, or action item.
+3. Add guardrail thresholds after 2-4 weeks of baseline capture: P50/P95 latency, error rate, low-confidence rate, source-missing rate, discard rate, and undo rate.
+4. Add dashboard acceptance checks: date range, timezone, workspace filters, device filters, and sample-size suppression for small segments.
+
+### Required Caveats for Stakeholders
+
+- No KPI value in this package is measured product performance.
+- All event names and targets are provisional until telemetry owner and destination are confirmed.
+- Conversion rates must not be compared across teams or periods until denominator grain and timezone rules are locked.
+- Model quality and trust/safety guardrails require evaluator/audit integration before launch-readiness decisions.
diff --git a/docs/superpowers/reports/2026-07-02-naruon-security-governance-followup.md b/docs/superpowers/reports/2026-07-02-naruon-security-governance-followup.md
new file mode 100644
index 000000000..74758afb9
--- /dev/null
+++ b/docs/superpowers/reports/2026-07-02-naruon-security-governance-followup.md
@@ -0,0 +1,68 @@
+# Naruon Security Governance Follow-up
+
+Date: 2026-07-02 KST
+
+## Scope
+
+This follow-up addresses issue #634, `Track post-merge security gate failure on PR #631`, as part of the 20B KRW commercial-readiness work.
+
+The issue tracks a governance failure mode:
+
+- A PR can have blocker evidence, such as `CHANGES_REQUESTED` or failed required-check metadata.
+- The PR governance script can publish a blocker comment.
+- Before this patch, the script still exited `0`, allowing the `metadata-only gate evaluation` check to appear green.
+
+That is not acceptable for enterprise sale readiness because the status check and governance comment can disagree.
+
+## Decision
+
+Patch one central gate instead of each workflow.
+
+The current `.github/workflows/pr-governance.yml` materializes and runs `scripts/ci/pr_governance_gate.sh` from the trusted base ref. The workflow does not need a structural change for this fix. The central script now exits non-zero whenever it posts or updates a blocker comment.
+
+## Implementation
+
+Changed files:
+
+- `scripts/ci/pr_governance_gate.sh`
+- `scripts/ci/test_pr_governance_gate.sh`
+
+Behavior after the patch:
+
+```text
+BLOCKERS present -> post/update marker comment -> exit 1
+WAITING present -> print waiting evidence -> exit 0
+No issues present -> print ready evidence -> exit 0
+```
+
+The waiting state remains green because pending required checks remain separately pending under branch protection. Concrete blocker evidence is now fail-closed.
+
+## Regression Coverage
+
+The shell test now records the gate process exit code and asserts:
+
+- failed required checks exit `1`
+- `STARTUP_FAILURE` exits `1`
+- `CHANGES_REQUESTED` exits `1`
+- current-head CodeRabbit blocking evidence exits `1`
+- pending required checks exit `0`
+- missing or pending CodeRabbit evidence exits `0`
+- passing governance exits `0`
+
+Validation commands:
+
+```text
+bash scripts/ci/test_pr_governance_gate.sh
+test_pr_governance_gate: PASS
+
+cd backend && python3 -m pytest tests/test_release_governance.py -q
+29 passed in 0.21s
+```
+
+## Residual Risk
+
+This patch will govern future PRs after it lands on the trusted base branch. The current pull request's `pull_request_target` governance job still uses the existing trusted base script until this change is merged. Therefore issue #634 should remain open until the fix is merged and a follow-up governance run proves blocker comments produce a failing check on the base branch.
+
+## Sale-readiness Impact
+
+This reduces a P0 governance risk for the 20B KRW package, but it does not complete the full sale-readiness goal. Remaining blockers still include production deployment proof, live provider execution evidence, buyer security/compliance packaging, full responsive design QA, and measured ROI evidence.
diff --git a/docs/superpowers/reports/assets/2026-07-02-naruon-responsive-qa-contact-sheet.png b/docs/superpowers/reports/assets/2026-07-02-naruon-responsive-qa-contact-sheet.png
new file mode 100644
index 000000000..7b031076e
Binary files /dev/null and b/docs/superpowers/reports/assets/2026-07-02-naruon-responsive-qa-contact-sheet.png differ
diff --git a/docs/superpowers/specs/2026-07-02-naruon-20b-full-commercial-readiness-design.md b/docs/superpowers/specs/2026-07-02-naruon-20b-full-commercial-readiness-design.md
new file mode 100644
index 000000000..bdff660bd
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-02-naruon-20b-full-commercial-readiness-design.md
@@ -0,0 +1,195 @@
+# Naruon 20B Full Commercial Readiness Design
+
+## Purpose
+
+This spec defines the standard for treating Naruon as a program that can be submitted for a 2,000,000,000 KRW enterprise sale review. It intentionally raises the bar above the current PR #893 frontend pilot slice.
+
+The target state is not "a useful demo." The target state is a buyer-reviewable product package with working product flows, production deployment evidence, tenant/security proof, analytics definitions, design evidence, support operations, and commercial handoff material.
+
+## Explicit Boundary
+
+Current PR #893 proves a controlled frontend pilot slice for `/mail` and `/search`. It does not prove the whole Naruon program is ready for final enterprise procurement, public launch, or a 2,000,000,000 KRW contract close.
+
+For this spec, Naruon is 20B-commercial-ready only when every gate in this document has current evidence. A delayed review process, stale review decision, or `opencode-review` wait state is not a blocker. A failing CI job, missing production path, missing security proof, missing Figma evidence, or missing buyer handoff artifact is a blocker.
+
+## Plugin Roles
+
+### Figma
+
+Use Figma for source-map repair, product IA boards, reusable component maps, desktop/mobile screen coverage, interaction states, QA screenshots, and buyer-demo review boards.
+
+Rules:
+
+- Do not use Figma Code Connect.
+- Call `search_design_system` with `disableCodeConnect=true`.
+- Treat `docs/ui-ux/mockups/*.png` and `docs/ui-ux/naruon-ui-ux-mapping.md` as the visual source of truth until a newer approved design file is provided.
+- Current Figma file: `https://www.figma.com/design/68b5XB58w8nwT2LYOOnikK`.
+- Current live metadata observation on 2026-07-02 KST: only top-level page `Source Map` is visible through `get_metadata`; design-system search returned no components, variables, or styles. The first Figma task is therefore file-structure repair or a new verified replacement file.
+
+### Product Design
+
+Use Product Design for screenshot-backed flow audits, UX findings, accessibility risks, and design QA. Do not accept opinion-only reviews. Every finding must reference a captured screen, Figma node, or local screenshot.
+
+Required audited buyer flows:
+
+1. Login or signed-session entry.
+2. Home decision dashboard.
+3. Mail thread selection, `맥락 종합`, evidence drawer, `판단 포인트`, `답장 초안`, `실행 항목`, `일정 반영`.
+4. Context search, result detail, relation capture, source backlink.
+5. Calendar coordination and provider-write intent.
+6. Data document store, ingestion, embedding, and quality controls.
+7. AI Hub prompt/workflow execution and run history.
+8. Security access control, audit log, policy posture, and external sharing.
+9. Settings members, connected accounts, notifications, automation, billing, and developer/API areas.
+
+### Superpowers
+
+Use Superpowers to keep this work goal-backed, plan-backed, task-oriented, and verified before any completion claim.
+
+Required Superpowers artifacts:
+
+- Design spec under `docs/superpowers/specs/`.
+- Implementation plan under `docs/superpowers/plans/`.
+- Evidence reports under `docs/superpowers/reports/`.
+- Fresh verification output before claiming completion.
+
+### Ponytail
+
+Use Ponytail as the architecture guardrail. Prefer existing code, existing scripts, current monorepo boundaries, platform primitives, and already-installed dependencies.
+
+Library split decision:
+
+- Do not introduce a new submodule now.
+- Do not split a separate published library now.
+- Keep shared frontend product-event and UI primitives in the existing `frontend/src/lib` and `frontend/src/components/ui` boundaries until there are at least three independent consumers, a separate release cadence, or cross-repo ownership.
+- If extraction becomes necessary, prefer an internal workspace package under `frontend/packages/` before a git submodule. A submodule is acceptable only when the extracted code has independent ownership, independent versioning, and a stable public API.
+
+### Data Analytics
+
+Use Data Analytics for KPI definitions, ROI assumptions, funnel/guardrail design, and evidence caveats. No live KPI value may be claimed without a live source of truth.
+
+Required analytics outputs:
+
+- Event dictionary with privacy-safe payloads.
+- Funnel metrics and guardrails.
+- Pilot success criteria.
+- Buyer ROI model with assumptions separated from measurements.
+- Data-quality and source-of-truth caveats.
+
+## Product Completion Standard
+
+### Gate 1: Buyer-Visible Product Coverage
+
+All ten IA areas from `docs/ui-ux/naruon-ui-ux-mapping.md` must have buyer-reviewable UI coverage:
+
+| Area | Completion requirement |
+| --- | --- |
+| Home | Decision points, pending tasks, recent mail, calendar conflict, and quick execution actions render with real or deterministic signed data. |
+| Mail | Inbox, thread detail, context synthesis, evidence drawer, draft reply, action item, calendar reflection, attachment/source context, and provider-send boundary are visible and testable. |
+| Calendar | Month/week/detail, schedule candidates, conflict state, source mail backlink, and provider-write intent are visible and testable. |
+| Tasks | My tasks, delegated tasks, task detail, status change, assignee change, due date change, and source backlink are visible and testable. |
+| Projects | Project list/detail, milestones, decision log, related mail/document/task links, and source-backed project boundaries are visible and testable. |
+| Context Search | Search, filters, result detail, relation graph/timeline, source detail, and downstream action creation are visible and testable. |
+| Data | Document store, ingestion, embedding, quality checks, WebDAV materialization intent, and raw-content privacy rules are visible and testable. |
+| AI Hub | Prompt studio, workflow canvas, agent/run detail, evaluation, execution history, and logs are visible and testable. |
+| Security | Access control, audit logs, policies, external sharing, permission changes, and security posture evidence are visible and testable. |
+| Settings | Workspace, members, connected accounts, notifications, automation, billing, developer/API keys, and webhook controls are visible and testable. |
+
+### Gate 2: Real Integration Boundaries
+
+The product must clearly separate these execution modes:
+
+- deterministic local pilot mode
+- private mailbox test mode
+- production signed-session mode
+- provider-write intent mode
+- executed provider-write mode
+
+For a 20B commercial package, mock-only success is not enough. Each provider boundary must have either current live evidence or an explicit buyer-facing caveat:
+
+- IMAP/POP import and private mailbox ingestion
+- SMTP or provider send
+- CalDAV/CardDAV read/write
+- WebDAV document materialization
+- LLM provider selection and allowlisted outbound calls
+- embedding generation and search
+- OIDC or signed-session membership proof
+- RBAC/ABAC deny-first authorization
+- audit-event durability
+
+### Gate 3: Security And Compliance
+
+Required evidence:
+
+- Branch protection and required security gates are enforced.
+- Issue #634 is either resolved or explicitly included as an open governance risk.
+- Tenant isolation and workspace authorization are tested.
+- Sequential ids and raw provider data are not exposed in buyer-facing APIs.
+- Raw email body, raw draft body, raw search query, credentials, provider usernames, and raw mailbox paths are not emitted to analytics or logs.
+- Security questionnaire draft exists.
+- Data-processing, retention, audit-log, and incident-response terms exist.
+- Support and escalation runbooks exist.
+
+### Gate 4: Production Operations
+
+Required evidence:
+
+- Production deployment path with rollback instructions.
+- Environment and secret setup guide.
+- Backup and restore procedure.
+- Observability dashboard or documented OpenTelemetry/APM path.
+- Health checks for frontend, backend, database, connector, provider reachability, queue/retry workers, and model provider.
+- Smoke tests for local, private-test, and production-like modes.
+- Incident runbook and SLA draft.
+
+### Gate 5: Figma And Product Design Evidence
+
+Required Figma pages:
+
+1. `Source Map`
+2. `Foundations`
+3. `Components`
+4. `Desktop Screens`
+5. `Mobile Screens`
+6. `Interaction States`
+7. `Sales Demo`
+8. `QA Notes`
+
+Required visual proof:
+
+- Figma metadata shows all required pages.
+- Screenshots exist for each required buyer flow.
+- Product Design audit notes are tied to captured screenshots.
+- QA finds no P0/P1/P2 layout, accessibility, copy, or interaction blockers.
+- Korean UI terminology follows the Naruon vocabulary.
+- No visible placeholder-only frames remain in buyer-demo screens.
+
+### Gate 6: Data Analytics And ROI
+
+Required evidence:
+
+- Event dictionary is implemented or clearly marked as measurement-only.
+- No sensitive raw text leaves the browser or backend without explicit approved policy.
+- Funnel definitions exist for thread selected -> synthesis viewed -> source opened -> decision viewed -> action created.
+- Guardrails exist for latency, model quality, source missing, discard/correction, permission denial, and provider-write failures.
+- ROI model separates measured current data from assumptions.
+- Pilot success criteria define a pass/fail decision a buyer can sign off.
+
+### Gate 7: Commercial Handoff
+
+Required buyer package:
+
+- Product overview.
+- Demo script.
+- Deployment architecture.
+- Security questionnaire responses.
+- Data-processing and retention summary.
+- SLA/support draft.
+- Pilot acceptance criteria.
+- Pricing/contract assumptions.
+- Known caveats and excluded work.
+- Evidence index linking PRs, screenshots, tests, Figma nodes, and reports.
+
+## Completion Rule
+
+Do not mark the 20B full-product goal complete until all gates above have current evidence. Passing PR #893, passing frontend tests, or creating a Figma first slice is useful evidence, but it is not sufficient.
diff --git a/docs/superpowers/specs/2026-07-02-naruon-commercial-pilot-readiness-design.md b/docs/superpowers/specs/2026-07-02-naruon-commercial-pilot-readiness-design.md
new file mode 100644
index 000000000..6a8ef1649
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-02-naruon-commercial-pilot-readiness-design.md
@@ -0,0 +1,141 @@
+# Naruon Commercial Pilot Readiness Design
+
+## Purpose
+
+This spec defines the minimum standard for presenting Naruon as a paid pilot product, not a Figma handoff, telemetry-only demo, or public launch claim.
+
+The target state is **commercial pilot ready**: the core buyer-facing mail and context-search workflows can be demonstrated repeatedly, privacy-sensitive analytics remain local-only, production frontend build passes, and the remaining public-launch gaps are explicit.
+
+For high-value enterprise sales, including a 2,000,000,000 KRW review target, this spec only covers the technical slice that a buyer can inspect. Contract close still requires production deployment, tenant security evidence, legal/procurement artifacts, SLA, and support operations outside this frontend slice.
+
+## Non-Goals
+
+- Do not claim public SaaS launch readiness.
+- Do not claim live KPI performance.
+- Do not add billing, legal/compliance workflow, SLA, hosted deployment, or external analytics export in this slice.
+- Do not send raw email body, raw draft body, or raw search query text to product events.
+- Do not use Figma Code Connect.
+
+## Commercial Pilot Definition
+
+Naruon is commercial-pilot ready when these are true:
+
+1. A stakeholder can open `/mail`, select a message, inspect AI context, open and close source evidence, generate a reply draft, simulate send, create action items, and create calendar writeback intents without visible runtime failure.
+2. A stakeholder can open `/search`, submit a context query, open a result, capture a sender relationship, and see a source backlink without visible runtime failure.
+3. Product events are emitted locally with stable event names and entity lineage, but do not leave the browser or contain raw sensitive text.
+4. The frontend passes typecheck, unit/component tests, production build, diff hygiene, required-event search, and browser interaction QA.
+5. QA evidence is repeatable through a committed smoke script, not only an ad hoc terminal session.
+6. Public-launch caveats are documented in stakeholder-facing language.
+
+## Architecture
+
+The current frontend remains the product anchor. `EmailDetail.tsx` owns mail-detail actions and source evidence interactions. `SearchLayout.tsx` owns context-search actions and lineage. `product-events.ts` owns local event validation, sanitization, in-memory recording, and browser-local `naruon:product-event` dispatch.
+
+Commercial QA is exercised through a local Playwright smoke script that runs against a live Next dev or preview server and intercepts `/api/*` calls with deterministic pilot-safe data. This proves UI behavior without requiring a private mailbox, live backend, or external analytics destination.
+
+The smoke script must reject non-localhost base URLs. It must not navigate to or click through shared staging or production environments.
+
+## Surfaces
+
+### Mail Detail
+
+Required user-visible flow:
+
+- Inbox item renders.
+- Selecting the item opens detail.
+- `맥락 종합` renders with confidence and source affordance.
+- `근거 원본 보기` opens `SourceDrawer`.
+- Drawer has `role="dialog"`, `aria-modal="true"`, a labelled title, initial focus on close, close button, Escape close, and focus restore.
+- `답장 초안 생성` fills the editable reply draft.
+- `답장 보내기` clears the draft and shows simulated-send status.
+- `실행 항목 생성` shows created task status.
+- `일정 반영` shows calendar intent status.
+
+Required local events:
+
+- `context_synthesis_viewed`
+- `source_chip_opened`
+- `draft_reply_generated`
+- `draft_reply_inserted`
+- `draft_reply_sent`
+- `action_item_created`
+- `calendar_reflected`
+- `latency_guardrail_recorded`
+- `model_quality_guardrail_recorded` when confidence is low
+
+### Context Search
+
+Required user-visible flow:
+
+- Default search results render.
+- Submitting a new query updates the selected result.
+- Result detail renders source binding and confidence.
+- `발신자 관계 캡처` creates a relationship card and source backlink.
+
+Required local events:
+
+- `context_search_submitted`
+- `context_search_result_opened`
+- `context_search_result_action_created`
+- `latency_guardrail_recorded`
+
+## Privacy And Analytics
+
+The pilot build may emit only local browser events. External analytics export remains blocked until destination ownership, retention, consent, and warehouse schema are approved.
+
+Forbidden payload content:
+
+- raw email body
+- raw draft body
+- raw search query
+- arbitrary non-contract payload fields
+
+Runtime safety requirements:
+
+- local product-event history must be bounded
+- fallback event IDs must not double-prefix caller-provided event prefixes
+- event recording must remain browser-local unless a separately approved analytics destination, consent, retention, and warehouse contract exist
+
+Allowed payload content:
+
+- stable IDs
+- event names
+- surface names
+- status values
+- source types
+- length/rank buckets
+- booleans such as `source_backlink_present`
+
+## QA Gates
+
+All gates must pass before calling the slice complete:
+
+```bash
+pnpm --dir frontend test
+pnpm --dir frontend typecheck
+pnpm --dir frontend build
+pnpm --dir frontend pilot:smoke
+git diff --check
+rg -n "context_synthesis_viewed|source_chip_opened|action_item_created|calendar_reflected|draft_reply_generated|draft_reply_inserted|draft_reply_sent|context_search_submitted|context_search_result_opened|context_search_result_action_created|latency_guardrail_recorded|model_quality_guardrail_recorded|trust_safety_guardrail_triggered" frontend/src docs/superpowers design-qa.md
+rg -n "TBD|TODO|FIXME|contract only|proposed only|not instrumented|public launch ready|live KPI" docs/superpowers design-qa.md frontend/src
+```
+
+The placeholder scan may return guarded caveat language such as “No live KPI values”; it must not return claims that the product is publicly launch ready.
+
+## Public Launch Caveats
+
+These are not blockers for a paid pilot demo, but they are blockers for public launch:
+
+- Hosted production deployment and rollback process.
+- Real auth/tenant authorization audit.
+- Provider-send email path beyond simulated send.
+- Live backend and private mailbox integration in a controlled environment.
+- External analytics destination, consent, retention, and dashboard governance.
+- Billing and legal/compliance review.
+- SLA, support, incident response, and data processing terms.
+- Procurement/security questionnaire package for enterprise buyers.
+- Commercial terms and acceptance criteria for any 2,000,000,000 KRW transaction.
+
+## Completion Rule
+
+Do not mark this work complete unless production build, automated tests, browser smoke, privacy scan, and PR/merge/keep/discard handoff are all present in current evidence.
diff --git a/docs/superpowers/specs/2026-07-02-naruon-figma-product-design-analytics-design.md b/docs/superpowers/specs/2026-07-02-naruon-figma-product-design-analytics-design.md
new file mode 100644
index 000000000..abfc69492
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-02-naruon-figma-product-design-analytics-design.md
@@ -0,0 +1,310 @@
+# Naruon Figma Product Design Analytics Design Spec
+
+## Summary
+
+This spec defines the execution package for `ContextualWisdomLab/naruon` using Figma, Product Design, Superpowers, Ponytail, and Data Analytics. Figma Code Connect is explicitly out of scope: do not create Code Connect files, do not depend on Code Connect mappings, and do not treat Code Connect as required evidence for this work.
+
+The package turns the existing Naruon UI/UX repository assets into a product-design and measurement handoff: a Figma file structure, design-system screen plan, core user stories, acceptance criteria, KPI definitions, QA checks, and follow-up backlog. The first implementation slice is deliberately small and high-value: Mail detail -> `맥락 종합` -> `판단 포인트` -> `실행 항목` / `일정 반영`.
+
+## Evidence Baseline
+
+- Repository: `ContextualWisdomLab/naruon`
+- Checked branch: `develop`
+- Checked commit: `00e9c15f559349d21fdf53bffc6c487c323dd4be`
+- Live repo description: `AI 이메일 워크스페이스: 메일·첨부·일정·작업을 맥락으로 묶어 판단과 실행으로 연결합니다.`
+- Canonical UI/UX entrypoint: `docs/ui-ux/README.md`
+- Agent text map: `docs/ui-ux/naruon-ui-ux-mapping.md`
+- Canonical mockup set: `docs/ui-ux/mockups/mockup_01.png` through `mockup_41.png` (41 files)
+- Durable reference set: `docs/ui-ux/reference-set-2026-06-18/images/ui-ux-reference-01.png` through `ui-ux-reference-45.png` (45 files)
+- Reference integrity file: `docs/ui-ux/reference-set-2026-06-18/sources.tsv` (45 rows)
+- Asset overview manifest: `docs/ui-ux/asset-overviews-2026-06-21/manifest.tsv` (97 rows)
+- Individual asset manifest: `docs/ui-ux/individual-assets-2026-06-22/manifest.tsv` (945 rows)
+- Product Design saved context: not configured at runtime; use repo sources as the first-pass design context.
+
+The README requires agents to start with `naruon-ui-ux-mapping.md`, then open the referenced original PNG files directly. The mapping file is an index, not a replacement for visual inspection.
+
+## Product Definition
+
+Naruon is an evidence-based AI email workspace. It is not a generic mail client and not a simple summarizer. It connects email, attachments, images, calendars, relationships, tasks, projects, and source evidence so a user can move from fragmented information to judgment and execution.
+
+Design principles:
+
+- Connect context, do not merely shorten email.
+- Present evidence and confidence, not unsupported AI conclusions.
+- Turn judgment into execution through reply, calendar, task, project, approval, or policy actions.
+
+Mandatory UI terminology:
+
+| Avoid | Use | Meaning |
+| --- | --- | --- |
+| AI Summary | `맥락 종합` | Evidence-backed synthesis |
+| Summary | `종합`, `핵심 맥락` | Condensed but sourced context |
+| Insight | `판단 포인트` | Decision point that needs judgment |
+| Todo | `실행 항목` | Action item tied to evidence |
+| Smart Reply | `답장 초안` | Draft reply the user can inspect |
+| Search | `맥락 검색` | Search across connected context |
+| Network Graph | `관계 맥락` | Relationship context |
+| Calendar Sync | `일정 반영` | Reflect schedule into calendar |
+| AI Assistant | `판단 보조` | Judgment assist |
+
+## Information Architecture
+
+Naruon has 10 main GNB areas. Figma and product artifacts must preserve this IA even when the first execution slice only implements a subset.
+
+| Area | Main surfaces | Required actions |
+| --- | --- | --- |
+| 홈 | 판단 포인트, 대기 작업, 일정 충돌, 최근 메일 | 열기, 보류, 실행 항목 만들기, 일정 조율하기 |
+| 메일 | 받은편지함, 메일 상세, 새 메일, 답장 초안, 스레드 전체 | 답장 초안, 맥락 종합, 판단 포인트, 실행 항목 생성, 일정 후보 보기, 첨부 분석 |
+| 일정 | 월간/주간 캘린더, 일정 상세, 회의 조율, 일정 후보 | 새 일정, 회의 조율, 일정 반영, 후보 확정, 관련 메일 열기 |
+| 작업 | 내 작업, 위임한 작업, 칸반, 작업 상세 | 작업 생성, 담당자 변경, 상태 변경, 마감일 변경, 관련 메일 연결 |
+| 프로젝트 | 프로젝트 목록, 프로젝트 상세, 마일스톤, 의사결정 로그 | 새 프로젝트, 프로젝트 열기, 마일스톤 추가, 의사결정 추가, 관련 문서/메일 연결 |
+| 맥락 검색 | 통합 검색, 결과 상세, 관계 그래프, 타임라인 | 검색, 필터 적용, 결과 종합, 그래프 확장, 원본 열기 |
+| 데이터 | 문서 저장소, 수집 파이프라인, 임베딩, 품질 점검 | 업로드, 재파싱, 임베딩 재생성, 품질 점검, 격리 |
+| AI 허브 | 프롬프트 스튜디오, 워크플로우, 에이전트, 평가, 실행 이력 | 테스트 실행, 게시, 워크플로우 실행, 평가 시작, 로그 보기 |
+| 보안 | 보안 대시보드, 접근 권한, 감사 로그, 외부 공유, 정책 | 권한 변경, 차단, 공유 승인/거절, 정책 배포, 보고서 내보내기 |
+| 설정 | 워크스페이스, 멤버, 연결 계정, 알림, 자동화, 결제, 개발자 | 저장, 초대, 계정 연결/해제, 규칙 추가, API 키 생성, Webhook 추가 |
+
+## Figma Deliverable
+
+If the user provides a Figma design URL in a future iteration, use that file's `fileKey`. In this run, no pre-existing Figma URL was provided, so a new Figma design file named `Naruon Product Design System - 2026-07-02` was created after authenticated plan discovery.
+
+Current Figma file:
+
+- URL: https://www.figma.com/design/68b5XB58w8nwT2LYOOnikK
+- File key: `68b5XB58w8nwT2LYOOnikK`
+- Created from the authenticated single Figma plan `Seongho Bae's team` (`team::1408252278989737675`).
+- Search policy used: `search_design_system(..., disableCodeConnect=true)`.
+- Code Connect status: not used for file creation, not used as a dependency, and not used as evidence.
+
+Required Figma pages:
+
+1. `Source Map`
+ - Place or reference the 41 canonical mockups and the 45-image reference set.
+ - Include source paths, SHA-256 references, and semantic aliases.
+ - Highlight the first vertical slice source images: `mockup_19.png`, `mockup_29.png`, `mockup_31.png`, `mockup_36.png`, `mockup_37.png`, `mockup_40.png`, `mockup_41.png`.
+2. `Foundations`
+ - Logo and brand principles from `mockup_27.png` and `mockup_41.png`.
+ - Color, typography, elevation, radii, spacing, icon style, and responsive breakpoints from `mockup_27.png`, `mockup_28.png`, `mockup_29.png`, `mockup_34.png`, `mockup_41.png`.
+3. `Components`
+ - Navigation shell, GNB, side rail, page header, tabs, profile/notification menu.
+ - Buttons, icon buttons, input fields, selects, toggles, segmented controls, chips, badges, cards, tables, drawers, modals, evidence panels, action panels, confidence badges, source chips.
+ - Repeated components must be componentized; avoid flat one-off shapes.
+4. `Desktop Screens`
+ - First slice: Mail detail / thread analysis screen using `mockup_19.png`, `mockup_30.png`, `mockup_31.png`, `mockup_36.png`.
+ - Next screens: Home from `mockup_35.png`, Context Search from `mockup_37.png`, Calendar/Task coordination from `mockup_38.png`, Data from `mockup_23.png`, AI Hub workflow from `mockup_22.png`, Security access control from `mockup_24.png`, Settings members from `mockup_26.png`.
+5. `Mobile Screens`
+ - Mobile login, inbox, mail detail, context synthesis, quick action sheet, profile/settings from `mockup_40.png` and `mockup_32.png`.
+6. `QA Notes`
+ - Figma screenshot checks, source/terminology checks, accessibility notes, missing inputs, and follow-up backlog.
+
+Allowed Figma tools:
+
+- `create_new_file`
+- `use_figma`
+- `get_libraries`
+- `search_design_system`
+- `get_metadata`
+- `get_screenshot`
+- `generate_figma_design` only if a rendered web source exists and a target fileKey is available
+
+Forbidden Figma work:
+
+- Creating Code Connect files
+- Using Code Connect as a required dependency
+- Treating Code Connect results as the source of truth
+- Replacing canonical repo images with unsourced generated visuals
+
+## First Vertical Slice
+
+The first slice proves the core product promise with the smallest useful surface.
+
+Flow:
+
+1. User opens `메일`.
+2. User selects a thread in project/grouped mail.
+3. Naruon shows thread content plus evidence-backed `맥락 종합`.
+4. Naruon extracts `판단 포인트` with confidence and source chips.
+5. User chooses an execution path:
+ - Create `실행 항목`
+ - Generate `답장 초안`
+ - Use `일정 반영`
+ - Open `관계 맥락`
+6. User can inspect cited evidence before accepting an action.
+
+Primary source images:
+
+- `mockup_19.png`: full Mail screen and AI evidence panel
+- `mockup_30.png`: inbox/thread components
+- `mockup_31.png`: AI decision, confidence, source, and action components
+- `mockup_36.png`: applied mail/thread analysis screen
+- `mockup_37.png`: related context search behavior
+- `mockup_40.png`: mobile context synthesis
+- `mockup_41.png`: final brand/component principles
+
+## User Stories And Acceptance Criteria
+
+### Story 1: Evidence-backed thread synthesis
+
+As a workspace user, I want a selected mail thread to show `맥락 종합` with source chips and confidence so I can judge whether the AI result is reliable.
+
+Acceptance criteria:
+
+- The selected thread remains visible while the right panel shows `맥락 종합`.
+- Each synthesis section has at least one visible source chip.
+- Confidence is shown as a badge or meter and is not hidden in tooltip-only UI.
+- The panel offers correction or source-opening affordances.
+- Empty/loading/error states exist for unavailable synthesis.
+
+### Story 2: Decision point extraction
+
+As a user handling project email, I want Naruon to surface `판단 포인트` separately from ordinary summaries so I know what requires a decision.
+
+Acceptance criteria:
+
+- `판단 포인트` is visually distinct from `맥락 종합`.
+- Each decision point has source evidence, owner/role context when available, and a priority/severity signal.
+- A decision point can lead to reply, task, calendar, or project actions.
+- Low-confidence decision points are marked rather than suppressed.
+
+### Story 3: Action item creation
+
+As a user, I want to convert a decision point into an `실행 항목` without losing the source email context.
+
+Acceptance criteria:
+
+- The action item creation entrypoint is visible near the decision point.
+- Created action item includes title, due date or schedule candidate, source thread, assignee, and status.
+- The UI preserves a backlink to the source thread or relation context.
+- Confirmation and failure states are defined.
+
+### Story 4: Calendar reflection
+
+As a user, I want schedule candidates extracted from mail to become calendar actions after review.
+
+Acceptance criteria:
+
+- Candidate times are shown with conflict indicators.
+- The user can inspect related email and attendees before confirming.
+- `일정 반영` is the action label, not `Calendar Sync`.
+- Confirmation creates or updates a calendar item and links back to the source mail.
+
+### Story 5: Context search follow-through
+
+As a user, I want `맥락 검색` to connect query results to email, documents, people, schedules, tasks, projects, and timelines.
+
+Acceptance criteria:
+
+- Results are grouped by source or type with relevance/confidence signals.
+- Selecting a result opens detail plus evidence/action panel.
+- Relation graph or timeline can be opened without losing the selected result.
+- Source filters, date filters, people filters, and attachment filters are visible.
+
+### Story 6: Mobile context synthesis
+
+As a mobile user, I want the same judgment-to-action flow in a compact bottom sheet.
+
+Acceptance criteria:
+
+- Mobile screens keep bottom navigation and quick action patterns from `mockup_40.png`.
+- `맥락 종합`, `판단 포인트`, and `실행 항목` fit without text overlap.
+- Quick actions are reachable with one thumb-friendly surface.
+- Evidence remains inspectable rather than hidden behind final AI conclusions.
+
+## Component Requirements
+
+Core reusable components:
+
+- App shell: top GNB, left rail, local sidebar, primary work area, evidence/action panel.
+- Navigation: breadcrumbs, tabs, segmented controls, mobile bottom tabs.
+- List/table: inbox row, task row, document row, audit log row, member row.
+- Evidence: source chip, confidence badge, source drawer, relation card, timeline item.
+- Judgment: `맥락 종합` card, `판단 포인트` card, risk/issue card, recommended action card.
+- Execution: action item card, reply draft card, schedule candidate card, confirm/cancel actions.
+- Data states: empty, loading skeleton, success, error, offline, low-confidence, permission denied.
+- Admin/security: permission toggle, role badge, audit event detail, policy status.
+
+Design rules:
+
+- Use quiet workbench layouts, not marketing/hero layouts.
+- Use 3-column desktop composition where appropriate.
+- Preserve source evidence and confidence near AI-generated content.
+- Use icon buttons for compact tool actions and text buttons only for clear commands.
+- Keep card radius restrained and consistent with existing mockups.
+- Do not hide primary execution actions below the fold in the first slice.
+
+## Data Analytics Measurement Plan
+
+Live analytics data is not available in the current session. The metrics below are a proposed measurement framework, not measured performance.
+
+| Metric | Type | Definition | Event/source assumption | Guardrail |
+| --- | --- | --- | --- | --- |
+| Context synthesis usage | Primary adoption | Share of selected threads where the user opens or consumes `맥락 종합` | Frontend event: `context_synthesis_viewed`; backend synthesis request logs | Latency and error rate |
+| Decision-to-action conversion | Primary value | Share of `판단 포인트` views that lead to reply, task, calendar, or project action | Events: `decision_point_viewed`, `action_item_created`, `calendar_reflected`, `draft_reply_inserted` | Undo/cancel rate |
+| Evidence interaction | Trust driver | Share of AI outputs where source chips are opened before execution | Event: `source_chip_opened` tied to AI output ID | Over-clicking due unclear evidence |
+| Context search success | Discovery driver | Searches that result in source detail open or downstream action | Events: `context_search_submitted`, `context_search_result_opened`, `context_search_result_action_created` | Zero-result and query-refinement rate |
+| Draft reply acceptance | Execution driver | Draft replies inserted, edited, and sent after AI generation | Events: `draft_reply_generated`, `draft_reply_inserted`, `draft_reply_sent` | Manual edit distance and discard rate |
+| Calendar/task conversion | Execution driver | Schedule candidates or action items confirmed from mail context | Events: `calendar_reflected`, `action_item_created` | Conflict warning override rate |
+| Model quality | Quality guardrail | Human correction, low-confidence rate, failed evidence binding | Feedback events plus backend evaluator output | Hallucination/source-missing rate |
+| Latency | Experience guardrail | P50/P95 time from thread selection to synthesis ready | Frontend timing plus backend trace | Abandonment while loading |
+| Trust/safety | Safety guardrail | Permission denials, external-share warnings, policy blocks, audit events | Security/audit logs | False-positive workflow blocks |
+
+Recommended dashboard cuts:
+
+- By workspace, mail source, project, model/provider, language, device class, and feature surface.
+- Daily adoption trend, weekly conversion trend, and P95 latency.
+- Funnel: thread selected -> synthesis viewed -> source opened -> decision point viewed -> action created.
+- Quality: low-confidence rate, correction rate, discarded draft rate, source-missing rate.
+
+Instrumentation gaps:
+
+- Event naming and analytics destination are not confirmed in repo evidence.
+- No live data warehouse or product analytics source is available in the current run.
+- KPI targets should remain provisional until baseline usage is captured.
+
+## QA And Completion Criteria
+
+Figma/package QA must verify:
+
+- `Source Map`, `Foundations`, `Components`, `Desktop Screens`, `Mobile Screens`, and `QA Notes` are present in the Figma plan or file.
+- The first vertical slice includes Mail detail, context synthesis, decision point, and at least one execution action.
+- `맥락 종합`, `판단 포인트`, `실행 항목`, `답장 초안`, `맥락 검색`, `관계 맥락`, `일정 반영`, and `판단 보조` are used correctly.
+- Figma screenshots show no clipped text, incoherent overlap, blank image placeholders, wrong fonts, or leftover placeholders.
+- Source images and repo paths are referenced in the Figma/source map.
+- Figma Code Connect is not used.
+- KPI definitions label live-data gaps and do not claim measured performance.
+
+Current QA evidence:
+
+- Figma Source Map page: `0:1`
+- Figma Desktop page: `3:4`
+- Figma Mobile page: `3:5`
+- First desktop frame: `3:157` (`Desktop / Mail Detail / Evidence Review`)
+- First mobile frame: `3:273` (`Mobile / Context Synthesis Bottom Sheet`)
+- Expansion roadmap frame: `11:17` (`Desktop / Expansion Roadmap`)
+- Added component frames: `Component / Table Row`, `Component / Source Drawer`
+- Uploaded source mockup frames: `3:315` through `3:321` (`mockup_19`, `mockup_30`, `mockup_31`, `mockup_36`, `mockup_37`, `mockup_40`, `mockup_41`)
+- Figma screenshots:
+ - `docs/superpowers/artifacts/naruon-figma-package/qa/figma-desktop-mail-detail.png`
+ - `docs/superpowers/artifacts/naruon-figma-package/qa/figma-mobile-context-sheet.png`
+- Source comparison boards:
+ - `docs/superpowers/artifacts/naruon-figma-package/qa/compare-desktop-mockup36-vs-figma.png`
+ - `docs/superpowers/artifacts/naruon-figma-package/qa/compare-mobile-mockup40-vs-figma.png`
+- Product Design QA report: `design-qa.md`
+- Data Analytics validation report: `docs/superpowers/reports/2026-07-02-naruon-kpi-validation.md`
+
+## Follow-up Backlog
+
+1. Expand the first Figma slice into full-density production screens for Home, Calendar, Data, AI Hub, Security, and Settings.
+2. Replace the local minimal components with the team's official Naruon component library if a newer source file is provided.
+3. Wire a product analytics event dictionary in the repo once the tracking destination is chosen.
+4. Add design-to-code tasks for navigation shell, evidence cards, confidence/source chips, and execution action cards.
+5. Add interaction prototypes for source-chip opening, evidence drawer, draft reply review, and schedule confirmation.
+
+## Explicit Assumptions
+
+- The first useful slice is Mail + evidence + execution because it best proves Naruon's value.
+- Existing repo mockups are authoritative until the user provides a newer Figma source.
+- Product Design saved context is absent, so this package uses repo sources as current context.
+- Figma file creation is complete for this run through the authenticated single Figma plan.
+- The current Figma screens are an execution-ready first vertical slice, not a pixel-perfect clone of every existing mockup.
+- No live analytics source is available, so Data Analytics outputs are definitions and measurement plans.
diff --git a/frontend/package.json b/frontend/package.json
index 5cbfea8f3..bfbc1a73d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,6 +10,8 @@
"lint": "eslint",
"test": "vitest run",
"typecheck": "tsc --noEmit",
+ "full:smoke": "node scripts/full-product-ui-smoke.mjs",
+ "pilot:smoke": "node scripts/pilot-ui-smoke.mjs",
"test:e2e": "playwright test"
},
"dependencies": {
diff --git a/frontend/scripts/full-product-ui-smoke.mjs b/frontend/scripts/full-product-ui-smoke.mjs
new file mode 100644
index 000000000..714f87b8a
--- /dev/null
+++ b/frontend/scripts/full-product-ui-smoke.mjs
@@ -0,0 +1,1524 @@
+import { spawn } from "node:child_process";
+import { mkdir, access } from "node:fs/promises";
+import net from "node:net";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+import { chromium } from "@playwright/test";
+
+const scriptDir = path.dirname(fileURLToPath(import.meta.url));
+const frontendDir = path.resolve(scriptDir, "..");
+const baseUrl = process.env.NARUON_FULL_PRODUCT_BASE_URL || "http://127.0.0.1:3001";
+const screenshotDir = process.env.NARUON_FULL_PRODUCT_SCREENSHOT_DIR || "/tmp/naruon-full-product-smoke";
+const ALLOWED_FULL_PRODUCT_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
+const SERVER_PROBE_TIMEOUT_MS = 5_000;
+const SERVER_READY_TIMEOUT_MS = 90_000;
+const IS_WINDOWS = process.platform === "win32";
+
+export const FULL_PRODUCT_ROUTES = [
+ { path: "/", name: "home", expectedText: "Naruon" },
+ { path: "/mail", name: "mail", expectedText: "메일" },
+ { path: "/search", name: "search", expectedText: "맥락 검색" },
+ { path: "/calendar", name: "calendar", expectedText: "일정" },
+ { path: "/tasks", name: "tasks", expectedText: "작업" },
+ { path: "/projects", name: "projects", expectedText: "프로젝트" },
+ { path: "/data", name: "data", expectedText: "데이터" },
+ { path: "/ai-hub", name: "ai-hub", expectedText: "AI 허브" },
+ { path: "/security", name: "security", expectedText: "보안" },
+ { path: "/settings", name: "settings", expectedText: "설정" },
+];
+
+export const FULL_PRODUCT_VIEWPORTS = [
+ { name: "desktop", width: 1440, height: 1024 },
+ { name: "mobile", width: 390, height: 844, isMobile: true },
+];
+
+export const FULL_PRODUCT_CRITICAL_INTERACTION_ROUTE_NAMES = [
+ "mail",
+ "search",
+ "calendar",
+ "tasks",
+ "projects",
+ "data",
+ "ai-hub",
+ "security",
+ "settings",
+];
+export const FULL_PRODUCT_CRITICAL_INTERACTION_VIEWPORT_NAMES = ["desktop", "mobile"];
+export const FULL_PRODUCT_DESKTOP_INTERACTION_ROUTE_NAMES = FULL_PRODUCT_CRITICAL_INTERACTION_ROUTE_NAMES;
+export const FULL_PRODUCT_ACCESSIBILITY_CHECK_NAMES = [
+ "visible-duplicate-id",
+ "visible-interactive-accessible-name",
+ "keyboard-tab-focus-entry",
+];
+
+export function resolveFullProductBaseUrl(rawBaseUrl) {
+ const fullProductBaseUrl = new URL(rawBaseUrl);
+ if (!ALLOWED_FULL_PRODUCT_HOSTS.has(fullProductBaseUrl.hostname)) {
+ throw new Error(`Full product smoke must run only against localhost targets, got: ${fullProductBaseUrl.hostname}`);
+ }
+ return fullProductBaseUrl;
+}
+
+resolveFullProductBaseUrl(baseUrl);
+
+export function resolveFullProductViewportSpecs(rawViewports = "desktop") {
+ const viewportByName = new Map(FULL_PRODUCT_VIEWPORTS.map((viewport) => [viewport.name, viewport]));
+ const requestedNames = rawViewports
+ .split(",")
+ .map((name) => name.trim())
+ .filter(Boolean);
+ const expandedNames = requestedNames.flatMap((name) => {
+ if (name === "all") return FULL_PRODUCT_VIEWPORTS.map((viewport) => viewport.name);
+ return [name];
+ });
+ if (expandedNames.length === 0) {
+ throw new Error("At least one full-product viewport is required");
+ }
+
+ const seen = new Set();
+ return expandedNames.map((name) => {
+ const viewport = viewportByName.get(name);
+ if (!viewport) {
+ throw new Error(`Unknown full-product viewport '${name}'. Expected one of: ${FULL_PRODUCT_VIEWPORTS.map((item) => item.name).join(", ")}`);
+ }
+ if (seen.has(name)) {
+ throw new Error(`Duplicate full-product viewport '${name}'`);
+ }
+ seen.add(name);
+ return viewport;
+ });
+}
+
+export function fullProductScreenshotName(routeSpec, viewportSpec, viewportCount = 1) {
+ if (viewportCount === 1 && viewportSpec.name === "desktop") return `${routeSpec.name}.png`;
+ return `${viewportSpec.name}-${routeSpec.name}.png`;
+}
+
+function log(message) {
+ process.stdout.write(`${message}\n`);
+}
+
+async function isServerReady(url) {
+ try {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), SERVER_PROBE_TIMEOUT_MS);
+ const response = await fetch(url, { signal: controller.signal });
+ clearTimeout(timeout);
+ return response.status < 500;
+ } catch {
+ return false;
+ }
+}
+
+export async function isTcpPortOpen(url) {
+ const host = url.hostname.replace(/^\[|\]$/g, "");
+ const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
+ if (!Number.isInteger(port) || port <= 0) return false;
+
+ return new Promise((resolve) => {
+ const socket = net.createConnection({ host, port });
+ const done = (result) => {
+ socket.removeAllListeners();
+ socket.destroy();
+ resolve(result);
+ };
+
+ socket.setTimeout(1000);
+ socket.once("connect", () => done(true));
+ socket.once("timeout", () => done(false));
+ socket.once("error", () => done(false));
+ });
+}
+
+async function waitForServer(url, child) {
+ const startedAt = Date.now();
+ while (Date.now() - startedAt < SERVER_READY_TIMEOUT_MS) {
+ if (child?.exitCode !== null && child?.exitCode !== undefined) {
+ throw new Error(`Next dev server exited before becoming ready with code ${child.exitCode}`);
+ }
+ if (await isServerReady(url)) return;
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ throw new Error(`Timed out waiting for ${url}`);
+}
+
+async function startServerIfNeeded() {
+ if (await isServerReady(baseUrl)) return null;
+
+ const url = new URL(baseUrl);
+ if (url.hostname !== "127.0.0.1" && url.hostname !== "localhost") {
+ throw new Error(`Server is not reachable and cannot be auto-started for non-local URL: ${baseUrl}`);
+ }
+
+ if (await isTcpPortOpen(url)) {
+ await waitForServer(baseUrl, null);
+ return null;
+ }
+
+ const child = spawn(
+ IS_WINDOWS ? process.env.ComSpec || "cmd.exe" : "pnpm",
+ IS_WINDOWS
+ ? ["/d", "/s", "/c", "pnpm", "dev", "--hostname", url.hostname, "--port", url.port || "3001"]
+ : ["dev", "--hostname", url.hostname, "--port", url.port || "3001"],
+ {
+ cwd: frontendDir,
+ env: { ...process.env, NEXT_TELEMETRY_DISABLED: "1" },
+ stdio: ["ignore", "pipe", "pipe"],
+ detached: !IS_WINDOWS,
+ },
+ );
+ child.stdout.on("data", (chunk) => process.stdout.write(chunk));
+ child.stderr.on("data", (chunk) => process.stderr.write(chunk));
+ try {
+ await waitForServer(baseUrl, child);
+ return child;
+ } catch (error) {
+ await stopServerProcess(child);
+ throw error;
+ }
+}
+
+async function stopServerProcess(child) {
+ if (!child || child.exitCode !== null) return;
+
+ const waitForExit = new Promise((resolve) => {
+ child.once("exit", resolve);
+ });
+ const timeout = (ms) => new Promise((resolve) => setTimeout(resolve, ms, "timeout"));
+
+ if (IS_WINDOWS) {
+ await Promise.race([
+ new Promise((resolve) => spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }).once("close", resolve)),
+ timeout(5_000),
+ ]);
+ await Promise.race([waitForExit, timeout(2_000)]);
+ return;
+ }
+
+ try {
+ process.kill(-child.pid, "SIGTERM");
+ } catch {
+ child.kill("SIGTERM");
+ }
+
+ if ((await Promise.race([waitForExit, timeout(5_000)])) !== "timeout") return;
+
+ try {
+ process.kill(-child.pid, "SIGKILL");
+ } catch {
+ child.kill("SIGKILL");
+ }
+ await Promise.race([waitForExit, timeout(2_000)]);
+}
+
+async function launchBrowser() {
+ try {
+ return await chromium.launch({ headless: true });
+ } catch (error) {
+ const fallbackPath =
+ process.env.PLAYWRIGHT_CHROME_PATH ||
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
+ await access(fallbackPath);
+ log(`Using system Chrome fallback because bundled Playwright browser is unavailable: ${error.message.split("\n")[0]}`);
+ return chromium.launch({ headless: true, executablePath: fallbackPath });
+ }
+}
+
+const sourceEmail = {
+ id: 23,
+ message_id: "",
+ thread_id: "full-product-thread",
+ sender: "pm@example.com",
+ recipients: "user@example.com",
+ subject: "20B smoke source",
+ date: "2026-05-19T09:00:00Z",
+ snippet: "20억 판매 검토용 맥락 종합입니다.",
+ body: "Private body stays in route mock only.",
+ reply_count: 1,
+};
+
+const task = {
+ id: "task-public-20b",
+ title: "20억 판매 검토 실행 항목",
+ status: "open",
+ priority: "high",
+ source_type: "email",
+ source_email_id: String(sourceEmail.id),
+ related_thread_id: sourceEmail.thread_id,
+ updated_at: "2026-07-02T05:00:00Z",
+};
+
+const knowledgeTask = {
+ id: "task-knowledge-20b",
+ title: "나에게 보낸 지식 메모 정리",
+ status: "open",
+ priority: "normal",
+ source_type: "self_sent_knowledge",
+ source_email_id: String(sourceEmail.id),
+ related_thread_id: sourceEmail.thread_id,
+ updated_at: "2026-07-02T05:10:00Z",
+};
+
+const webdavTask = {
+ id: "task-webdav-evidence-20b",
+ title: "첨부파일 WebDAV 폴더 정리",
+ status: "in_progress",
+ priority: "normal",
+ source_type: "webdav",
+ source_email_id: String(sourceEmail.id),
+ related_thread_id: sourceEmail.thread_id,
+ updated_at: "2026-07-02T05:20:00Z",
+};
+
+const calendarWritebackSource = {
+ source_id: "calendar-source-20b",
+ provider: "caldav",
+ protocol: "caldav",
+ owner_id: "smoke-user",
+ organization_id: "org-acme",
+ capabilities: ["read", "write", "etag"],
+ writeback_enabled: true,
+ etag: "calendar-etag-20b",
+};
+
+const projectFolder = {
+ folder_uid: "project-20b",
+ project_name: "Naruon 20B Readiness",
+ webdav_path: "/Projects/Naruon_20B_Readiness",
+ owner_user_id: "smoke-user",
+ organization_id: "org-acme",
+};
+
+const webdavAccount = {
+ source_id: "webdav-source-20b",
+ display_label: "20B readiness WebDAV",
+ provider: "webdav",
+ protocol: "webdav",
+ owner_id: "smoke-user",
+ organization_id: "org-acme",
+ webdav_path: "/Projects/Naruon_20B_Readiness",
+ capabilities: ["read", "write", "etag"],
+ writeback_enabled: true,
+ etag: "webdav-etag-20b",
+};
+
+const aiHubSurface = {
+ summary_cards: [
+ {
+ summary_key: "prompt_templates",
+ label_text: "프롬프트",
+ value_text: "2",
+ detail_text: "원본 근거 템플릿",
+ state_code: "ready",
+ },
+ {
+ summary_key: "ai_providers",
+ label_text: "판단 보조",
+ value_text: "1/1",
+ detail_text: "활성 조직 모델 연결",
+ state_code: "ready",
+ },
+ ],
+ prompt_cards: [
+ {
+ prompt_key: "prompt_safe",
+ prompt_title: "의사결정 로그 맥락 종합",
+ description_text: "메일에서 판단 포인트를 추출합니다.",
+ shared_scope: false,
+ owner_label: "alice",
+ updated_at: "2026-05-29T09:30:00Z",
+ },
+ ],
+ workflow_cards: [
+ {
+ workflow_key: "workflow_prompt_safe",
+ workflow_title: "의사결정 로그 자동 작성",
+ trigger_source: "workflow_definition",
+ state_code: "ready",
+ evidence_text: "2 persisted workflow steps",
+ },
+ ],
+ agent_cards: [
+ {
+ agent_key: "agent_primary",
+ agent_title: "Primary OpenAI",
+ model_label: "openai",
+ state_code: "active",
+ configured: true,
+ governance_text: "조직 LLM 모델 연결 registry",
+ },
+ ],
+ evaluation_metrics: [
+ {
+ metric_key: "provider_readiness",
+ metric_label: "Provider 준비도",
+ score_value: 100,
+ trend_text: "활성 모델 연결 1/1",
+ },
+ ],
+ run_events: [
+ {
+ event_key: "agent_run_prompt_safe",
+ event_title: "워크플로우 실행",
+ state_code: "completed",
+ evidence_source: "agent_run_records",
+ observed_at: "2026-05-29T09:30:00Z",
+ detail_text: "3개 판단 포인트를 추출했습니다.",
+ },
+ ],
+};
+
+const securitySurface = {
+ scope_kind: "organization",
+ viewer: {
+ role: "tenant_admin",
+ scope_kind: "organization",
+ },
+ sources: [
+ {
+ source_type: "webdav_repository",
+ source_label: "WebDAV repository",
+ scope_kind: "organization",
+ capabilities: ["read", "write", "etag"],
+ writeback_enabled: true,
+ last_observed_at: "2026-05-28T04:00:00Z",
+ policy_decision: {
+ resource_label: "WebDAV repository",
+ resource_type: "webdav_repository",
+ allowed: true,
+ reason: "allowed",
+ evidence_label: "webdav_source_evidence",
+ },
+ },
+ ],
+ connector_events: [
+ {
+ state_code: "heartbeat",
+ evidence_label: "connector_observation_evidence",
+ observed_at: "2026-05-28T04:00:00Z",
+ },
+ ],
+ durable_audit_events: [
+ {
+ actor_role: "tenant_admin",
+ scope_kind: "organization",
+ event_action: "update",
+ resource_type: "llm_provider",
+ evidence_label: "server_audit_evidence",
+ observed_at: "2026-05-28T04:02:00Z",
+ },
+ ],
+ policy_decisions: [
+ {
+ resource_label: "WebDAV repository",
+ resource_type: "webdav_repository",
+ allowed: true,
+ reason: "allowed",
+ evidence_label: "webdav_source_evidence",
+ },
+ {
+ resource_label: "Cross-organization provider secret",
+ resource_type: "provider_secret",
+ allowed: false,
+ reason: "organization_denied",
+ evidence_label: "policy_engine_evidence",
+ },
+ ],
+ external_share_reviews: [
+ {
+ source_type: "webdav_repository",
+ review_label: "WebDAV repository writeback boundary",
+ exposure_level: "external_writeback",
+ decision_reason: "allowed",
+ },
+ ],
+ policy_order: [
+ {
+ display_name: "Signed session identity",
+ evidence_label: "signed_session_evidence",
+ },
+ {
+ display_name: "RBAC allow after ABAC denies",
+ evidence_label: "policy_engine_evidence",
+ },
+ ],
+};
+
+const dataQualitySurface = {
+ workspace_id: "workspace-org-acme",
+ organization_id: "org-acme",
+ audit_event: "data.quality_surface.viewed",
+ provider_write_executed: false,
+ repositories: [
+ {
+ source_id: "document_repository",
+ repository_type: "workspace_document",
+ display_name: "20B readiness documents",
+ object_count: 2,
+ writeback_enabled: null,
+ evidence_source: "documents",
+ provider_write_executed: false,
+ },
+ ],
+ repository_assets: [
+ {
+ asset_key: "doc_repository_ready",
+ asset_type: "workspace_document",
+ display_name: "20b-readiness.md",
+ source_label: "Workspace document",
+ state_code: "ready",
+ detail_text: "document status: uploaded",
+ content_chars: 128,
+ captured_at: "2026-05-28T05:46:00Z",
+ evidence_source: "documents.document_status",
+ thread_key: "workspace_document",
+ provider_write_executed: false,
+ },
+ ],
+ pipeline_stages: [
+ {
+ stage_key: "source_registry",
+ display_name: "Source registry",
+ status_code: "ready",
+ progress_percent: 100,
+ evidence_source: "webdav_accounts, project_folders",
+ detail_text: "2 customer-owned sources are in scope.",
+ provider_write_executed: false,
+ },
+ ],
+ embedding_collections: [
+ {
+ collection_key: "emails_embedding",
+ display_name: "Email vectors",
+ object_count: 4,
+ embedded_count: 3,
+ embedding_model: "text-embedding-3-small",
+ vector_dimensions: 1536,
+ status_code: "running",
+ evidence_source: "emails.embedding",
+ provider_write_executed: false,
+ },
+ ],
+ quality_checks: [
+ {
+ check_key: "thread_id_integrity",
+ display_name: "Thread id integrity",
+ status_code: "ready",
+ issue_count: 0,
+ total_count: 4,
+ evidence_source: "emails.thread_id",
+ detail_text: "Scoped emails have canonical thread ids.",
+ provider_write_executed: false,
+ },
+ ],
+ connector_events: [
+ {
+ event_uid: "connector_evt_data_quality",
+ signal_key: "connector_heartbeat",
+ state_code: "heartbeat",
+ detail_text: "outbound connector heartbeat received",
+ observed_at: "2026-05-28T05:45:00Z",
+ },
+ ],
+};
+
+const dataEvidenceSnapshot = {
+ snapshot_version: "data_quality_evidence_snapshot.v1",
+ generated_at: "2026-05-28T05:47:00Z",
+ audit_event: "data.quality_surface.evidence_snapshot.viewed",
+ scope_label: "full_product_smoke",
+ snapshot_digest: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ digest_algorithm: "sha256",
+ canonical_payload_fields: [],
+ privacy_redaction_policy: {
+ raw_content_exposed: false,
+ stable_identifiers_exposed: false,
+ provider_credentials_exposed: false,
+ redacted_fields: [],
+ allowed_sample_fields: [],
+ },
+ validation_status: {
+ status_code: "ready",
+ display_name: "Ready",
+ detail_text: "Smoke evidence snapshot is redacted and verifier-ready.",
+ provider_write_executed: false,
+ },
+ verification_handoff: {
+ handoff_text: "Verify the copied smoke snapshot JSON before sharing diligence materials.",
+ verifier_command: "python scripts/verify_evidence_snapshot.py snapshot.json",
+ accepted_input: "data_quality_evidence_snapshot.v1 JSON",
+ digest_algorithm: "sha256",
+ excluded_digest_fields: ["snapshot_digest"],
+ success_exit_code: 0,
+ failure_exit_codes: { invalid_digest: 2 },
+ provider_write_executed: false,
+ },
+ parser_manifest_summary: [],
+ content_graph_evidence_samples: [],
+ knowledge_graph_evidence_samples: [],
+ evidence_packet_checklist: [],
+ data_room_package_manifest: [],
+ diligence_exception_register: [],
+ diligence_risk_matrix: [],
+ diligence_close_artifact_review_queue: [],
+ diligence_close_owner_handoff_queue: [],
+ diligence_close_traceability_map: [],
+ diligence_close_decision_summary: null,
+ diligence_close_proof_plan: [],
+};
+
+const accountConfig = {
+ user_id: "smoke-user",
+ smtp_server: null,
+ smtp_port: null,
+ smtp_username: null,
+ has_smtp_password: false,
+ imap_server: null,
+ imap_port: null,
+ imap_username: null,
+ has_imap_password: false,
+ pop3_server: null,
+ pop3_port: null,
+ pop3_username: null,
+ has_pop3_password: false,
+ oauth_client_id: null,
+ oauth_redirect_uri: null,
+ has_oauth_client_secret: false,
+};
+
+const llmProvider = {
+ id: 1,
+ name: "Primary OpenAI",
+ provider_type: "openai",
+ base_url: "https://api.openai.com/v1",
+ model_identifier: "gpt-5.4",
+ embedding_model: "text-embedding-3-small",
+ is_active: true,
+ configured: true,
+ fingerprint: "***1234",
+ updated_at: "2026-06-11T03:00:00Z",
+};
+
+const runnerConfig = {
+ workspace_id: "workspace-org-acme",
+ configured: true,
+ fingerprint: "runner-smoke",
+ updated_at: "2026-05-28T05:45:00Z",
+ connector_manifest: {
+ role: "outbound_connector",
+ network_mode: "outbound_only",
+ control_plane_domain: "naruon.net",
+ local_protocols: ["imap", "smtp", "caldav", "webdav"],
+ prohibited_roles: ["mailbox_host", "mx_server"],
+ runner_usage: "customer_owned_connector",
+ },
+};
+
+const operationalSignals = {
+ workspace_id: "workspace-org-acme",
+ audit_event: "observability.operational_signals.viewed",
+ telemetry: {
+ prometheus_metrics_enabled: true,
+ otel_traces_enabled: true,
+ otel_endpoint_configured: true,
+ otel_endpoint_host: "otel.example.com",
+ },
+ connector: {
+ workspace_id: "workspace-org-acme",
+ registration_state: "registration_configured",
+ connection_state: "connected",
+ active_connection_count: 1,
+ control_plane_domain: "naruon.net",
+ network_mode: "outbound_only",
+ runner_usage: "customer_owned_connector",
+ local_protocols: ["imap", "smtp", "caldav", "webdav"],
+ last_heartbeat_at: "2026-05-28T05:45:00Z",
+ last_disconnect_at: null,
+ queue_depth_state: "clear",
+ queue_depth: {
+ pending_count: 0,
+ running_count: 0,
+ failed_count: 0,
+ total_count: 0,
+ next_retry_at: null,
+ },
+ recent_events: [],
+ },
+ signals: [
+ {
+ signal_key: "frontend",
+ display_name: "Frontend health",
+ state: "ready",
+ evidence_source: "full-product-smoke",
+ detail: "Route rendered",
+ provider_write_executed: false,
+ },
+ ],
+};
+
+function routeJson(route, body, status = 200) {
+ return route.fulfill({
+ status,
+ contentType: "application/json",
+ body: JSON.stringify(body),
+ });
+}
+
+async function installRoutes(page) {
+ let emailSendCount = 0;
+ let savedAccountConfig = { ...accountConfig };
+ let savedLlmProviders = [{ ...llmProvider }];
+
+ await page.route("**/auth/session", (route) => routeJson(route, {
+ claims: {
+ userId: "smoke-user",
+ organizationId: "org-acme",
+ workspaceId: "workspace-org-acme",
+ },
+ }));
+ await page.route("**/api/**", async (route) => {
+ const request = route.request();
+ const endpoint = new URL(request.url()).pathname;
+
+ if (endpoint === "/api/emails") return routeJson(route, { emails: [sourceEmail] });
+ if (endpoint === "/api/emails/pending-replies") return routeJson(route, { emails: [sourceEmail] });
+ if (endpoint === "/api/emails/23") return routeJson(route, sourceEmail);
+ if (endpoint === "/api/emails/thread/full-product-thread") return routeJson(route, { thread: [sourceEmail] });
+ if (endpoint === "/api/search") {
+ return routeJson(route, {
+ results: [
+ {
+ id: 202,
+ source_message_id: "",
+ subject: "20B readiness result",
+ sender: "pm@example.com",
+ date: "2026-05-20T09:00:00Z",
+ snippet: "맥락 검색 결과에서 관계 캡처 액션을 실행할 수 있습니다.",
+ thread_id: "full-product-thread",
+ reply_count: 1,
+ score: 0.93,
+ },
+ ],
+ });
+ }
+ if (endpoint === "/api/llm/summarize") return routeJson(route, { summary: "20억 판매 검토용 맥락 종합입니다.", todos: ["근거 확인"], confidence: 0.86 });
+ if (endpoint === "/api/llm/draft") return routeJson(route, { draft: "검토 가능한 답장 초안입니다." });
+ if (endpoint === "/api/llm/translate") return routeJson(route, { translation: "번역된 맥락입니다." });
+ if (endpoint === "/api/emails/send") {
+ emailSendCount += 1;
+ return routeJson(route, { simulated: emailSendCount === 1 });
+ }
+ if (endpoint === "/api/tasks") return routeJson(route, [task, knowledgeTask, webdavTask]);
+ if (endpoint === "/api/tasks/from-email") return routeJson(route, { created: 1 });
+ if (endpoint === "/api/tasks/reply-sla-escalations") {
+ return routeJson(route, {
+ evaluated: 1,
+ created: 1,
+ policy: { overdue_hours: 48 },
+ tasks: [task, knowledgeTask, webdavTask],
+ });
+ }
+ if (endpoint.startsWith("/api/tasks/")) {
+ const targetTask = [task, knowledgeTask, webdavTask].find((candidate) => endpoint.includes(candidate.id)) ?? task;
+ return routeJson(route, { ...targetTask, status: "done" });
+ }
+ if (endpoint === "/api/calendar/writeback-sources") return routeJson(route, [calendarWritebackSource]);
+ if (endpoint === "/api/calendar/writeback-intent") {
+ let requestBody = {};
+ try {
+ requestBody = request.postDataJSON();
+ } catch {
+ requestBody = {};
+ }
+ const executeProvider = requestBody.execute_provider === true;
+ return routeJson(route, {
+ workspace_id: "workspace-org-acme",
+ target_source_id: calendarWritebackSource.source_id,
+ protocol: calendarWritebackSource.protocol,
+ writeback_mode: "customer_owned",
+ requires_if_match: true,
+ if_match: calendarWritebackSource.etag,
+ provenance: { source: "full-product-smoke" },
+ audit_event: executeProvider ? "calendar.writeback.executed" : "calendar.writeback_intent.created",
+ provider_write_executed: executeProvider,
+ status: executeProvider ? "executed" : "intent_ready",
+ runner_request_id: executeProvider ? "runner-calendar-20b" : null,
+ provider_status: executeProvider ? 204 : null,
+ error_code: null,
+ retry_item_uid: null,
+ });
+ }
+ if (endpoint === "/api/webdav/folders") return routeJson(route, [projectFolder]);
+ if (endpoint === "/api/webdav/accounts") return routeJson(route, [webdavAccount]);
+ if (endpoint === "/api/webdav/writeback-intent") {
+ return routeJson(route, {
+ intent: "writeback",
+ source_id: webdavAccount.source_id,
+ target_label: webdavAccount.display_label,
+ requires_if_match: true,
+ if_match: webdavAccount.etag,
+ provenance: "server-authoritative",
+ audit_event: "webdav.writeback_intent.created",
+ provider_write_executed: false,
+ });
+ }
+ if (endpoint === "/api/webdav/knowledge-materialization-intent") {
+ let requestBody = {};
+ try {
+ requestBody = request.postDataJSON();
+ } catch {
+ requestBody = {};
+ }
+ const executeProvider = requestBody.execute_provider === true;
+ return routeJson(route, {
+ intent: "knowledge_materialization",
+ status: executeProvider ? "executed" : "intent_ready",
+ task_id: requestBody.source_task_id ?? knowledgeTask.id,
+ source_type: "self_sent_knowledge",
+ source_email_id: knowledgeTask.source_email_id,
+ source_thread_id: knowledgeTask.related_thread_id,
+ source_id: webdavAccount.source_id,
+ target_label: webdavAccount.display_label,
+ target_path: "/Projects/Naruon_20B_Readiness/knowledge-note.md",
+ requires_if_match: true,
+ provenance: "server-authoritative",
+ provider_write_executed: executeProvider,
+ audit_event: executeProvider ? "webdav.knowledge_materialization.executed" : "webdav.knowledge_materialization_intent.created",
+ runner_request_id: executeProvider ? "runner-knowledge-20b" : null,
+ provider_status: executeProvider ? 201 : null,
+ error_code: null,
+ retry_item_uid: null,
+ });
+ }
+ if (endpoint === "/api/data/quality-surface") return routeJson(route, dataQualitySurface);
+ if (endpoint === "/api/data/quality-surface/evidence-snapshot") return routeJson(route, dataEvidenceSnapshot);
+ if (endpoint === "/api/data/documents") return routeJson(route, { document_id: "doc-smoke", status: "stored" });
+ if (endpoint === "/api/data/documents/doc_repository_ready/embedding-regeneration-intent") {
+ return routeJson(route, {
+ action_id: "doc-embedding-smoke",
+ document_name: "20b-readiness.md",
+ message: "Embedding regeneration intent recorded; no provider write executed.",
+ provider_write_executed: false,
+ });
+ }
+ if (endpoint === "/api/data/documents/doc_repository_ready/hwp-conversion-intent") {
+ return routeJson(route, {
+ action_id: "doc-hwp-smoke",
+ document_name: "20b-readiness.md",
+ message: "HWP conversion intent recorded; no provider write executed.",
+ provider_write_executed: false,
+ });
+ }
+ if (endpoint === "/api/data/documents/doc_repository_ready/webdav-materialization-intent") {
+ return routeJson(route, {
+ action_id: "doc-webdav-smoke",
+ document_name: "20b-readiness.md",
+ message: "WebDAV materialization executed by the connector.",
+ provider_write_executed: true,
+ });
+ }
+ if (endpoint.startsWith("/api/data/documents/")) return routeJson(route, { action_id: "doc-action-smoke", status: "accepted" });
+ if (endpoint === "/api/emails/unique-thread-intent") {
+ return routeJson(route, {
+ status: "intent_ready",
+ candidates_checked: 2,
+ duplicates_found: 2,
+ provider_write_executed: false,
+ provenance: "server-authoritative",
+ audit_event: "email.unique_thread_intent.created",
+ thread_updates: [
+ {
+ candidate_key: "full-product-smoke-message-id",
+ canonical_thread_id: "full-product-thread",
+ dedupe_key: "full-product-smoke@example.com",
+ match_reason: "message_id",
+ existing_message_id: "full-product-smoke@example.com",
+ },
+ ],
+ });
+ }
+ if (endpoint === "/api/ai-hub/surface") return routeJson(route, aiHubSurface);
+ if (endpoint === "/api/security/access-surface") return routeJson(route, securitySurface);
+ if (endpoint === "/api/security/permission-change-intent") {
+ let requestBody = {};
+ try {
+ requestBody = request.postDataJSON();
+ } catch {
+ requestBody = {};
+ }
+ const reasonByDecision = {
+ allow_writeback: "allowed",
+ deny_external_write: "organization_denied",
+ deny_workspace_write: "workspace_denied",
+ deny_region_export: "data_region_denied",
+ deny_missing_consent: "consent_denied",
+ };
+ const allowed = requestBody.decision === "allow_writeback";
+ return routeJson(route, {
+ decision: requestBody.decision ?? "deny_external_write",
+ resource_type: requestBody.resource_type ?? "provider_secret",
+ allowed,
+ reason: reasonByDecision[requestBody.decision] ?? "organization_denied",
+ evidence_label: "policy_engine_evidence",
+ audit_event: "security.permission_change_intent",
+ provider_write_executed: false,
+ denial_result: allowed ? "approval_required_before_external_write" : "provider_denied_by_policy",
+ observed_at: "2026-05-28T04:05:00Z",
+ });
+ }
+ if (endpoint === "/api/accounts/config") {
+ if (request.method() === "PUT") {
+ let requestBody = {};
+ try {
+ requestBody = request.postDataJSON();
+ } catch {
+ requestBody = {};
+ }
+ savedAccountConfig = {
+ ...savedAccountConfig,
+ smtp_server: requestBody.smtp_server ?? null,
+ smtp_port: requestBody.smtp_port ?? null,
+ smtp_username: requestBody.smtp_username ?? null,
+ has_smtp_password: Boolean(requestBody.smtp_password) || savedAccountConfig.has_smtp_password,
+ imap_server: requestBody.imap_server ?? null,
+ imap_port: requestBody.imap_port ?? null,
+ imap_username: requestBody.imap_username ?? null,
+ has_imap_password: Boolean(requestBody.imap_password) || savedAccountConfig.has_imap_password,
+ pop3_server: requestBody.pop3_server ?? null,
+ pop3_port: requestBody.pop3_port ?? null,
+ pop3_username: requestBody.pop3_username ?? null,
+ has_pop3_password: Boolean(requestBody.pop3_password) || savedAccountConfig.has_pop3_password,
+ oauth_client_id: requestBody.oauth_client_id ?? null,
+ oauth_redirect_uri: requestBody.oauth_redirect_uri ?? null,
+ has_oauth_client_secret: Boolean(requestBody.oauth_client_secret) || savedAccountConfig.has_oauth_client_secret,
+ };
+ }
+ return routeJson(route, savedAccountConfig);
+ }
+ if (endpoint === "/api/llm-providers" && request.method() === "POST") {
+ let requestBody = {};
+ try {
+ requestBody = request.postDataJSON();
+ } catch {
+ requestBody = {};
+ }
+ const createdProvider = {
+ ...llmProvider,
+ id: 2,
+ name: requestBody.name ?? "Local Gemma4",
+ provider_type: requestBody.provider_type ?? "ollama",
+ base_url: requestBody.base_url ?? "http://ollama:11434/v1",
+ model_identifier: requestBody.model_identifier ?? "gemma4:e2b-it-qat",
+ embedding_model: requestBody.embedding_model ?? "embeddinggemma",
+ fingerprint: null,
+ updated_at: "2026-07-02T05:30:00Z",
+ };
+ savedLlmProviders = [createdProvider, ...savedLlmProviders.filter((provider) => provider.id !== createdProvider.id)];
+ return routeJson(route, createdProvider);
+ }
+ if (endpoint === "/api/llm-providers") return routeJson(route, savedLlmProviders);
+ if (endpoint.startsWith("/api/llm-providers/")) {
+ let requestBody = {};
+ try {
+ requestBody = request.postDataJSON();
+ } catch {
+ requestBody = {};
+ }
+ const providerId = Number.parseInt(endpoint.split("/").at(-1) ?? "", 10);
+ const currentProvider = savedLlmProviders.find((provider) => provider.id === providerId) ?? llmProvider;
+ const updatedProvider = {
+ ...currentProvider,
+ embedding_model: requestBody.embedding_model ?? llmProvider.embedding_model,
+ updated_at: "2026-07-02T05:31:00Z",
+ };
+ savedLlmProviders = savedLlmProviders.map((provider) => (provider.id === updatedProvider.id ? updatedProvider : provider));
+ return routeJson(route, updatedProvider);
+ }
+ if (endpoint === "/api/runner-config") return routeJson(route, runnerConfig);
+ if (endpoint === "/api/runner-config/rotate") return routeJson(route, runnerConfig);
+ if (endpoint === "/api/observability/operational-signals") return routeJson(route, operationalSignals);
+ if (endpoint === "/api/network/graph") {
+ return routeJson(route, {
+ nodes: [
+ { id: "sender-pm", label: "PM 김지현", title: "계약 검토 담당자" },
+ { id: "thread-20b", label: "20B readiness thread", title: "구매 검토 메일 스레드" },
+ { id: "calendar-review", label: "이사회 일정", title: "CalDAV writeback 후보" },
+ ],
+ edges: [
+ { source: "sender-pm", target: "thread-20b", weight: 2, title: "메일 2건" },
+ { source: "thread-20b", target: "calendar-review", weight: 1, title: "일정 후보 1건" },
+ ],
+ });
+ }
+ if (endpoint === "/api/ontology/relationships") return routeJson(route, []);
+ if (endpoint === "/api/ontology/relationships/capture-source") {
+ return routeJson(route, {
+ sender_email: "pm@example.com",
+ parent_sender_email: null,
+ source_message_id: "",
+ source_thread_id: "full-product-thread",
+ relationship_type: "sender_context",
+ confidence_score: 0.91,
+ next_action: "계약 검토 담당자를 확인합니다.",
+ action_reason: "검색 결과 원본 메시지의 후속 조치입니다.",
+ });
+ }
+ if (endpoint === "/api/tools") return routeJson(route, []);
+ if (endpoint.startsWith("/api/tools/")) return routeJson(route, { output: "ok", status: "success" });
+ if (endpoint === "/api/runtime-config") return routeJson(route, {});
+
+ return routeJson(route, { ok: true });
+ });
+}
+
+async function runCriticalInteractionSmoke(page, routeSpec, viewportSpec) {
+ if (!FULL_PRODUCT_CRITICAL_INTERACTION_ROUTE_NAMES.includes(routeSpec.name)) return [];
+ const evidence = (name) => `${viewportSpec.name}:${name}`;
+
+ if (routeSpec.name === "mail") {
+ await page.getByText("20B smoke source", { exact: true }).first().click();
+ const sourceDrawerTrigger = page.getByRole("button", { name: "근거 원본 보기", exact: true });
+ await sourceDrawerTrigger.click();
+ const sourceDrawer = page.getByRole("dialog", { name: "맥락 종합 근거", exact: true });
+ await sourceDrawer.waitFor({ state: "visible", timeout: 10_000 });
+ await page.waitForFunction(() => document.activeElement?.getAttribute("aria-label") === "근거 원본 닫기");
+ const closeSourceDrawerButton = sourceDrawer.getByRole("button", { name: "근거 원본 닫기", exact: true });
+ const openOriginalButton = sourceDrawer.getByRole("button", { name: "스레드 원문으로 이동", exact: true });
+ await openOriginalButton.focus();
+ await page.keyboard.press("Tab");
+ await page.waitForFunction(() => document.activeElement?.getAttribute("aria-label") === "근거 원본 닫기");
+ await closeSourceDrawerButton.focus();
+ await page.keyboard.press("Shift+Tab");
+ await page.waitForFunction(() => document.activeElement?.textContent?.replace(/\s+/g, " ").trim() === "스레드 원문으로 이동");
+ await page.keyboard.press("Escape");
+ await sourceDrawer.waitFor({ state: "hidden", timeout: 10_000 });
+ await page.waitForFunction(() => document.activeElement?.textContent?.replace(/\s+/g, " ").trim() === "근거 원본 보기");
+ await page.getByRole("button", { name: "답장 초안 생성", exact: true }).click();
+ await page.waitForFunction(() => document.querySelector("#reply-draft")?.value.includes("검토 가능한 답장 초안입니다."));
+ await page.getByRole("button", { name: "답장 보내기", exact: true }).click();
+ await page.getByText("개발 모드에서 답장을 시뮬레이션했습니다. 실제 메일은 전송되지 않았습니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "답장 초안 생성", exact: true }).click();
+ await page.waitForFunction(() => document.querySelector("#reply-draft")?.value.includes("검토 가능한 답장 초안입니다."));
+ await page.getByRole("button", { name: "답장 보내기", exact: true }).click();
+ await page.getByText("답장을 전송했습니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ const createTaskButton = page.getByRole("button", { name: "실행 항목 생성" }).first();
+ await createTaskButton.waitFor({ state: "visible", timeout: 10_000 });
+ await createTaskButton.click();
+ await page.getByText("1개 실행 항목을 티켓형 실행 항목으로 추적합니다.").waitFor({ state: "visible", timeout: 10_000 });
+ return [
+ evidence("mail:select-message"),
+ evidence("mail:open-source-drawer"),
+ evidence("mail:verify-source-drawer-initial-focus"),
+ evidence("mail:verify-source-drawer-tab-trap"),
+ evidence("mail:verify-source-drawer-escape-close"),
+ evidence("mail:verify-source-drawer-focus-restore"),
+ evidence("mail:generate-reply-draft"),
+ evidence("mail:send-simulated-reply"),
+ evidence("mail:send-provider-reply"),
+ evidence("mail:create-source-linked-task"),
+ ];
+ }
+
+ if (routeSpec.name === "search") {
+ await page.getByText("20B readiness result", { exact: true }).first().waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("tab", { name: "관계 원본", exact: true }).click();
+ await page.getByText("원본 메시지 필터로 관계 API를 조회합니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("tab", { name: "판단 보조", exact: true }).click();
+ await page.getByText("외부 실행은 사용자가 메일, 일정, 관계 캡처 액션을 명시적으로 선택할 때만 진행됩니다.", { exact: false }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "관계 캡처", exact: true }).click();
+ await page.getByText("계약 검토 담당자를 확인합니다.").waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("tab", { name: "관계 원본", exact: true }).click();
+ await page.getByText("1개 관계 연결", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("heading", { name: "관계 맥락과 타임라인", exact: true }).scrollIntoViewIfNeeded();
+ await page.getByText("3개 노드와 2개 관계가 이 스레드 맥락에 연결되어 있습니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("관련 노드: PM 김지현, 20B readiness thread, 이사회 일정", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.locator('[aria-label="3개 노드와 2개 관계가 있는 관계 맥락"]').waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "첫 관계 보기", exact: true }).click();
+ await page.getByText("선택된 관계: PM 김지현 -> 20B readiness thread (메일 2건)", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("첫 관계를 선택했습니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "그래프 확대", exact: true }).click();
+ await page.getByText("그래프 확대 완료", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "전체 그래프 맞춤", exact: true }).click();
+ await page.getByText("그래프 맞춤 완료", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByLabel("관계 선택", { exact: true }).selectOption({ label: "관계 2: 20B readiness thread -> 이사회 일정 (일정 후보 1건)" });
+ await page.getByText("선택된 관계: 20B readiness thread -> 이사회 일정 (일정 후보 1건)", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("선택한 관계를 열었습니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByLabel("노드 선택", { exact: true }).selectOption({ label: "노드: 이사회 일정" });
+ await page.getByText("선택된 노드: 이사회 일정", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("선택한 노드를 열었습니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("선택된 노드: 이사회 일정", { exact: true }).scrollIntoViewIfNeeded();
+ return [
+ evidence("search:select-result"),
+ evidence("search:open-source-evidence-tab"),
+ evidence("search:open-decision-assist-tab"),
+ evidence("search:capture-sender-relationship"),
+ evidence("search:verify-captured-relationship-state"),
+ evidence("search:open-network-graph"),
+ evidence("search:verify-network-graph-summary"),
+ evidence("search:verify-network-graph-canvas-label"),
+ evidence("search:select-network-relationship"),
+ evidence("search:verify-network-relationship-detail"),
+ evidence("search:zoom-network-graph"),
+ evidence("search:fit-network-graph"),
+ evidence("search:select-network-second-relationship"),
+ evidence("search:verify-network-node-detail"),
+ ];
+ }
+
+ if (routeSpec.name === "calendar") {
+ await page.getByRole("button", { name: "새 일정 intent 점검", exact: true }).click();
+ await page.getByText("기록됨", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "ETag 업데이트 점검", exact: true }).click();
+ await page.getByText("If-Match 필요", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "ETag 실행 요청", exact: true }).click();
+ await page.getByText("외부 원본 쓰기 완료", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("재시도 없음", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ return [
+ evidence("calendar:create-writeback-intent"),
+ evidence("calendar:verify-etag-update-intent"),
+ evidence("calendar:request-provider-write"),
+ evidence("calendar:verify-provider-completion-state"),
+ evidence("calendar:verify-provider-no-retry-state"),
+ ];
+ }
+
+ if (routeSpec.name === "tasks") {
+ await page.getByRole("button", { name: "보낸 메일 미답변 팔로업 작업 생성" }).click();
+ await page.getByText("미답변 팔로업 결과가 보드에 반영되었습니다.").waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "20억 판매 검토 실행 항목 상태를 완료로 변경", exact: true }).click();
+ await page.getByText("20억 판매 검토 실행 항목 상태를 완료로 변경했습니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "나에게 보낸 지식 메모 정리 WebDAV 지식 노트 의도 생성", exact: true }).click();
+ await page.getByText("WebDAV/Notes 의도 준비", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "나에게 보낸 지식 메모 정리 WebDAV 지식 노트 실행 요청", exact: true }).click();
+ await page.getByText("외부 쓰기 실행됨", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("재시도 없음", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ return [
+ evidence("tasks:create-reply-sla-followup"),
+ evidence("tasks:complete-source-linked-task"),
+ evidence("tasks:create-knowledge-webdav-intent"),
+ evidence("tasks:request-knowledge-provider-write"),
+ evidence("tasks:verify-knowledge-provider-completion-state"),
+ evidence("tasks:verify-knowledge-provider-no-retry-state"),
+ ];
+ }
+
+ if (routeSpec.name === "projects") {
+ const projectContent = page.getByRole("region", { name: "프로젝트 내용" });
+ await page.getByRole("link", { name: "관련 문서/메일 연결", exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "프로젝트 의사결정 추가" }).first().click();
+ await projectContent.getByText("작업 흐름 반영", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await projectContent.getByText("근거: WebDAV 폴더", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "프로젝트 상세 열기", exact: true }).click();
+ await projectContent.getByText("프로젝트 개요", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await projectContent.getByText("저장소 경계 확인됨", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await projectContent.getByText("WebDAV 폴더 근거", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await projectContent.getByText("스레드 근거 연결됨", { exact: true }).first().waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("region", { name: "프로젝트 작업 목록" }).getByText("문서 근거", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ const evidenceEditor = page.getByRole("region", { name: "프로젝트 근거 편집" });
+ await evidenceEditor.getByLabel("프로젝트 근거 메모", { exact: true }).fill("20B 구매 심사용 WebDAV 경계와 이사회 승인 근거를 함께 저장합니다.");
+ await evidenceEditor.getByLabel("연결 원본 변경", { exact: true }).selectOption({ label: "문서 근거" });
+ await evidenceEditor.getByRole("button", { name: "근거 저장", exact: true }).click();
+ await evidenceEditor.getByText("프로젝트 근거가 저장되었습니다: 문서 근거", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await evidenceEditor.getByText("20B 구매 심사용 WebDAV 경계와 이사회 승인 근거를 함께 저장합니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ const connectedResources = page.getByRole("region", { name: "연결된 자원" });
+ await connectedResources.getByText("원본 종류", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await connectedResources.locator("li").filter({ hasText: "원본 종류" }).getByText("3", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ return [
+ evidence("projects:open-related-source-link"),
+ evidence("projects:open-decision-log"),
+ evidence("projects:verify-webdav-folder-evidence"),
+ evidence("projects:reopen-project-detail"),
+ evidence("projects:verify-source-boundary"),
+ evidence("projects:verify-thread-source-attachment"),
+ evidence("projects:verify-document-source-attachment"),
+ evidence("projects:edit-evidence-note"),
+ evidence("projects:mutate-evidence-source"),
+ evidence("projects:save-evidence-note"),
+ evidence("projects:verify-evidence-save-state"),
+ evidence("projects:verify-source-type-count"),
+ ];
+ }
+
+ if (routeSpec.name === "data") {
+ await page.getByRole("button", { name: "임베딩 재생성 의도", exact: true }).click();
+ await page.getByText("Embedding regeneration intent recorded", { exact: false }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "HWP 변환 의도", exact: true }).click();
+ await page.getByText("HWP conversion intent recorded", { exact: false }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "WebDAV 문서 실행 요청", exact: true }).click();
+ await page.getByText("WebDAV materialization executed by the connector.", { exact: false }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("외부 쓰기 실행됨", { exact: false }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "WebDAV 반영 의도 점검", exact: true }).click();
+ await page.getByText("원본 반영 의도", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("If-Match 필요", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "중복 메일 스레드 의도 점검", exact: true }).click();
+ await page.getByText("Message-ID 근거", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "품질 점검", exact: true }).click();
+ await page.getByRole("heading", { name: "Thread id integrity", exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ return [
+ evidence("data:create-embedding-regeneration-intent"),
+ evidence("data:create-hwp-conversion-intent"),
+ evidence("data:execute-webdav-materialization"),
+ evidence("data:verify-webdav-materialization-completion-state"),
+ evidence("data:create-webdav-writeback-intent"),
+ evidence("data:create-unique-thread-intent"),
+ evidence("data:open-quality-checks"),
+ ];
+ }
+
+ if (routeSpec.name === "ai-hub") {
+ await page.getByRole("tab", { name: "워크플로우", exact: true }).click();
+ await page.getByRole("tabpanel", { name: "워크플로우", exact: true }).getByText("의사결정 로그 자동 작성", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("tab", { name: "평가", exact: true }).click();
+ await page.getByRole("tabpanel", { name: "평가", exact: true }).getByText("연동 준비도", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "평가 근거 보기", exact: true }).first().click();
+ await page.getByRole("tab", { name: "실행 이력", exact: true }).click();
+ const runHistoryPanel = page.getByRole("tabpanel", { name: "실행 이력", exact: true });
+ const runEvent = runHistoryPanel.locator("article").filter({ hasText: "워크플로우 실행" }).first();
+ await runEvent.getByText("워크플로우 실행", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await runEvent.getByText("완료", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await runEvent.getByText("3개 판단 포인트를 추출했습니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await runEvent.getByText("agent_run_records", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ return [
+ evidence("ai-hub:open-workflow-tab"),
+ evidence("ai-hub:open-evaluation-tab"),
+ evidence("ai-hub:open-run-history-from-evidence"),
+ evidence("ai-hub:open-run-history"),
+ evidence("ai-hub:verify-run-event-title"),
+ evidence("ai-hub:verify-run-event-completion-state"),
+ evidence("ai-hub:verify-run-event-detail"),
+ evidence("ai-hub:verify-run-event-evidence-source"),
+ ];
+ }
+
+ if (routeSpec.name === "security") {
+ const accessRegion = page.getByRole("region", { name: "접근 권한 소스 거버넌스", exact: true });
+ await accessRegion.getByRole("heading", { name: "원본 연결 RBAC / ABAC", exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ const sourceGovernance = viewportSpec.name === "mobile"
+ ? accessRegion.locator("article").filter({ hasText: "WebDAV 저장소 1" }).first()
+ : accessRegion.locator("tbody tr").filter({ hasText: "WebDAV 저장소 1" }).first();
+ await sourceGovernance.getByText("WebDAV 저장소 1", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await sourceGovernance.getByText("조직 스코프", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await sourceGovernance.getByText("쓰기", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ if (viewportSpec.name !== "mobile") {
+ await sourceGovernance.getByText("쓰기 의도 가능", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ }
+ await sourceGovernance.getByText("충돌 검사", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await sourceGovernance.getByText("허용", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ const permissionEditor = page.getByRole("region", { name: "보안 권한 편집", exact: true });
+ await permissionEditor.getByLabel("권한 판정 변경", { exact: true }).selectOption({ label: "외부 쓰기 차단" });
+ await permissionEditor.getByText("조직 차단 - 외부 쓰기 실행 안 함", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await permissionEditor.getByRole("button", { name: "권한 저장", exact: true }).click();
+ await permissionEditor.getByText("권한 변경이 저장되었습니다: 외부 쓰기 차단", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await permissionEditor.getByText("security.permission_change_intent", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await permissionEditor.getByText("실행 안 함", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await permissionEditor.getByLabel("권한 판정 변경", { exact: true }).selectOption({ label: "워크스페이스 밖 쓰기 차단" });
+ await permissionEditor.getByText("워크스페이스 차단 - 외부 쓰기 실행 안 함", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await permissionEditor.getByRole("button", { name: "권한 저장", exact: true }).click();
+ await permissionEditor.getByText("워크스페이스 차단", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await permissionEditor.getByLabel("권한 판정 변경", { exact: true }).selectOption({ label: "리전 외부 내보내기 차단" });
+ await permissionEditor.getByText("리전 차단 - 외부 쓰기 실행 안 함", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await permissionEditor.getByRole("button", { name: "권한 저장", exact: true }).click();
+ await permissionEditor.getByText("리전 차단", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await permissionEditor.getByText("데이터 내보내기", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await permissionEditor.getByLabel("권한 판정 변경", { exact: true }).selectOption({ label: "동의 없는 CalDAV 쓰기 차단" });
+ await permissionEditor.getByText("동의 차단 - 외부 쓰기 실행 안 함", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await permissionEditor.getByRole("button", { name: "권한 저장", exact: true }).click();
+ await permissionEditor.getByText("동의 차단", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "감사 로그", exact: true }).click();
+ const auditRegion = page.getByRole("region", { name: "보안 감사 로그", exact: true });
+ await auditRegion.getByText("지속 감사 근거", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await auditRegion.getByText("설정 변경 / LLM 제공자", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await auditRegion.getByText("서버 감사 로그", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await auditRegion.getByText("서버 근거", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await auditRegion.getByText("하트비트 수신", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await auditRegion.getByText("connector 관측 근거", { exact: false }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "외부 공유", exact: true }).click();
+ await page.getByText("외부 공유 / 쓰기 경계", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("외부 쓰기 실행 안 함", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "정책", exact: true }).click();
+ await page.getByText("차단 우선 정책 순서", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("교차 조직 제공자 secret", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("조직 차단", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "접근 권한", exact: true }).click();
+ const finalPermissionEditor = page.getByRole("region", { name: "보안 권한 편집", exact: true });
+ await finalPermissionEditor.getByLabel("권한 판정 변경", { exact: true }).selectOption({ label: "외부 쓰기 차단" });
+ await finalPermissionEditor.getByRole("button", { name: "권한 저장", exact: true }).click();
+ await finalPermissionEditor.scrollIntoViewIfNeeded();
+ await finalPermissionEditor.getByText("권한 변경이 저장되었습니다: 외부 쓰기 차단", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await finalPermissionEditor.getByText("security.permission_change_intent", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ return [
+ evidence("security:verify-access-source-governance"),
+ evidence("security:verify-write-capability-boundary"),
+ evidence("security:verify-policy-allow-decision"),
+ evidence("security:edit-permission-decision"),
+ evidence("security:save-permission-decision"),
+ evidence("security:verify-denial-result-state"),
+ evidence("security:verify-permission-save-server-evidence"),
+ evidence("security:verify-permission-workspace-denial"),
+ evidence("security:verify-permission-region-denial"),
+ evidence("security:verify-permission-consent-denial"),
+ evidence("security:return-permission-editor-evidence"),
+ evidence("security:open-audit-log"),
+ evidence("security:verify-durable-audit-event"),
+ evidence("security:verify-connector-observation"),
+ evidence("security:open-sharing-review"),
+ evidence("security:verify-external-write-block"),
+ evidence("security:open-policy-order"),
+ evidence("security:verify-deny-sample"),
+ ];
+ }
+
+ if (routeSpec.name === "settings") {
+ await page.getByRole("button", { name: "AI 모델", exact: true }).click();
+ await page.getByText("/api/llm-providers", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("heading", { name: "Primary OpenAI", exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("radio", { name: /text-embedding-3-large/ }).check();
+ await page.getByRole("button", { name: "임베딩 모델 저장", exact: true }).click();
+ await page.getByText("임베딩 모델 지정을 저장했습니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("region", { name: "등록된 AI 모델", exact: true }).getByText("text-embedding-3-large", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "연결 계정", exact: true }).click();
+ await page.locator("#smtp-server").fill("smtp.20b.example.com");
+ await page.locator("#smtp-port").fill("587");
+ await page.locator("#smtp-username").fill("pilot.sender@20b.example.com");
+ await page.locator("#smtp-password").fill("smoke-secret-only");
+ await page.getByRole("button", { name: "계정 설정 저장", exact: true }).click();
+ await page.getByText("계정 설정을 저장했습니다. 저장된 secret은 응답에 노출되지 않습니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("smtp.20b.example.com:587", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("pilot.sender@20b.example.com", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("저장된 secret 유지", { exact: true }).first().waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "워크스페이스", exact: true }).click();
+ const calendarStartupButton = page.locator("button").filter({ hasText: "일정 관리" }).last();
+ await calendarStartupButton.click();
+ const calendarStartupClass = await calendarStartupButton.getAttribute("class");
+ if (!calendarStartupClass?.includes("border-primary")) {
+ throw new Error("Settings startup view selector did not mark the calendar option as active");
+ }
+ await page.reload({ waitUntil: "networkidle" });
+ await page.getByRole("heading", { name: "워크스페이스 설정", exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ const persistedCalendarStartupButton = page.locator("button").filter({ hasText: "일정 관리" }).last();
+ await persistedCalendarStartupButton.waitFor({ state: "visible", timeout: 10_000 });
+ const persistedCalendarStartupClass = await persistedCalendarStartupButton.getAttribute("class");
+ if (!persistedCalendarStartupClass?.includes("border-primary")) {
+ throw new Error("Settings startup view selector did not persist the calendar option after reload");
+ }
+ await page.getByRole("button", { name: "AI 모델", exact: true }).click();
+ await page.getByRole("region", { name: "등록된 AI 모델", exact: true }).getByText("text-embedding-3-large", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "연결 계정", exact: true }).click();
+ await page.getByText("smtp.20b.example.com:587", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("pilot.sender@20b.example.com", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByText("저장된 secret 유지", { exact: true }).first().waitFor({ state: "visible", timeout: 10_000 });
+ await page.getByRole("button", { name: "개발자", exact: true }).click();
+ await page.getByRole("button", { name: "등록 토큰 회전", exact: true }).click();
+ await page.getByText("등록 토큰이 생성되었습니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ return [
+ evidence("settings:switch-ai-model-tab"),
+ evidence("settings:save-embedding-model"),
+ evidence("settings:verify-embedding-model-save-state"),
+ evidence("settings:save-account-config"),
+ evidence("settings:verify-account-save-state"),
+ evidence("settings:select-calendar-startup-view"),
+ evidence("settings:verify-startup-view-persistence"),
+ evidence("settings:verify-embedding-model-reload-persistence"),
+ evidence("settings:verify-account-reload-persistence"),
+ evidence("settings:rotate-connector-token"),
+ ];
+ }
+
+ return [];
+}
+
+async function runAccessibilitySmoke(page, routeSpec) {
+ const findings = await page.evaluate(() => {
+ function isVisible(element) {
+ if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) return false;
+ if (element.closest("[hidden], [aria-hidden='true']")) return false;
+ const style = window.getComputedStyle(element);
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false;
+ return element.getClientRects().length > 0;
+ }
+
+ function textFromIdRefs(value) {
+ return value
+ .split(/\s+/)
+ .map((id) => document.getElementById(id)?.textContent?.trim() || "")
+ .filter(Boolean)
+ .join(" ")
+ .trim();
+ }
+
+ function accessibleName(element) {
+ const ariaLabel = element.getAttribute("aria-label")?.trim();
+ if (ariaLabel) return ariaLabel;
+ const labelledBy = element.getAttribute("aria-labelledby");
+ if (labelledBy) {
+ const label = textFromIdRefs(labelledBy);
+ if (label) return label;
+ }
+ if ("labels" in element && element.labels?.length) {
+ const label = Array.from(element.labels)
+ .map((item) => item.textContent?.trim() || "")
+ .filter(Boolean)
+ .join(" ")
+ .trim();
+ if (label) return label;
+ }
+ const title = element.getAttribute("title")?.trim();
+ if (title) return title;
+ const text = element.textContent?.replace(/\s+/g, " ").trim();
+ if (text) return text;
+ return "";
+ }
+
+ const visibleIds = new Map();
+ for (const element of Array.from(document.querySelectorAll("[id]"))) {
+ if (!isVisible(element)) continue;
+ const id = element.getAttribute("id");
+ if (!id) continue;
+ visibleIds.set(id, (visibleIds.get(id) || 0) + 1);
+ }
+ const duplicateIds = Array.from(visibleIds.entries())
+ .filter(([, count]) => count > 1)
+ .map(([id, count]) => `${id}(${count})`);
+
+ const selector = [
+ "button",
+ "a[href]",
+ "input",
+ "select",
+ "textarea",
+ "[role='button']",
+ "[role='link']",
+ "[role='tab']",
+ "[role='searchbox']",
+ "[role='menuitem']",
+ ].join(",");
+ const unnamedInteractive = Array.from(document.querySelectorAll(selector))
+ .filter((element) => isVisible(element))
+ .filter((element) => !element.hasAttribute("disabled"))
+ .filter((element) => element.getAttribute("aria-disabled") !== "true")
+ .filter((element) => !accessibleName(element))
+ .slice(0, 10)
+ .map((element) => {
+ const tag = element.tagName.toLowerCase();
+ const role = element.getAttribute("role");
+ const type = element.getAttribute("type");
+ return [tag, role ? `role=${role}` : "", type ? `type=${type}` : ""].filter(Boolean).join("[") + (role || type ? "]" : "");
+ });
+
+ return { duplicateIds, unnamedInteractive };
+ });
+
+ if (findings.duplicateIds.length > 0) {
+ throw new Error(`Route ${routeSpec.path} has visible duplicate IDs: ${findings.duplicateIds.join(", ")}`);
+ }
+ if (findings.unnamedInteractive.length > 0) {
+ throw new Error(`Route ${routeSpec.path} has visible interactive controls without accessible names: ${findings.unnamedInteractive.join(", ")}`);
+ }
+
+ await page.evaluate(() => {
+ if (document.activeElement instanceof HTMLElement) {
+ document.activeElement.blur();
+ }
+ });
+ let focusTarget = null;
+ for (let attempt = 0; attempt < 12; attempt += 1) {
+ await page.keyboard.press("Tab");
+ focusTarget = await page.evaluate(() => {
+ const element = document.activeElement;
+ if (!element) return null;
+ return {
+ tagName: element.tagName,
+ role: element.getAttribute("role"),
+ ariaLabel: element.getAttribute("aria-label"),
+ text: element.textContent?.replace(/\s+/g, " ").trim().slice(0, 80) || "",
+ };
+ });
+ if (focusTarget && focusTarget.tagName !== "BODY" && focusTarget.tagName !== "HTML") break;
+ }
+ if (!focusTarget || focusTarget.tagName === "BODY" || focusTarget.tagName === "HTML") {
+ throw new Error(`Route ${routeSpec.path} did not expose a keyboard focus target after pressing Tab`);
+ }
+
+ return [`${routeSpec.name}:a11y-basics`];
+}
+
+async function runRouteSmoke(context, routeSpec, viewportSpec, viewportCount) {
+ const page = await context.newPage();
+ const consoleErrors = [];
+ page.on("console", (message) => {
+ if (message.type() === "error") {
+ consoleErrors.push(`${message.type()}: ${message.text()}`);
+ }
+ });
+ page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${error.message}`));
+ await installRoutes(page);
+ await page.goto(`${baseUrl}${routeSpec.path}`, { waitUntil: "domcontentloaded" });
+ await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
+ await page.locator("body").waitFor({ state: "visible", timeout: 20_000 });
+ const bodyText = await page.locator("body").innerText({ timeout: 10_000 });
+ const expectedTexts = Array.isArray(routeSpec.expectedText) ? routeSpec.expectedText : [routeSpec.expectedText];
+ if (!expectedTexts.some((expectedText) => bodyText.includes(expectedText))) {
+ const bodySnippet = bodyText.replace(/\s+/g, " ").trim().slice(0, 500);
+ throw new Error(
+ `Route ${routeSpec.path} did not render expected text: ${expectedTexts.join(" or ")}. Body snippet: ${bodySnippet}`,
+ );
+ }
+ if (bodyText.includes("404") || bodyText.includes("This page could not be found")) {
+ throw new Error(`Route ${routeSpec.path} rendered a not-found page`);
+ }
+ if (consoleErrors.length > 0) {
+ throw new Error(`Route ${routeSpec.path} emitted console errors:\n${consoleErrors.join("\n")}`);
+ }
+ const interactionEvidence = await runCriticalInteractionSmoke(page, routeSpec, viewportSpec);
+ const accessibilityEvidence = await runAccessibilitySmoke(page, routeSpec);
+ const screenshotPath = path.join(screenshotDir, fullProductScreenshotName(routeSpec, viewportSpec, viewportCount));
+ await page.screenshot({ path: screenshotPath, fullPage: false });
+ await page.close();
+ return { screenshotPath, interactionEvidence, accessibilityEvidence };
+}
+
+async function main() {
+ let serverProcess = null;
+ let browser = null;
+ try {
+ await mkdir(screenshotDir, { recursive: true });
+ serverProcess = await startServerIfNeeded();
+ browser = await launchBrowser();
+ const screenshots = [];
+ const interactions = [];
+ const accessibility = [];
+ const viewportSpecs = resolveFullProductViewportSpecs(process.env.NARUON_FULL_PRODUCT_VIEWPORTS || "desktop");
+ for (const viewportSpec of viewportSpecs) {
+ const context = await browser.newContext({
+ viewport: { width: viewportSpec.width, height: viewportSpec.height },
+ isMobile: Boolean(viewportSpec.isMobile),
+ });
+ for (const routeSpec of FULL_PRODUCT_ROUTES) {
+ const result = await runRouteSmoke(context, routeSpec, viewportSpec, viewportSpecs.length);
+ screenshots.push(result.screenshotPath);
+ interactions.push(...result.interactionEvidence);
+ accessibility.push(...result.accessibilityEvidence);
+ }
+ await context.close();
+ }
+ log("Naruon full-product route smoke passed.");
+ log(`Routes: ${FULL_PRODUCT_ROUTES.map((route) => route.path).join(", ")}`);
+ log(`Viewports: ${viewportSpecs.map((viewport) => `${viewport.name}(${viewport.width}x${viewport.height})`).join(", ")}`);
+ if (interactions.length > 0) {
+ log(`Critical interactions: ${interactions.join(", ")}`);
+ }
+ log(`Accessibility checks: ${accessibility.join(", ")}`);
+ log(`Screenshots: ${screenshots.join(", ")}`);
+ } finally {
+ if (browser) await browser.close().catch(() => {});
+ if (serverProcess) await stopServerProcess(serverProcess);
+ }
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+ });
+}
diff --git a/frontend/scripts/full-product-ui-smoke.test.mjs b/frontend/scripts/full-product-ui-smoke.test.mjs
new file mode 100644
index 000000000..7460caa22
--- /dev/null
+++ b/frontend/scripts/full-product-ui-smoke.test.mjs
@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vitest";
+import net from "node:net";
+
+import {
+ FULL_PRODUCT_ACCESSIBILITY_CHECK_NAMES,
+ FULL_PRODUCT_CRITICAL_INTERACTION_ROUTE_NAMES,
+ FULL_PRODUCT_CRITICAL_INTERACTION_VIEWPORT_NAMES,
+ FULL_PRODUCT_DESKTOP_INTERACTION_ROUTE_NAMES,
+ FULL_PRODUCT_ROUTES,
+ fullProductScreenshotName,
+ isTcpPortOpen,
+ resolveFullProductBaseUrl,
+ resolveFullProductViewportSpecs,
+} from "./full-product-ui-smoke.mjs";
+
+describe("full product UI smoke base URL guard", () => {
+ it("allows localhost full-product smoke targets", () => {
+ expect(resolveFullProductBaseUrl("http://127.0.0.1:3001").hostname).toBe("127.0.0.1");
+ expect(resolveFullProductBaseUrl("http://localhost:3001").hostname).toBe("localhost");
+ expect(resolveFullProductBaseUrl("http://[::1]:3001").hostname).toBe("[::1]");
+ });
+
+ it("rejects non-localhost targets", () => {
+ expect(() => resolveFullProductBaseUrl("https://staging.example.com")).toThrow("localhost targets");
+ expect(() => resolveFullProductBaseUrl("https://naruon.example.com")).toThrow("localhost targets");
+ expect(() => resolveFullProductBaseUrl("http://192.168.0.10:3000")).toThrow("localhost targets");
+ });
+
+ it("covers the ten buyer-review IA routes", () => {
+ expect(FULL_PRODUCT_ROUTES.map((route) => route.path)).toEqual([
+ "/",
+ "/mail",
+ "/search",
+ "/calendar",
+ "/tasks",
+ "/projects",
+ "/data",
+ "/ai-hub",
+ "/security",
+ "/settings",
+ ]);
+ });
+
+ it("resolves desktop and mobile responsive capture viewports", () => {
+ expect(resolveFullProductViewportSpecs("desktop,mobile").map((viewport) => viewport.name)).toEqual([
+ "desktop",
+ "mobile",
+ ]);
+ expect(resolveFullProductViewportSpecs("all").map((viewport) => viewport.name)).toEqual(["desktop", "mobile"]);
+ expect(() => resolveFullProductViewportSpecs("tablet")).toThrow("Unknown full-product viewport");
+ expect(() => resolveFullProductViewportSpecs("desktop,desktop")).toThrow("Duplicate full-product viewport");
+ });
+
+ it("keeps the legacy desktop screenshot names unless multiple viewports are captured", () => {
+ const [homeRoute] = FULL_PRODUCT_ROUTES;
+ const [desktopViewport, mobileViewport] = resolveFullProductViewportSpecs("desktop,mobile");
+ expect(fullProductScreenshotName(homeRoute, desktopViewport, 1)).toBe("home.png");
+ expect(fullProductScreenshotName(homeRoute, desktopViewport, 2)).toBe("desktop-home.png");
+ expect(fullProductScreenshotName(homeRoute, mobileViewport, 2)).toBe("mobile-home.png");
+ });
+
+ it("tracks only buyer-critical interaction routes with existing smoke route names", () => {
+ const routeNames = new Set(FULL_PRODUCT_ROUTES.map((route) => route.name));
+ expect(FULL_PRODUCT_CRITICAL_INTERACTION_ROUTE_NAMES).toEqual([
+ "mail",
+ "search",
+ "calendar",
+ "tasks",
+ "projects",
+ "data",
+ "ai-hub",
+ "security",
+ "settings",
+ ]);
+ expect(FULL_PRODUCT_CRITICAL_INTERACTION_VIEWPORT_NAMES).toEqual(["desktop", "mobile"]);
+ expect(FULL_PRODUCT_CRITICAL_INTERACTION_ROUTE_NAMES.every((routeName) => routeNames.has(routeName))).toBe(true);
+ expect(FULL_PRODUCT_DESKTOP_INTERACTION_ROUTE_NAMES).toEqual(FULL_PRODUCT_CRITICAL_INTERACTION_ROUTE_NAMES);
+ });
+
+ it("keeps the full-product accessibility smoke scoped to basic automatable checks", () => {
+ expect(FULL_PRODUCT_ACCESSIBILITY_CHECK_NAMES).toEqual([
+ "visible-duplicate-id",
+ "visible-interactive-accessible-name",
+ "keyboard-tab-focus-entry",
+ ]);
+ });
+
+ it("detects an existing local TCP listener before spawning another dev server", async () => {
+ const server = net.createServer();
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+
+ try {
+ const address = server.address();
+ expect(address).not.toBeNull();
+ expect(typeof address).not.toBe("string");
+ await expect(isTcpPortOpen(new URL(`http://127.0.0.1:${address.port}`))).resolves.toBe(true);
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ }
+ });
+});
diff --git a/frontend/scripts/pilot-ui-smoke.mjs b/frontend/scripts/pilot-ui-smoke.mjs
new file mode 100644
index 000000000..f65173da9
--- /dev/null
+++ b/frontend/scripts/pilot-ui-smoke.mjs
@@ -0,0 +1,400 @@
+import { spawn } from "node:child_process";
+import { access } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+import { chromium } from "@playwright/test";
+
+const scriptDir = path.dirname(fileURLToPath(import.meta.url));
+const frontendDir = path.resolve(scriptDir, "..");
+const baseUrl = process.env.NARUON_PILOT_BASE_URL || "http://127.0.0.1:3001";
+const mailScreenshotPath = process.env.NARUON_PILOT_MAIL_SCREENSHOT || "/tmp/naruon-pilot-mail.png";
+const searchScreenshotPath = process.env.NARUON_PILOT_SEARCH_SCREENSHOT || "/tmp/naruon-pilot-search.png";
+const ALLOWED_PILOT_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
+
+export function resolvePilotBaseUrl(rawBaseUrl) {
+ const pilotBaseUrl = new URL(rawBaseUrl);
+ if (!ALLOWED_PILOT_HOSTS.has(pilotBaseUrl.hostname)) {
+ throw new Error(`Pilot smoke must run only against localhost targets, got: ${pilotBaseUrl.hostname}`);
+ }
+ return pilotBaseUrl;
+}
+
+resolvePilotBaseUrl(baseUrl);
+
+const sensitiveMailBody = "Sensitive source body must stay out of analytics payloads.";
+const sensitiveDraftBody = "상용 파일럿 답장 초안입니다. 내부 원문은 이벤트에 남지 않아야 합니다.";
+const rawSearchQuery = "계약";
+
+const sourceEmail = {
+ id: 23,
+ message_id: "",
+ thread_id: "source-thread",
+ sender: "source@example.com",
+ recipients: "user@example.com",
+ subject: "Source Drawer",
+ date: "2026-05-19T09:00:00Z",
+ snippet: "근거 원본을 확인해야 하는 맥락 종합입니다.",
+ body: sensitiveMailBody,
+ reply_count: 1,
+};
+
+function log(message) {
+ process.stdout.write(`${message}\n`);
+}
+
+async function isServerReady(url) {
+ try {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 1000);
+ const response = await fetch(url, { signal: controller.signal });
+ clearTimeout(timeout);
+ return response.status < 500;
+ } catch {
+ return false;
+ }
+}
+
+async function waitForServer(url, child) {
+ const startedAt = Date.now();
+ while (Date.now() - startedAt < 30_000) {
+ if (child?.exitCode !== null && child?.exitCode !== undefined) {
+ throw new Error(`Next dev server exited before becoming ready with code ${child.exitCode}`);
+ }
+ if (await isServerReady(url)) return;
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ throw new Error(`Timed out waiting for ${url}`);
+}
+
+async function startServerIfNeeded() {
+ if (await isServerReady(baseUrl)) return null;
+
+ const url = new URL(baseUrl);
+ if (url.hostname !== "127.0.0.1" && url.hostname !== "localhost") {
+ throw new Error(`Server is not reachable and cannot be auto-started for non-local URL: ${baseUrl}`);
+ }
+
+ const child = spawn(
+ "pnpm",
+ ["dev", "--hostname", url.hostname, "--port", url.port || "3001"],
+ {
+ cwd: frontendDir,
+ env: { ...process.env, NEXT_TELEMETRY_DISABLED: "1" },
+ stdio: ["ignore", "pipe", "pipe"],
+ },
+ );
+ child.stdout.on("data", (chunk) => process.stdout.write(chunk));
+ child.stderr.on("data", (chunk) => process.stderr.write(chunk));
+ await waitForServer(baseUrl, child);
+ return child;
+}
+
+async function launchBrowser() {
+ try {
+ return await chromium.launch({ headless: true });
+ } catch (error) {
+ const fallbackPath =
+ process.env.PLAYWRIGHT_CHROME_PATH ||
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
+ await access(fallbackPath);
+ log(`Using system Chrome fallback because bundled Playwright browser is unavailable: ${error.message.split("\n")[0]}`);
+ return chromium.launch({ headless: true, executablePath: fallbackPath });
+ }
+}
+
+async function installRoutes(page) {
+ await page.route("**/auth/session", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ claims: {} }),
+ });
+ });
+
+ await page.route("**/api/**", async (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ const endpoint = url.pathname;
+ const respond = (body, status = 200) =>
+ route.fulfill({
+ status,
+ contentType: "application/json",
+ body: JSON.stringify(body),
+ });
+
+ if (endpoint === "/api/network/graph") {
+ return respond({
+ nodes: [
+ { id: "source", label: "source@example.com" },
+ { id: "thread", label: "source-thread" },
+ ],
+ edges: [{ from: "source", to: "thread" }],
+ });
+ }
+
+ if (endpoint === "/api/emails" && request.method() === "GET") {
+ return respond({ emails: [sourceEmail] });
+ }
+ if (endpoint === "/api/emails/23") return respond(sourceEmail);
+ if (endpoint === "/api/emails/thread/source-thread") return respond({ thread: [sourceEmail] });
+ if (endpoint === "/api/llm/summarize") {
+ return respond({
+ summary: "근거 원본을 확인해야 하는 맥락 종합입니다.",
+ todos: ["원본 확인"],
+ confidence: 0.86,
+ provenance: "mail-thread",
+ });
+ }
+ if (endpoint === "/api/llm/draft") return respond({ draft: sensitiveDraftBody });
+ if (endpoint === "/api/emails/send") return respond({ simulated: true });
+ if (endpoint === "/api/tasks/from-email") return respond({ created: 1 });
+ if (endpoint === "/api/calendar/writeback-intent") {
+ return respond({
+ intent_id: "calendar-intent-1",
+ target_source_id: "calendar-source-1",
+ provider_write_executed: false,
+ });
+ }
+
+ if (endpoint === "/api/search" && request.method() === "POST") {
+ const body = request.postDataJSON?.() ?? JSON.parse(request.postData() || "{}");
+ const isContractSearch = body.query === rawSearchQuery;
+ return respond({
+ results: [{
+ id: isContractSearch ? 202 : 101,
+ source_message_id: isContractSearch ? "" : "",
+ subject: isContractSearch ? "계약 검토 결과" : "런칭 캠페인 결과",
+ sender: "pm@example.com",
+ date: "2026-05-20T09:00:00Z",
+ snippet: "검색 결과에서 관계 캡처 액션을 실행할 수 있습니다.",
+ thread_id: isContractSearch ? "thread-contract" : "thread-launch",
+ reply_count: 2,
+ score: 0.93,
+ }],
+ });
+ }
+ if (endpoint === "/api/ontology/relationships" && request.method() === "GET") return respond([]);
+ if (endpoint === "/api/ontology/relationships/capture-source") {
+ return respond({
+ sender_email: "pm@example.com",
+ parent_sender_email: null,
+ source_message_id: "",
+ source_thread_id: "thread-contract",
+ relationship_type: "sender_context",
+ confidence_score: 0.91,
+ next_action: "계약 검토 담당자를 확인합니다.",
+ action_reason: "검색 결과 원본 메시지의 후속 조치입니다.",
+ });
+ }
+
+ if (endpoint === "/api/tasks") return respond([]);
+ if (endpoint === "/api/calendar/writeback-sources") return respond([]);
+ if (endpoint === "/api/webdav/folders") return respond([]);
+ return respond({ ok: true });
+ });
+}
+
+async function preparePage(context, consoleIssues) {
+ const page = await context.newPage();
+ page.on("console", (message) => {
+ if (message.type() === "error" || message.type() === "warning") {
+ consoleIssues.push(`${message.type()}: ${message.text()}`);
+ }
+ });
+ page.on("pageerror", (error) => consoleIssues.push(`pageerror: ${error.message}`));
+ await page.addInitScript(() => {
+ window.__naruonEvents = [];
+ window.addEventListener("naruon:product-event", (event) => {
+ window.__naruonEvents.push(event.detail);
+ });
+ });
+ await installRoutes(page);
+ return page;
+}
+
+function assertNoSensitiveEventText(eventText, forbiddenValues) {
+ for (const value of forbiddenValues) {
+ if (eventText.includes(value)) {
+ throw new Error(`Sensitive text leaked into product event payload: ${value}`);
+ }
+ }
+}
+
+async function runMailFlow(context, consoleIssues) {
+ const page = await preparePage(context, consoleIssues);
+ await page.goto(`${baseUrl}/mail`, { waitUntil: "domcontentloaded" });
+
+ const desktopMailRegion = page.getByRole("region", { name: "데스크톱 메일 작업공간" });
+ await desktopMailRegion.waitFor({ state: "visible", timeout: 20_000 });
+
+ const mailListItem = desktopMailRegion.locator("button", { hasText: "Source Drawer" });
+ await mailListItem.waitFor({ state: "visible", timeout: 20_000 });
+ if (await mailListItem.count() !== 1) throw new Error("Expected exactly one pilot inbox item");
+ await mailListItem.click();
+
+ await desktopMailRegion
+ .locator("text=근거 원본을 확인해야 하는 맥락 종합입니다.")
+ .first()
+ .waitFor({ state: "visible", timeout: 20_000 });
+
+ const sourceButtonCandidates = desktopMailRegion
+ .locator("button", { hasText: "근거 원본 보기" });
+ const sourceButtonCount = await sourceButtonCandidates.count();
+ if (sourceButtonCount === 0) {
+ throw new Error("Could not find any source drawer button candidates");
+ }
+ const sourceButton = sourceButtonCandidates.first();
+ if (await sourceButton.count() === 0) throw new Error("Could not locate source drawer button after normalization");
+ if (!(await sourceButton.first().isVisible())) {
+ await sourceButton.first().scrollIntoViewIfNeeded();
+ }
+
+ if (!(await sourceButton.first().isVisible())) {
+ const sourceDrawerCandidates = await page
+ .getByRole("button", { name: /근거 원본 보기/ })
+ .allTextContents();
+ throw new Error(`Source drawer button is not visible; candidates: ${JSON.stringify(sourceDrawerCandidates)}`);
+ }
+ await sourceButton.first().click();
+
+ const dialog = page.getByRole("dialog", { name: "맥락 종합 근거" });
+ await dialog.waitFor({ state: "visible", timeout: 10_000 });
+ const drawerState = await page.evaluate(() => ({
+ activeLabel: document.activeElement?.getAttribute("aria-label"),
+ ariaModal: document.querySelector("[role=\"dialog\"]")?.getAttribute("aria-modal"),
+ }));
+ if (drawerState.activeLabel !== "근거 원본 닫기") throw new Error("Source drawer did not focus the close button");
+ if (drawerState.ariaModal !== "true") throw new Error("Source drawer is missing aria-modal=true");
+ await page.getByRole("button", { name: "근거 원본 닫기" }).click();
+ await dialog.waitFor({ state: "hidden", timeout: 10_000 });
+ await sourceButton.click();
+ await dialog.waitFor({ state: "visible", timeout: 10_000 });
+ await page.keyboard.press("Escape");
+ await dialog.waitFor({ state: "hidden", timeout: 10_000 });
+
+ await desktopMailRegion.getByRole("button", { name: "답장 초안 생성" }).click();
+ const draftTextarea = desktopMailRegion.getByRole("textbox", { name: "답장 초안", exact: true });
+ await draftTextarea.waitFor({ state: "visible", timeout: 10_000 });
+ await page.waitForFunction((expectedDraft) => {
+ const textarea = document.querySelector("textarea[aria-label=\"답장 초안\"]");
+ return textarea?.value === expectedDraft;
+ }, sensitiveDraftBody);
+
+ await desktopMailRegion.getByRole("button", { name: "답장 보내기" }).click();
+ await desktopMailRegion
+ .getByText("개발 모드에서 답장을 시뮬레이션했습니다. 실제 메일은 전송되지 않았습니다.", { exact: true })
+ .waitFor({ state: "visible", timeout: 10_000 });
+
+ await desktopMailRegion.getByRole("button", { name: "실행 항목 생성" }).click();
+ await desktopMailRegion
+ .getByText("1개 실행 항목을 티켓형 실행 항목으로 추적합니다.", { exact: true })
+ .waitFor({ state: "visible", timeout: 10_000 });
+
+ await desktopMailRegion.getByRole("button", { name: "일정 반영" }).click();
+ await desktopMailRegion
+ .getByText("1개 일정 반영 의도를 선택한 원본 계정에 요청했습니다.", { exact: true })
+ .waitFor({ state: "visible", timeout: 10_000 });
+
+ await page.screenshot({ path: mailScreenshotPath, fullPage: false });
+ const eventState = await page.evaluate(() => ({
+ events: window.__naruonEvents,
+ eventText: JSON.stringify(window.__naruonEvents),
+ }));
+ const requiredEvents = [
+ "context_synthesis_viewed",
+ "source_chip_opened",
+ "draft_reply_generated",
+ "draft_reply_inserted",
+ "draft_reply_sent",
+ "action_item_created",
+ "calendar_reflected",
+ ];
+ for (const eventName of requiredEvents) {
+ if (!eventState.events.some((event) => event.name === eventName)) {
+ throw new Error(`Missing mail product event: ${eventName}`);
+ }
+ }
+ assertNoSensitiveEventText(eventState.eventText, [sensitiveMailBody, sensitiveDraftBody]);
+ await page.close();
+ return requiredEvents;
+}
+
+async function runSearchFlow(context, consoleIssues) {
+ const page = await preparePage(context, consoleIssues);
+ await page.goto(`${baseUrl}/search`, { waitUntil: "domcontentloaded" });
+
+ const searchDetail = page.getByLabel("맥락 검색 결과 상세");
+ await searchDetail.getByRole("heading", { name: "런칭 캠페인 결과" }).waitFor({ state: "visible", timeout: 20_000 });
+
+ const searchInput = page.getByRole("searchbox", { name: "맥락 검색어 입력" });
+ if (await searchInput.count() !== 1) throw new Error("Expected exactly one context search input");
+ await searchInput.fill(rawSearchQuery);
+
+ const searchForm = page.locator("form").filter({ has: searchInput });
+ if (await searchForm.count() !== 1) throw new Error("Expected exactly one context search form");
+ const submitButton = searchForm.getByRole("button", { name: "맥락 검색", exact: true });
+ if (await submitButton.count() !== 1) throw new Error("Expected exactly one context search submit button");
+ await submitButton.click();
+
+ await searchDetail.getByRole("heading", { name: "계약 검토 결과" }).waitFor({ state: "visible", timeout: 20_000 });
+ const captureButton = page.getByRole("button", { name: "발신자 관계 캡처" });
+ await captureButton.waitFor({ state: "visible", timeout: 10_000 });
+ if (await captureButton.count() !== 1) throw new Error("Expected exactly one relationship capture button");
+ await captureButton.click();
+
+ await page.getByText("계약 검토 담당자를 확인합니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
+ await page.screenshot({ path: searchScreenshotPath, fullPage: false });
+
+ const eventState = await page.evaluate(() => ({
+ events: window.__naruonEvents,
+ eventText: JSON.stringify(window.__naruonEvents),
+ }));
+ const requiredEvents = [
+ "context_search_submitted",
+ "context_search_result_opened",
+ "context_search_result_action_created",
+ ];
+ for (const eventName of requiredEvents) {
+ if (!eventState.events.some((event) => event.name === eventName)) {
+ throw new Error(`Missing search product event: ${eventName}`);
+ }
+ }
+ assertNoSensitiveEventText(eventState.eventText, [rawSearchQuery]);
+ await page.close();
+ return requiredEvents;
+}
+
+async function main() {
+ let serverProcess = null;
+ let browser = null;
+ try {
+ serverProcess = await startServerIfNeeded();
+ browser = await launchBrowser();
+ const context = await browser.newContext({ viewport: { width: 1440, height: 1024 } });
+ const consoleIssues = [];
+
+ const mailEvents = await runMailFlow(context, consoleIssues);
+ const searchEvents = await runSearchFlow(context, consoleIssues);
+
+ if (consoleIssues.length > 0) {
+ throw new Error(`Console issues detected:\n${consoleIssues.join("\n")}`);
+ }
+
+ await context.close();
+ log("Naruon pilot smoke passed.");
+ log(`Mail events: ${mailEvents.join(", ")}`);
+ log(`Search events: ${searchEvents.join(", ")}`);
+ log(`Screenshots: ${mailScreenshotPath}, ${searchScreenshotPath}`);
+ } finally {
+ if (browser) await browser.close().catch(() => {});
+ if (serverProcess) serverProcess.kill("SIGTERM");
+ }
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+ });
+}
diff --git a/frontend/scripts/pilot-ui-smoke.test.mjs b/frontend/scripts/pilot-ui-smoke.test.mjs
new file mode 100644
index 000000000..fecd94886
--- /dev/null
+++ b/frontend/scripts/pilot-ui-smoke.test.mjs
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+
+import { resolvePilotBaseUrl } from "./pilot-ui-smoke.mjs";
+
+describe("pilot UI smoke base URL guard", () => {
+ it("allows localhost pilot smoke targets", () => {
+ expect(resolvePilotBaseUrl("http://127.0.0.1:3001").hostname).toBe("127.0.0.1");
+ expect(resolvePilotBaseUrl("http://localhost:3001").hostname).toBe("localhost");
+ expect(resolvePilotBaseUrl("http://[::1]:3001").hostname).toBe("[::1]");
+ });
+
+ it("rejects non-localhost targets", () => {
+ expect(() => resolvePilotBaseUrl("https://staging.example.com")).toThrow("localhost targets");
+ expect(() => resolvePilotBaseUrl("https://naruon.example.com")).toThrow("localhost targets");
+ });
+});
diff --git a/frontend/src/app/projects/page.test.tsx b/frontend/src/app/projects/page.test.tsx
index e055984d5..f1c775774 100644
--- a/frontend/src/app/projects/page.test.tsx
+++ b/frontend/src/app/projects/page.test.tsx
@@ -38,6 +38,12 @@ async function flushAsyncWork() {
});
}
+function setNativeValue(element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, value: string) {
+ const prototype = Object.getPrototypeOf(element);
+ const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
+ prototypeValueSetter?.call(element, value);
+}
+
describe("ProjectsPage", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
@@ -147,6 +153,27 @@ describe("ProjectsPage", () => {
expect(container.textContent).toContain("의사결정 추가");
expect(container.textContent).toContain("관련 문서/메일 연결");
expect(container.textContent).not.toContain("Naruon 2.0 런칭");
+
+ const evidenceNote = container.querySelector('#project-evidence-note');
+ const evidenceSource = container.querySelector('#project-evidence-source');
+ const saveButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes("근거 저장"));
+ expect(evidenceNote).not.toBeNull();
+ expect(evidenceSource).not.toBeNull();
+ expect(saveButton).toBeDefined();
+
+ await act(async () => {
+ setNativeValue(evidenceNote!, "이사회 승인 근거와 WebDAV 경계를 함께 검토합니다.");
+ evidenceNote!.dispatchEvent(new Event("input", { bubbles: true }));
+ setNativeValue(evidenceSource!, "document");
+ evidenceSource!.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ expect(container.textContent).toContain("문서 저장소 승인 기록 기준");
+
+ await act(async () => {
+ saveButton!.click();
+ });
+ expect(container.textContent).toContain("프로젝트 근거가 저장되었습니다: 문서 근거");
+ expect(container.textContent).toContain("이사회 승인 근거와 WebDAV 경계를 함께 검토합니다.");
});
it("renders the semantic project graph command center with paragraph citations", async () => {
diff --git a/frontend/src/app/security/page.test.tsx b/frontend/src/app/security/page.test.tsx
index 4dd3a1679..40f80b580 100644
--- a/frontend/src/app/security/page.test.tsx
+++ b/frontend/src/app/security/page.test.tsx
@@ -108,10 +108,32 @@ const securitySurface = {
function mockSecurityFetch(surface: typeof securitySurface = securitySurface) {
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
- void init;
if (String(input) === "/api/security/access-surface") {
return jsonResponse(surface);
}
+ if (String(input) === "/api/security/permission-change-intent") {
+ const requestBody = JSON.parse(String(init?.body ?? "{}")) as { decision?: string; resource_type?: string };
+ const reasonByDecision: Record = {
+ allow_writeback: "allowed",
+ deny_external_write: "organization_denied",
+ deny_workspace_write: "workspace_denied",
+ deny_region_export: "data_region_denied",
+ deny_missing_consent: "consent_denied",
+ };
+ const reason = reasonByDecision[requestBody.decision ?? ""] ?? "organization_denied";
+ const allowed = requestBody.decision === "allow_writeback";
+ return jsonResponse({
+ decision: requestBody.decision,
+ resource_type: requestBody.resource_type,
+ allowed,
+ reason,
+ evidence_label: "policy_engine_evidence",
+ audit_event: "security.permission_change_intent",
+ provider_write_executed: false,
+ denial_result: allowed ? "approval_required_before_external_write" : "provider_denied_by_policy",
+ observed_at: "2026-05-28T04:05:00Z",
+ });
+ }
throw new Error(`Unhandled fetch: ${String(input)}`);
});
}
@@ -128,6 +150,12 @@ async function renderSecurityPage() {
return { container, root };
}
+function setNativeValue(element: HTMLSelectElement, value: string) {
+ const prototype = Object.getPrototypeOf(element);
+ const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
+ prototypeValueSetter?.call(element, value);
+}
+
describe("SecurityPage", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
@@ -159,6 +187,28 @@ describe("SecurityPage", () => {
expect(container.textContent).not.toContain("곧 제공됩니다");
expect(container.textContent).not.toContain("비정상 로그인 시도");
+ const permissionDecision = container.querySelector("#security-permission-decision");
+ const saveButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("권한 저장"));
+ expect(permissionDecision).not.toBeNull();
+ expect(saveButton).toBeDefined();
+
+ await act(async () => {
+ setNativeValue(permissionDecision!, "deny_external_write");
+ permissionDecision!.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ expect(container.textContent).toContain("조직 차단 - 외부 쓰기 실행 안 함");
+
+ await act(async () => {
+ saveButton!.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(container.textContent).toContain("권한 변경이 저장되었습니다: 외부 쓰기 차단");
+ expect(container.textContent).toContain("서버 감사 이벤트");
+ expect(container.textContent).toContain("security.permission_change_intent");
+ expect(container.textContent).toContain("제공자 쓰기");
+ expect(container.textContent).toContain("실행 안 함");
+
const accessCall = fetchMock.mock.calls.find(([input]) => String(input) === "/api/security/access-surface");
expect(accessCall).toBeDefined();
const [, init] = accessCall ?? [];
@@ -181,6 +231,38 @@ describe("SecurityPage", () => {
]) {
expect(requestHeaders[publicHeader]).toBeUndefined();
}
+
+ const permissionIntentCall = fetchMock.mock.calls.find(([input]) => String(input) === "/api/security/permission-change-intent");
+ expect(permissionIntentCall).toBeDefined();
+ const [, permissionIntentInit] = permissionIntentCall ?? [];
+ expect(permissionIntentInit?.credentials).toBe("same-origin");
+ expect(JSON.parse(String(permissionIntentInit?.body))).toEqual({
+ decision: "deny_external_write",
+ resource_type: "provider_secret",
+ });
+
+ await act(async () => {
+ setNativeValue(permissionDecision!, "deny_region_export");
+ permissionDecision!.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ expect(container.textContent).toContain("리전 차단 - 외부 쓰기 실행 안 함");
+
+ await act(async () => {
+ saveButton!.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(container.textContent).toContain("정책 결과");
+ expect(container.textContent).toContain("리전 차단");
+ expect(container.textContent).toContain("데이터 내보내기");
+
+ const permissionIntentCalls = fetchMock.mock.calls.filter(
+ ([input]) => String(input) === "/api/security/permission-change-intent",
+ );
+ expect(JSON.parse(String(permissionIntentCalls.at(-1)?.[1]?.body))).toEqual({
+ decision: "deny_region_export",
+ resource_type: "data_export",
+ });
});
it("renders audit sharing and policy tabs without inert placeholders", async () => {
diff --git a/frontend/src/components/EmailDetail.test.tsx b/frontend/src/components/EmailDetail.test.tsx
index 3545c27ba..5a46737a6 100644
--- a/frontend/src/components/EmailDetail.test.tsx
+++ b/frontend/src/components/EmailDetail.test.tsx
@@ -45,12 +45,19 @@ vi.mock("@/components/ui/input", () => ({
vi.mock("lucide-react", () => ({
MessagesSquare: () => ,
AlertCircle: () => ,
+ ExternalLink: () => ,
+ FileText: () => ,
RefreshCw: () => ,
Info: () => ,
Loader2: () => ,
+ X: () => ,
}));
import { EmailDetail } from "./EmailDetail";
+import {
+ clearRecordedProductEvents,
+ getRecordedProductEvents,
+} from "@/lib/product-events";
type Deferred = {
promise: Promise;
@@ -111,6 +118,7 @@ describe("EmailDetail", () => {
container?.remove();
container = null;
vi.unstubAllGlobals();
+ clearRecordedProductEvents();
});
it("translates email content when the Translate button is clicked", async () => {
@@ -389,6 +397,89 @@ describe("EmailDetail", () => {
expect(cards.find((card) => card.getAttribute("aria-label") === "답장 초안")?.querySelector('textarea[aria-label="답장 초안"]')).not.toBeNull();
});
+ it("opens an accessible source drawer from the source chip and records source evidence events", async () => {
+ const email: TestEmail = {
+ id: 23,
+ message_id: "",
+ thread_id: "source-thread",
+ sender: "source@example.com",
+ recipients: "user@example.com",
+ subject: "Source Drawer",
+ date: "2026-05-19T09:00:00Z",
+ body: "Sensitive source body must stay out of analytics payloads.",
+ };
+
+ vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/api/emails/23")) return Promise.resolve(jsonResponse(email));
+ if (url.endsWith("/api/emails/thread/source-thread")) return Promise.resolve(jsonResponse({ thread: [email] }));
+ if (url.endsWith("/api/llm/summarize")) {
+ return Promise.resolve(jsonResponse({
+ summary: "근거 원본을 확인해야 하는 맥락 종합입니다.",
+ todos: ["원본 확인"],
+ confidence: 0.86,
+ provenance: "mail-thread",
+ }));
+ }
+ throw new Error(`Unexpected fetch: ${url}`);
+ }));
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render();
+ });
+ await waitForCondition(() => container?.textContent?.includes("근거 원본을 확인해야 하는 맥락 종합입니다.") ?? false);
+
+ expect(getRecordedProductEvents().some((event) => event.name === "context_synthesis_viewed")).toBe(true);
+
+ const sourceButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("근거 원본 보기"),
+ );
+ expect(sourceButton).not.toBeUndefined();
+
+ await act(async () => {
+ sourceButton?.click();
+ });
+
+ const dialog = container.querySelector('[role="dialog"][aria-modal="true"]');
+ expect(dialog).not.toBeNull();
+ expect(dialog?.textContent).toContain("맥락 종합 근거");
+ expect(dialog?.textContent).toContain("");
+ expect(document.activeElement?.getAttribute("aria-label")).toBe("근거 원본 닫기");
+
+ const sourceEvent = getRecordedProductEvents().find((event) => event.name === "source_chip_opened");
+ expect(sourceEvent?.payload).toMatchObject({
+ surface: "mail_detail",
+ source_chip_id: "source-chip:23",
+ ai_output_id: "mail-synthesis:source-thread",
+ source_id: "",
+ source_type: "mail",
+ opened_from: "synthesis_card",
+ });
+ expect(JSON.stringify(getRecordedProductEvents())).not.toContain("Sensitive source body");
+
+ await act(async () => {
+ container?.querySelector('button[aria-label="근거 원본 닫기"]')?.click();
+ });
+ await flushAsyncWork();
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
+
+ await act(async () => {
+ sourceButton?.click();
+ });
+ expect(container.querySelector('[role="dialog"][aria-modal="true"]')).not.toBeNull();
+
+ await act(async () => {
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+ });
+ await flushAsyncWork();
+
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
+ });
+
it("lets users create tasks from visible execution items in the email detail", async () => {
const email: TestEmail = {
id: 14,
@@ -458,6 +549,11 @@ describe("EmailDetail", () => {
expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain("/api/tasks/from-email");
expect(actionCard?.textContent).toContain("2개 실행 항목을 티켓형 실행 항목으로 추적합니다.");
+ expect(getRecordedProductEvents().some((event) =>
+ event.name === "action_item_created" &&
+ event.payload.source_backlink_present === true &&
+ event.payload.thread_id === "",
+ )).toBe(true);
});
it("clears conversation loading when the latest email has no thread", async () => {
@@ -629,6 +725,10 @@ describe("EmailDetail", () => {
expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain("/api/llm/draft");
expect(container.querySelector('#reply-draft')?.value).toBe("초안 답장입니다.");
+ expect(getRecordedProductEvents().map((event) => event.name)).toEqual(
+ expect.arrayContaining(["draft_reply_generated", "draft_reply_inserted"]),
+ );
+ expect(JSON.stringify(getRecordedProductEvents())).not.toContain("초안 답장입니다.");
await act(async () => {
root?.render();
@@ -755,6 +855,11 @@ describe("EmailDetail", () => {
expect(container.textContent).not.toContain("Customer CalDAV");
expect(container.textContent).not.toContain("caldav-primary");
expect(container.textContent).not.toContain("calendar.writeback_intent.created");
+ expect(getRecordedProductEvents().some((event) =>
+ event.name === "calendar_reflected" &&
+ event.payload.calendar_candidate_id === "mail-calendar:9" &&
+ event.payload.provider_write_executed === false,
+ )).toBe(true);
});
it("ignores a late draft response after the selected email changes", async () => {
@@ -1109,6 +1214,10 @@ describe("EmailDetail", () => {
expect(container.textContent).toContain("실제 메일은 전송되지 않았습니다");
expect(draftInput?.value).toBe("");
+ expect(getRecordedProductEvents().some((event) =>
+ event.name === "draft_reply_sent" &&
+ event.payload.send_mode === "simulated",
+ )).toBe(true);
});
it("handles send message failure", async () => {
diff --git a/frontend/src/components/EmailDetail.tsx b/frontend/src/components/EmailDetail.tsx
index 2970917b9..873fd17f1 100644
--- a/frontend/src/components/EmailDetail.tsx
+++ b/frontend/src/components/EmailDetail.tsx
@@ -10,6 +10,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Loader2, MessagesSquare } from "lucide-react";
import { DecisionPointCard } from "@/components/DecisionPointCard";
+import { SourceDrawer } from "@/components/SourceDrawer";
import {
buildThreadUrl,
buildReplyPayload,
@@ -19,6 +20,11 @@ import {
} from "@/lib/email-threading";
import { toMailBodyText, toMailDisplayText } from "@/lib/mail-text";
import { toConfidencePercent } from "@/lib/confidence";
+import {
+ bucketTextLength,
+ createProductEventId,
+ recordProductEvent,
+} from "@/lib/product-events";
type EmailData = ThreadEmailData & {
requires_reply?: boolean;
@@ -53,6 +59,18 @@ type EmailDetailActionCommand = {
action: string;
};
+function getThreadEventId(email: EmailData) {
+ return email.thread_id || email.message_id || `email-${email.id}`;
+}
+
+function getMessageEventId(email: EmailData) {
+ return email.message_id || `email-${email.id}`;
+}
+
+function nowMs() {
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
+}
+
export function EmailDetail({ emailId, actionCommand = null }: { emailId: number | null; actionCommand?: EmailDetailActionCommand | null }) {
const [email, setEmail] = useState(null);
const [threadEmails, setThreadEmails] = useState([]);
@@ -78,9 +96,12 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
const [isCreatingTask, setIsCreatingTask] = useState(false);
const [syncStatus, setSyncStatus] = useState<{type: 'success' | 'error', message: string} | null>(null);
const [taskStatus, setTaskStatus] = useState(null);
+ const [sourceDrawerOpen, setSourceDrawerOpen] = useState(false);
const threadRequestIdRef = useRef(0);
const handledActionCommandIdRef = useRef(null);
const currentEmailIdRef = useRef(emailId);
+ const contextSynthesisEventKeyRef = useRef(null);
+ const activeDraftReplyIdRef = useRef(null);
useEffect(() => {
currentEmailIdRef.current = emailId;
@@ -146,6 +167,9 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
setIsCreatingTask(false);
setSyncStatus(null);
setTaskStatus(null);
+ setSourceDrawerOpen(false);
+ activeDraftReplyIdRef.current = null;
+ contextSynthesisEventKeyRef.current = null;
try {
const emailJson = await apiClient.get(`/api/emails/${emailId}`);
@@ -160,14 +184,29 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
});
// Fetch LLM summary in the background
+ const synthesisStartedAt = nowMs();
apiClient.post('/api/llm/summarize', { email_body: emailJson.body })
.then((llmJson) => {
if (!isMounted) return;
setLlmData(llmJson);
+ recordProductEvent("latency_guardrail_recorded", {
+ surface: "mail_detail",
+ request_trace_id: createProductEventId("synthesis_trace"),
+ operation: "synthesis",
+ duration_ms: Math.round(nowMs() - synthesisStartedAt),
+ status: "success",
+ });
})
.catch((llmErr) => {
console.error("Error generating LLM summary:", llmErr);
if (isMounted) setLlmError("맥락 종합을 생성하지 못했습니다.");
+ recordProductEvent("latency_guardrail_recorded", {
+ surface: "mail_detail",
+ request_trace_id: createProductEventId("synthesis_trace"),
+ operation: "synthesis",
+ duration_ms: Math.round(nowMs() - synthesisStartedAt),
+ status: "error",
+ });
});
} catch (err) {
@@ -184,6 +223,37 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
return () => { isMounted = false; };
}, [emailId, fetchThread]);
+ useEffect(() => {
+ if (!email || !llmData) return;
+
+ const eventKey = `${email.id}:${getThreadEventId(email)}`;
+ if (contextSynthesisEventKeyRef.current === eventKey) return;
+ contextSynthesisEventKeyRef.current = eventKey;
+
+ const confidence = toConfidencePercent(llmData.confidence);
+ const aiOutputId = `mail-synthesis:${getThreadEventId(email)}`;
+ recordProductEvent("context_synthesis_viewed", {
+ surface: "mail_detail",
+ thread_id: getThreadEventId(email),
+ message_id: getMessageEventId(email),
+ ai_output_id: aiOutputId,
+ confidence,
+ source_count: 1,
+ view_state: "loaded",
+ });
+
+ if (confidence !== undefined && confidence < 50) {
+ recordProductEvent("model_quality_guardrail_recorded", {
+ surface: "mail_detail",
+ guardrail_evaluation_id: createProductEventId("quality_guardrail"),
+ ai_output_id: aiOutputId,
+ quality_signal: "low_confidence",
+ confidence,
+ human_feedback_present: false,
+ });
+ }
+ }, [email, llmData]);
+
const handleTranslate = useCallback(async () => {
if (!email) return;
const actionEmailId = email.id;
@@ -207,17 +277,59 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
if (!email) return;
const actionEmailId = email.id;
const isCurrentEmail = () => currentEmailIdRef.current === actionEmailId;
+ const startedAt = nowMs();
setIsDrafting(true);
setDraftError(null);
setSendStatus(null);
try {
const data = await apiClient.post<{ draft: string }>('/api/llm/draft', { email_body: email.body, instruction });
if (!isCurrentEmail()) return;
- setDraft(data.draft || '');
+ const nextDraft = data.draft || '';
+ const draftReplyId = createProductEventId("draft_reply");
+ activeDraftReplyIdRef.current = draftReplyId;
+ setDraft(nextDraft);
+ recordProductEvent("draft_reply_generated", {
+ surface: "mail_detail",
+ draft_reply_id: draftReplyId,
+ thread_id: getThreadEventId(email),
+ message_id: getMessageEventId(email),
+ instruction_present: Boolean(instruction.trim()),
+ generation_state: "success",
+ });
+ recordProductEvent("draft_reply_inserted", {
+ surface: "mail_detail",
+ draft_reply_id: draftReplyId,
+ thread_id: getThreadEventId(email),
+ message_id: getMessageEventId(email),
+ insert_source: "generated",
+ character_count_bucket: bucketTextLength(nextDraft),
+ });
+ recordProductEvent("latency_guardrail_recorded", {
+ surface: "mail_detail",
+ request_trace_id: createProductEventId("draft_trace"),
+ operation: "draft_reply",
+ duration_ms: Math.round(nowMs() - startedAt),
+ status: "success",
+ });
} catch (err) {
if (!isCurrentEmail()) return;
console.error("Error drafting reply:", err);
setDraftError("답장 초안을 생성하지 못했습니다.");
+ recordProductEvent("draft_reply_generated", {
+ surface: "mail_detail",
+ draft_reply_id: createProductEventId("draft_reply"),
+ thread_id: getThreadEventId(email),
+ message_id: getMessageEventId(email),
+ instruction_present: Boolean(instruction.trim()),
+ generation_state: "error",
+ });
+ recordProductEvent("latency_guardrail_recorded", {
+ surface: "mail_detail",
+ request_trace_id: createProductEventId("draft_trace"),
+ operation: "draft_reply",
+ duration_ms: Math.round(nowMs() - startedAt),
+ status: "error",
+ });
} finally {
if (isCurrentEmail()) setIsDrafting(false);
}
@@ -225,6 +337,9 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
const handleSendReply = async () => {
if (!email || !draft) return;
+ const startedAt = nowMs();
+ const draftReplyId = activeDraftReplyIdRef.current || createProductEventId("draft_reply");
+ activeDraftReplyIdRef.current = draftReplyId;
setIsSending(true);
setSendStatus(null);
try {
@@ -236,10 +351,33 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
: '답장을 전송했습니다.',
});
setDraft('');
+ activeDraftReplyIdRef.current = null;
+ recordProductEvent("draft_reply_sent", {
+ surface: "mail_detail",
+ draft_reply_id: draftReplyId,
+ thread_id: getThreadEventId(email),
+ message_id: getMessageEventId(email),
+ send_mode: data.simulated ? "simulated" : "provider_send",
+ final_review_duration_ms: Math.round(nowMs() - startedAt),
+ });
+ recordProductEvent("latency_guardrail_recorded", {
+ surface: "mail_detail",
+ request_trace_id: createProductEventId("draft_send_trace"),
+ operation: "draft_reply",
+ duration_ms: Math.round(nowMs() - startedAt),
+ status: "success",
+ });
await fetchThread(email);
} catch (err) {
console.error("Error sending email:", err);
setSendStatus({ type: 'error', message: '답장 전송에 실패했습니다.' });
+ recordProductEvent("latency_guardrail_recorded", {
+ surface: "mail_detail",
+ request_trace_id: createProductEventId("draft_send_trace"),
+ operation: "draft_reply",
+ duration_ms: Math.round(nowMs() - startedAt),
+ status: "error",
+ });
} finally {
setIsSending(false);
}
@@ -254,6 +392,7 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
}
setIsSyncing(true);
setSyncStatus(null);
+ const startedAt = nowMs();
try {
const intents = await Promise.all(
llmData.todos.map((summary) =>
@@ -265,13 +404,35 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
);
if (!isCurrentEmail()) return;
setSyncStatus({ type: 'success', message: `${intents.length}개 일정 반영 의도를 선택한 원본 계정에 요청했습니다.` });
+ recordProductEvent("calendar_reflected", {
+ surface: "mail_detail",
+ calendar_candidate_id: `mail-calendar:${actionEmailId ?? "unknown"}`,
+ calendar_event_id: intents[0]?.target_source_id ?? null,
+ thread_id: email ? getThreadEventId(email) : null,
+ conflict_state: "none",
+ provider_write_executed: intents.some((intent) => Boolean(intent.provider_write_executed)),
+ });
+ recordProductEvent("latency_guardrail_recorded", {
+ surface: "mail_detail",
+ request_trace_id: createProductEventId("calendar_trace"),
+ operation: "calendar_reflection",
+ duration_ms: Math.round(nowMs() - startedAt),
+ status: "success",
+ });
} catch {
if (!isCurrentEmail()) return;
setSyncStatus({ type: 'error', message: '일정 반영 의도 요청에 실패했습니다.' });
+ recordProductEvent("latency_guardrail_recorded", {
+ surface: "mail_detail",
+ request_trace_id: createProductEventId("calendar_trace"),
+ operation: "calendar_reflection",
+ duration_ms: Math.round(nowMs() - startedAt),
+ status: "error",
+ });
} finally {
if (isCurrentEmail()) setIsSyncing(false);
}
- }, [emailId, llmData]);
+ }, [email, emailId, llmData]);
const handleCreateTask = useCallback(async () => {
const actionEmail = email;
@@ -284,6 +445,7 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
}
setIsCreatingTask(true);
setTaskStatus(null);
+ const startedAt = nowMs();
try {
const data = await apiClient.post('/api/tasks/from-email', {
source_email_id: actionEmail.message_id,
@@ -292,9 +454,32 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
});
if (!isCurrentEmail()) return;
setTaskStatus(`${data.created}개 실행 항목을 티켓형 실행 항목으로 추적합니다.`);
+ recordProductEvent("action_item_created", {
+ surface: "mail_detail",
+ action_item_id: `mail-task:${getMessageEventId(actionEmail)}:${data.created}`,
+ thread_id: getThreadEventId(actionEmail),
+ decision_point_id: `mail-synthesis:${getThreadEventId(actionEmail)}`,
+ assignee_type: "unassigned",
+ due_date_present: false,
+ source_backlink_present: true,
+ });
+ recordProductEvent("latency_guardrail_recorded", {
+ surface: "mail_detail",
+ request_trace_id: createProductEventId("task_trace"),
+ operation: "task_creation",
+ duration_ms: Math.round(nowMs() - startedAt),
+ status: "success",
+ });
} catch {
if (!isCurrentEmail()) return;
setTaskStatus('티켓형 실행 항목 생성에 실패했습니다.');
+ recordProductEvent("latency_guardrail_recorded", {
+ surface: "mail_detail",
+ request_trace_id: createProductEventId("task_trace"),
+ operation: "task_creation",
+ duration_ms: Math.round(nowMs() - startedAt),
+ status: "error",
+ });
} finally {
if (isCurrentEmail()) setIsCreatingTask(false);
}
@@ -351,6 +536,39 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
const safeReplyTo = toMailDisplayText(email.reply_to || email.sender, '답장 주소 없음');
const confidencePercent = toConfidencePercent(llmData?.confidence);
+ const handleOpenSourceDrawer = () => {
+ recordProductEvent("source_chip_opened", {
+ surface: "mail_detail",
+ source_chip_id: `source-chip:${email.id}`,
+ ai_output_id: `mail-synthesis:${getThreadEventId(email)}`,
+ source_id: getMessageEventId(email),
+ source_type: "mail",
+ opened_from: "synthesis_card",
+ });
+ setSourceDrawerOpen(true);
+ };
+
+ const handleOpenOriginalSource = () => {
+ document.getElementById(`msg-${email.id}`)?.scrollIntoView?.({ block: "center" });
+ setSourceDrawerOpen(false);
+ };
+
+ const handleDraftChange = (nextDraft: string) => {
+ if (!draft && nextDraft && !activeDraftReplyIdRef.current) {
+ const draftReplyId = createProductEventId("draft_reply");
+ activeDraftReplyIdRef.current = draftReplyId;
+ recordProductEvent("draft_reply_inserted", {
+ surface: "mail_detail",
+ draft_reply_id: draftReplyId,
+ thread_id: getThreadEventId(email),
+ message_id: getMessageEventId(email),
+ insert_source: "manual",
+ character_count_bucket: bucketTextLength(nextDraft),
+ });
+ }
+ setDraft(nextDraft);
+ };
+
return (
@@ -409,9 +627,13 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
) : null}
@@ -574,7 +796,7 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
id="reply-draft"
aria-label="답장 초안"
value={draft}
- onChange={(e) => setDraft(e.target.value)}
+ onChange={(e) => handleDraftChange(e.target.value)}
placeholder="답장 초안을 작성하거나 판단 보조로 초안을 생성하세요..."
className="min-h-[150px] rounded-2xl border-purple-500/20 bg-background/70"
/>
@@ -590,7 +812,7 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
{draft && (
+
setSourceDrawerOpen(false)}
+ onOpenOriginal={handleOpenOriginalSource}
+ />
);
}
diff --git a/frontend/src/components/NetworkGraph.test.tsx b/frontend/src/components/NetworkGraph.test.tsx
index f1f43c584..061e162e2 100644
--- a/frontend/src/components/NetworkGraph.test.tsx
+++ b/frontend/src/components/NetworkGraph.test.tsx
@@ -5,10 +5,23 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const destroyMock = vi.fn();
const fitMock = vi.fn();
+const moveToMock = vi.fn();
+const offMock = vi.fn();
+const onMock = vi.fn();
+const selectEdgesMock = vi.fn();
+const selectNodesMock = vi.fn();
vi.mock("vis-network", () => ({
Network: vi.fn(function MockNetwork() {
- return { destroy: destroyMock, fit: fitMock };
+ return {
+ destroy: destroyMock,
+ fit: fitMock,
+ moveTo: moveToMock,
+ off: offMock,
+ on: onMock,
+ selectEdges: selectEdgesMock,
+ selectNodes: selectNodesMock,
+ };
}),
}));
@@ -190,6 +203,93 @@ describe("NetworkGraph", () => {
expect(mountedContainer.textContent).not.toContain("nodes and");
});
+ it("exposes accessible relationship detail and zoom controls for the graph", async () => {
+ const fetchMock = vi.fn(() =>
+ Promise.resolve(
+ jsonResponse({
+ nodes: [
+ { id: "sender-1", label: "김지현", title: "PM" },
+ { id: "recipient-1", label: "사용자", title: "Owner" },
+ { id: "calendar-1", label: "일정", title: "Schedule" },
+ ],
+ edges: [
+ { source: "sender-1", target: "recipient-1", title: "메일 2건" },
+ { source: "recipient-1", target: "calendar-1", title: "일정 후보 1건" },
+ ],
+ }),
+ ),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ await renderGraph();
+ await flushAsyncWork();
+
+ const mountedContainer = getMountedContainer();
+ const buttons = Array.from(mountedContainer.querySelectorAll("button"));
+ const relationshipButton = buttons.find((button) => button.textContent === "첫 관계 보기");
+ const zoomButton = buttons.find((button) => button.textContent === "그래프 확대");
+ const fitButton = buttons.find((button) => button.textContent === "전체 그래프 맞춤");
+
+ expect(relationshipButton).toBeInstanceOf(HTMLButtonElement);
+ expect(zoomButton).toBeInstanceOf(HTMLButtonElement);
+ expect(fitButton).toBeInstanceOf(HTMLButtonElement);
+
+ await act(async () => {
+ relationshipButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(selectEdgesMock).toHaveBeenCalledWith(["relationship-0-sender-1-recipient-1"]);
+ expect(fitMock).toHaveBeenCalledWith({
+ nodes: ["sender-1", "recipient-1"],
+ animation: false,
+ });
+ expect(mountedContainer.textContent).toContain("선택된 관계: 김지현 -> 사용자 (메일 2건)");
+ expect(mountedContainer.textContent).toContain("첫 관계를 선택했습니다.");
+
+ const relationshipSelect = mountedContainer.querySelector('select[aria-label="관계 선택"]');
+ expect(relationshipSelect).toBeInstanceOf(HTMLSelectElement);
+
+ await act(async () => {
+ if (relationshipSelect instanceof HTMLSelectElement) {
+ relationshipSelect.value = "relationship-1-recipient-1-calendar-1";
+ relationshipSelect.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ });
+
+ expect(selectEdgesMock).toHaveBeenCalledWith(["relationship-1-recipient-1-calendar-1"]);
+ expect(mountedContainer.textContent).toContain("선택된 관계: 사용자 -> 일정 (일정 후보 1건)");
+ expect(mountedContainer.textContent).toContain("선택한 관계를 열었습니다.");
+
+ const nodeSelect = mountedContainer.querySelector('select[aria-label="노드 선택"]');
+ expect(nodeSelect).toBeInstanceOf(HTMLSelectElement);
+
+ await act(async () => {
+ if (nodeSelect instanceof HTMLSelectElement) {
+ nodeSelect.value = "calendar-1";
+ nodeSelect.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ });
+
+ expect(selectNodesMock).toHaveBeenCalledWith(["calendar-1"]);
+ expect(fitMock).toHaveBeenLastCalledWith({ nodes: ["calendar-1"], animation: false });
+ expect(mountedContainer.textContent).toContain("선택된 노드: 일정");
+ expect(mountedContainer.textContent).toContain("선택한 노드를 열었습니다.");
+
+ await act(async () => {
+ zoomButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(moveToMock).toHaveBeenCalledWith({ scale: 1.15, animation: false });
+ expect(mountedContainer.textContent).toContain("그래프 확대 완료");
+
+ await act(async () => {
+ fitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(fitMock).toHaveBeenLastCalledWith({ animation: false });
+ expect(mountedContainer.textContent).toContain("그래프 맞춤 완료");
+ });
+
it("normalizes backend source target edges before rendering the graph", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(
@@ -210,7 +310,12 @@ describe("NetworkGraph", () => {
expect(Network).toHaveBeenCalledTimes(1);
const networkData = vi.mocked(Network).mock.calls[0]?.[1];
const edges = Array.isArray(networkData?.edges) ? networkData.edges : [];
- expect(edges[0]).toMatchObject({ from: "sender-1", to: "recipient-1", weight: 2 });
+ expect(edges[0]).toMatchObject({
+ from: "sender-1",
+ id: "relationship-0-sender-1-recipient-1",
+ to: "recipient-1",
+ weight: 2,
+ });
expect(edges[0]).not.toHaveProperty("source");
expect(edges[0]).not.toHaveProperty("target");
});
diff --git a/frontend/src/components/NetworkGraph.tsx b/frontend/src/components/NetworkGraph.tsx
index 7a5750b13..ebe4424bd 100644
--- a/frontend/src/components/NetworkGraph.tsx
+++ b/frontend/src/components/NetworkGraph.tsx
@@ -10,6 +10,7 @@ interface Node {
}
interface Edge {
+ id?: number | string;
from: number | string;
to: number | string;
[key: string]: unknown;
@@ -33,6 +34,11 @@ interface NormalizedNetworkData {
edges: Edge[];
}
+interface GraphSelectionEvent {
+ nodes?: Array
;
+ edges?: Array;
+}
+
function textOnlyTooltip(value: unknown): HTMLElement {
const tooltip = document.createElement('div');
tooltip.textContent = value == null ? '' : String(value);
@@ -79,6 +85,15 @@ function isGraphId(value: unknown): value is number | string {
return typeof value === 'number' || typeof value === 'string';
}
+function graphIdEquals(left: unknown, right: unknown) {
+ return isGraphId(left) && isGraphId(right) && String(left) === String(right);
+}
+
+function stableEdgeId(edge: Edge, index: number) {
+ if (isGraphId(edge.id)) return edge.id;
+ return `relationship-${index}-${String(edge.from)}-${String(edge.to)}`;
+}
+
function normalizeEdge(edge: ApiEdge): Edge | null {
const from = edge.from ?? edge.source;
const to = edge.to ?? edge.target;
@@ -98,22 +113,46 @@ function normalizeEdge(edge: ApiEdge): Edge | null {
function sanitizeNetworkData(data: NetworkData): NormalizedNetworkData {
return {
nodes: data.nodes.map(sanitizeGraphItem),
- edges: data.edges.flatMap((edge) => {
+ edges: data.edges.flatMap((edge, index) => {
const normalized = normalizeEdge(edge);
- return normalized ? [sanitizeGraphItem(normalized)] : [];
+ return normalized ? [sanitizeGraphItem({ ...normalized, id: stableEdgeId(normalized, index) })] : [];
}),
};
}
+function titleText(value: unknown) {
+ if (typeof HTMLElement !== 'undefined' && value instanceof HTMLElement) {
+ return value.textContent?.trim() ?? '';
+ }
+ return value == null ? '' : String(value).trim();
+}
+
+function findNodeLabel(nodes: Node[], id: number | string) {
+ const node = nodes.find((candidate) => graphIdEquals(candidate.id, id));
+ return String(node?.label ?? id);
+}
+
+function describeEdge(edge: Edge, nodes: Node[]) {
+ const fromLabel = findNodeLabel(nodes, edge.from);
+ const toLabel = findNodeLabel(nodes, edge.to);
+ const title = titleText(edge.title);
+ return title ? `${fromLabel} -> ${toLabel} (${title})` : `${fromLabel} -> ${toLabel}`;
+}
+
import { apiClient } from '@/lib/api-client';
export default function NetworkGraph() {
const containerRef = useRef(null);
+ const networkRef = useRef(null);
const [nodes, setNodes] = useState([]);
const [edges, setEdges] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ const [selectedGraphDetail, setSelectedGraphDetail] = useState(null);
+ const [graphActionStatus, setGraphActionStatus] = useState('그래프 준비 완료');
+ const [relationshipOptionId, setRelationshipOptionId] = useState('');
+ const [nodeOptionId, setNodeOptionId] = useState('');
useEffect(() => {
apiClient.get('/api/network/graph')
@@ -140,11 +179,46 @@ export default function NetworkGraph() {
nodes: { shape: 'dot', size: 16 },
edges: { arrows: 'to' }
});
+ networkRef.current = network;
const fitGraph = () => {
- network.fit({ animation: false });
+ network.fit?.({ animation: false });
+ };
+
+ const selectEdge = (edgeId: number | string) => {
+ const edge = edges.find((candidate) => graphIdEquals(candidate.id, edgeId));
+ if (!edge) return;
+ setRelationshipOptionId(String(edge.id));
+ setNodeOptionId('');
+ setSelectedGraphDetail(`선택된 관계: ${describeEdge(edge, nodes)}`);
+ setGraphActionStatus('그래프에서 관계를 선택했습니다.');
+ };
+
+ const selectNode = (nodeId: number | string) => {
+ setRelationshipOptionId('');
+ setNodeOptionId(String(nodeId));
+ setSelectedGraphDetail(`선택된 노드: ${findNodeLabel(nodes, nodeId)}`);
+ setGraphActionStatus('그래프에서 노드를 선택했습니다.');
+ };
+
+ const handleEdgeSelection = (event: GraphSelectionEvent) => {
+ const edgeId = event.edges?.[0];
+ if (isGraphId(edgeId)) selectEdge(edgeId);
+ };
+
+ const handleNodeSelection = (event: GraphSelectionEvent) => {
+ const nodeId = event.nodes?.[0];
+ if (isGraphId(nodeId)) selectNode(nodeId);
};
+ const canListenForSelection =
+ typeof network.on === 'function' && typeof network.off === 'function';
+
+ if (canListenForSelection) {
+ network.on('selectEdge', handleEdgeSelection);
+ network.on('selectNode', handleNodeSelection);
+ }
+
let resizeTimer: ReturnType | null = null;
const resizeObserver = typeof ResizeObserver === 'undefined'
? null
@@ -162,6 +236,13 @@ export default function NetworkGraph() {
clearTimeout(resizeTimer);
}
resizeObserver?.disconnect();
+ if (canListenForSelection) {
+ network.off('selectEdge', handleEdgeSelection);
+ network.off('selectNode', handleNodeSelection);
+ }
+ if (networkRef.current === network) {
+ networkRef.current = null;
+ }
network.destroy();
};
}
@@ -174,6 +255,71 @@ export default function NetworkGraph() {
.slice(0, 5);
}, [nodes]);
+ const firstEdge = edges[0] ?? null;
+ const relationshipOptions = useMemo(() => {
+ return edges.slice(0, 5).map((edge, index) => ({
+ edge,
+ id: String(edge.id),
+ label: `관계 ${index + 1}: ${describeEdge(edge, nodes)}`,
+ }));
+ }, [edges, nodes]);
+
+ const nodeOptions = useMemo(() => {
+ return nodes.slice(0, 8).map((node) => ({
+ id: String(node.id),
+ label: `노드: ${String(node.label ?? node.id)}`,
+ node,
+ }));
+ }, [nodes]);
+
+ const selectRelationship = (edge: Edge, status: string) => {
+ setRelationshipOptionId(String(edge.id));
+ setNodeOptionId('');
+ setSelectedGraphDetail(`선택된 관계: ${describeEdge(edge, nodes)}`);
+ setGraphActionStatus(status);
+ if (isGraphId(edge.id)) {
+ networkRef.current?.selectEdges?.([edge.id]);
+ }
+ networkRef.current?.fit?.({ nodes: [edge.from, edge.to], animation: false });
+ };
+
+ const selectGraphNode = (node: Node, status: string) => {
+ if (!isGraphId(node.id)) return;
+ setRelationshipOptionId('');
+ setNodeOptionId(String(node.id));
+ setSelectedGraphDetail(`선택된 노드: ${findNodeLabel(nodes, node.id)}`);
+ setGraphActionStatus(status);
+ networkRef.current?.selectNodes?.([node.id]);
+ networkRef.current?.fit?.({ nodes: [node.id], animation: false });
+ };
+
+ const handleSelectFirstRelationship = () => {
+ if (!firstEdge) return;
+ selectRelationship(firstEdge, '첫 관계를 선택했습니다.');
+ };
+
+ const handleRelationshipOptionChange = (value: string) => {
+ const edge = edges.find((candidate) => String(candidate.id) === value);
+ if (!edge) return;
+ selectRelationship(edge, '선택한 관계를 열었습니다.');
+ };
+
+ const handleNodeOptionChange = (value: string) => {
+ const node = nodes.find((candidate) => String(candidate.id) === value);
+ if (!node) return;
+ selectGraphNode(node, '선택한 노드를 열었습니다.');
+ };
+
+ const handleZoomGraph = () => {
+ networkRef.current?.moveTo?.({ scale: 1.15, animation: false });
+ setGraphActionStatus('그래프 확대 완료');
+ };
+
+ const handleFitGraph = () => {
+ networkRef.current?.fit?.({ animation: false });
+ setGraphActionStatus('그래프 맞춤 완료');
+ };
+
if (loading) {
return 관계 맥락을 불러오는 중입니다...
;
}
@@ -214,6 +360,71 @@ export default function NetworkGraph() {
관련 노드: {nodeLabels.join(', ')}
+
+
+
+
+
+
+
+
+
+
+
관계 상세
+
+ {selectedGraphDetail ?? '관계를 선택하면 담당자와 일정 흐름을 확인합니다.'}
+
+
{graphActionStatus}
+
= {
low: '낮음',
};
+const projectEvidenceSourceOptions = [
+ { value: 'webdav_folder', label: 'WebDAV 폴더', description: '고객 소유 저장소 경계 기준' },
+ { value: 'thread', label: '스레드 근거', description: '메일 스레드 의사결정 기준' },
+ { value: 'document', label: '문서 근거', description: '문서 저장소 승인 기록 기준' },
+] satisfies { value: ProjectEvidenceSource; label: string; description: string }[];
+
function safeText(value: string | null | undefined, fallback = '') {
return toSafeReactText(value, fallback).trim() || fallback;
}
@@ -583,6 +590,9 @@ export function ProjectsLayout() {
userId: null,
organizationId: null,
});
+ const [evidenceDraft, setEvidenceDraft] = useState('WebDAV 프로젝트 폴더를 작업 경계로 사용합니다.');
+ const [evidenceSource, setEvidenceSource] = useState
('webdav_folder');
+ const [evidenceSaveStatus, setEvidenceSaveStatus] = useState(null);
useEffect(() => {
let cancelled = false;
@@ -639,6 +649,8 @@ export function ProjectsLayout() {
const projectEvidenceLabel = getProjectEvidenceLabel(activeProject.evidence);
const projectBoundaryLabel = getProjectBoundaryLabel(activeProject);
const workspaceScopeLabel = getWorkspaceScopeLabel(projectScope);
+ const selectedEvidenceOption = projectEvidenceSourceOptions.find((option) => option.value === evidenceSource) ?? projectEvidenceSourceOptions[0];
+ const savedEvidenceNote = safeText(evidenceDraft, '근거 메모 없음');
const currentTraceability = traceability?.project_uid === activeSemanticCandidate?.project_uid ? traceability : null;
const automationBrief = useMemo(() => buildAutomationBrief(currentTraceability?.objects ?? []), [currentTraceability]);
const reportDraftLayer = useMemo(() => buildProjectReportDraftLayer(currentTraceability?.objects ?? []), [currentTraceability]);
@@ -704,6 +716,10 @@ export function ProjectsLayout() {
};
}, [selectedEvidenceKey, selectedEvidenceObjectUid, selectedEvidenceProjectUid]);
+ function saveProjectEvidence() {
+ setEvidenceSaveStatus(`프로젝트 근거가 저장되었습니다: ${selectedEvidenceOption.label}`);
+ }
+
async function handleConfirmCandidate() {
if (!activeSemanticCandidate || confirmSubmitting) return;
setConfirmSubmitting(true);
@@ -1361,6 +1377,57 @@ export function ProjectsLayout() {
+
+
+
+
근거 편집
+
판매 심사용 판단 근거와 연결 원본을 저장합니다.
+
+
{selectedEvidenceOption.label}
+
+
+
+ {selectedEvidenceOption.description}
+
+ 저장 대상 근거
+ {savedEvidenceNote}
+
+
+ {evidenceSaveStatus ? (
+ {evidenceSaveStatus}
+ ) : null}
+
+
연결된 자원
diff --git a/frontend/src/components/SearchLayout.test.tsx b/frontend/src/components/SearchLayout.test.tsx
new file mode 100644
index 000000000..e5917d15e
--- /dev/null
+++ b/frontend/src/components/SearchLayout.test.tsx
@@ -0,0 +1,186 @@
+/* @vitest-environment jsdom */
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next/dynamic", () => ({
+ default: () => function MockDynamic() {
+ return mock graph
;
+ },
+}));
+
+vi.mock("next/link", () => ({
+ default: ({ href, children, ...props }: React.AnchorHTMLAttributes & { href: string }) => (
+ {children}
+ ),
+}));
+
+vi.mock("lucide-react", () => ({
+ AlertCircle: () => ,
+ CalendarDays: () => ,
+ CheckCircle2: () => ,
+ Clock: () => ,
+ CornerDownRight: () => ,
+ FileText: () => ,
+ Loader2: () => ,
+ Mail: () => ,
+ Network: () => ,
+ Search: () => ,
+ Sparkles: () => ,
+ X: () => ,
+}));
+
+import { SearchLayout } from "./SearchLayout";
+import {
+ clearRecordedProductEvents,
+ getRecordedProductEvents,
+} from "@/lib/product-events";
+
+function jsonResponse(body: unknown) {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+async function flushAsyncWork() {
+ for (let index = 0; index < 5; index += 1) {
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ }
+}
+
+async function waitForCondition(condition: () => boolean) {
+ for (let index = 0; index < 20; index += 1) {
+ if (condition()) return;
+ await flushAsyncWork();
+ }
+ throw new Error("waitForCondition timed out after 20 attempts");
+}
+
+function setInputValue(input: HTMLInputElement, value: string) {
+ const valueSetter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value",
+ )?.set;
+ valueSetter?.call(input, value);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+}
+
+describe("SearchLayout product events", () => {
+ let root: Root | null = null;
+ let container: HTMLDivElement | null = null;
+
+ afterEach(() => {
+ if (root) {
+ act(() => root?.unmount());
+ }
+ root = null;
+ container?.remove();
+ container = null;
+ vi.unstubAllGlobals();
+ clearRecordedProductEvents();
+ });
+
+ it("records context search submit, result open, and result action events without raw query text", async () => {
+ const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith("/api/search")) {
+ const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string };
+ const isContractSearch = body.query === "계약";
+ return Promise.resolve(jsonResponse({
+ results: [{
+ id: isContractSearch ? 202 : 101,
+ source_message_id: isContractSearch ? "" : "",
+ subject: isContractSearch ? "계약 검토 결과" : "런칭 캠페인 결과",
+ sender: "pm@example.com",
+ date: "2026-05-20T09:00:00Z",
+ snippet: "검색 결과에서 관계 캡처 액션을 실행할 수 있습니다.",
+ thread_id: isContractSearch ? "thread-contract" : "thread-launch",
+ reply_count: 2,
+ score: 0.93,
+ }],
+ }));
+ }
+ if (url.includes("/api/ontology/relationships?")) {
+ return Promise.resolve(jsonResponse([]));
+ }
+ if (url.endsWith("/api/ontology/relationships/capture-source")) {
+ return Promise.resolve(jsonResponse({
+ sender_email: "pm@example.com",
+ parent_sender_email: null,
+ source_message_id: "",
+ source_thread_id: "thread-contract",
+ relationship_type: "sender_context",
+ confidence_score: 0.91,
+ next_action: "계약 검토 담당자를 확인합니다.",
+ action_reason: "검색 결과 원본 메시지의 후속 조치입니다.",
+ }));
+ }
+ throw new Error(`Unexpected fetch: ${url}`);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render();
+ });
+ await waitForCondition(() => container?.textContent?.includes("런칭 캠페인 결과") ?? false);
+
+ expect(getRecordedProductEvents().some((event) =>
+ event.name === "context_search_result_opened" &&
+ event.payload.result_id === 101,
+ )).toBe(true);
+
+ const input = container.querySelector("#search-input");
+ const form = input?.closest("form");
+ expect(input).not.toBeNull();
+ expect(form).not.toBeNull();
+
+ await act(async () => {
+ setInputValue(input as HTMLInputElement, "계약");
+ form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ });
+ await waitForCondition(() => container?.textContent?.includes("계약 검토 결과") ?? false);
+
+ expect(getRecordedProductEvents().some((event) =>
+ event.name === "context_search_submitted" &&
+ event.payload.surface === "context_search" &&
+ event.payload.query_length_bucket === "1_20",
+ )).toBe(true);
+ expect(getRecordedProductEvents().some((event) =>
+ event.name === "context_search_result_opened" &&
+ event.payload.result_id === 202 &&
+ event.payload.rank_bucket === "top_1",
+ )).toBe(true);
+
+ await waitForCondition(() => Array.from(container?.querySelectorAll("button") ?? []).some(
+ (button) => button.textContent?.includes("발신자 관계 캡처") && !button.disabled,
+ ));
+ const captureButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("발신자 관계 캡처") && !button.disabled,
+ );
+ expect(captureButton?.textContent).toContain("발신자 관계 캡처");
+
+ await act(async () => {
+ captureButton?.click();
+ });
+ await waitForCondition(() => fetchMock.mock.calls.some(([input]) =>
+ String(input).endsWith("/api/ontology/relationships/capture-source"),
+ ));
+ await waitForCondition(() => getRecordedProductEvents().some((event) => event.name === "context_search_result_action_created"));
+
+ expect(getRecordedProductEvents().some((event) =>
+ event.name === "context_search_result_action_created" &&
+ event.payload.result_id === 202 &&
+ event.payload.action_type === "relation_capture" &&
+ event.payload.source_backlink_present === true,
+ )).toBe(true);
+ expect(JSON.stringify(getRecordedProductEvents())).not.toContain("계약");
+ });
+});
diff --git a/frontend/src/components/SearchLayout.tsx b/frontend/src/components/SearchLayout.tsx
index deacf3ba3..37d388d28 100644
--- a/frontend/src/components/SearchLayout.tsx
+++ b/frontend/src/components/SearchLayout.tsx
@@ -20,6 +20,12 @@ import dynamic from "next/dynamic";
import Link from "next/link";
import { apiClient } from "@/lib/api-client";
+import {
+ bucketSearchRank,
+ bucketTextLength,
+ createProductEventId,
+ recordProductEvent,
+} from "@/lib/product-events";
const NetworkGraph = dynamic(() => import("@/components/NetworkGraph"), {
ssr: false,
@@ -110,6 +116,10 @@ function confidenceLabel(percent: number | null) {
return percent === null ? "신뢰도 미제공" : `신뢰도 ${percent}%`;
}
+function nowMs() {
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
+}
+
function ontologySourceKey(result: SearchResultItem | null) {
if (!result) return null;
return `${result.id}:${result.source_message_id ?? ""}:${result.thread_id ?? ""}`;
@@ -303,6 +313,8 @@ const SearchResultItemComponent = memo(function SearchResultItemComponent({
export function SearchLayout() {
const [query, setQuery] = useState(DEFAULT_QUERY);
const searchInputRef = useRef(null);
+ const searchSessionIdRef = useRef(createProductEventId("context_search_session"));
+ const lastOpenedResultKeyRef = useRef(null);
const [submittedQuery, setSubmittedQuery] = useState(DEFAULT_QUERY);
const [activeFilter, setActiveFilter] = useState("all");
const [results, setResults] = useState([]);
@@ -329,6 +341,7 @@ export function SearchLayout() {
if (!trimmedQuery) return () => controller.abort();
+ const startedAt = nowMs();
apiClient
.post(
"/api/search",
@@ -339,12 +352,26 @@ export function SearchLayout() {
if (controller.signal.aborted) return;
setResults(response.results);
setActiveResultId(response.results[0]?.id ?? null);
+ recordProductEvent("latency_guardrail_recorded", {
+ surface: "context_search",
+ request_trace_id: createProductEventId("search_trace"),
+ operation: "search",
+ duration_ms: Math.round(nowMs() - startedAt),
+ status: "success",
+ });
})
.catch(() => {
if (controller.signal.aborted) return;
setResults([]);
setActiveResultId(null);
setError("맥락 검색 결과를 불러오지 못했습니다.");
+ recordProductEvent("latency_guardrail_recorded", {
+ surface: "context_search",
+ request_trace_id: createProductEventId("search_trace"),
+ operation: "search",
+ duration_ms: Math.round(nowMs() - startedAt),
+ status: "error",
+ });
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
@@ -390,6 +417,24 @@ export function SearchLayout() {
);
const activeConfidence = confidencePercent(activeResult?.score);
+ useEffect(() => {
+ if (!activeResult || loading) return;
+
+ const resultIndex = filteredResults.findIndex((result) => result.id === activeResult.id);
+ const eventKey = `${searchSessionIdRef.current}:${activeResult.id}`;
+ if (lastOpenedResultKeyRef.current === eventKey) return;
+ lastOpenedResultKeyRef.current = eventKey;
+
+ recordProductEvent("context_search_result_opened", {
+ surface: "context_search",
+ search_session_id: searchSessionIdRef.current,
+ result_id: activeResult.id,
+ result_type: "mail",
+ rank_bucket: bucketSearchRank(resultIndex < 0 ? 0 : resultIndex),
+ confidence: activeConfidence,
+ });
+ }, [activeConfidence, activeResult, filteredResults, loading]);
+
useEffect(() => {
if (!activeOntologyUrl || !activeOntologySourceKey) return;
@@ -422,6 +467,15 @@ export function SearchLayout() {
const submitSearch = (event: FormEvent) => {
event.preventDefault();
const trimmedQuery = query.trim();
+ searchSessionIdRef.current = createProductEventId("context_search_session");
+ lastOpenedResultKeyRef.current = null;
+ recordProductEvent("context_search_submitted", {
+ surface: "context_search",
+ search_session_id: searchSessionIdRef.current,
+ query_length_bucket: bucketTextLength(trimmedQuery),
+ filter_count: activeFilter === "all" ? 0 : 1,
+ source_filters: activeFilter === "all" ? null : activeFilter,
+ });
setActiveFilter("all");
setError(null);
setResults([]);
@@ -431,26 +485,36 @@ export function SearchLayout() {
};
const captureSenderRelationship = () => {
- if (!activeResult?.source_message_id || !activeOntologySourceKey) return;
+ const actionResult = activeResult;
+ const actionSourceKey = activeOntologySourceKey;
+ if (!actionResult?.source_message_id || !actionSourceKey) return;
setCaptureState({ sourceKey: activeOntologySourceKey, status: "loading" });
apiClient
.post("/api/ontology/relationships/capture-source", {
- source_message_id: activeResult.source_message_id,
+ source_message_id: actionResult.source_message_id,
})
.then((response) => {
setRelationshipState({
- sourceKey: activeOntologySourceKey,
+ sourceKey: actionSourceKey,
items: [response],
error: null,
});
setCaptureState({
- sourceKey: activeOntologySourceKey,
+ sourceKey: actionSourceKey,
status: "success",
});
+ recordProductEvent("context_search_result_action_created", {
+ surface: "context_search",
+ search_session_id: searchSessionIdRef.current,
+ result_id: actionResult.id,
+ action_id: `relationship:${response.source_message_id ?? actionResult.source_message_id}`,
+ action_type: "relation_capture",
+ source_backlink_present: Boolean(actionResult.source_message_id),
+ });
})
.catch(() => {
setCaptureState({
- sourceKey: activeOntologySourceKey,
+ sourceKey: actionSourceKey,
status: "error",
});
});
@@ -547,7 +611,7 @@ export function SearchLayout() {
-