hee_!J commited on
Commit
10f82ba
·
unverified ·
2 Parent(s): 8cb8915970697d

Merge pull request #6 from FabAgentGroup/feat/streamlit-ui

Browse files
app.py CHANGED
@@ -34,6 +34,27 @@ def inject_css():
34
  )
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  def init_state():
38
  ss = st.session_state
39
  ss.setdefault("selected_alarm_id", "A1")
@@ -45,13 +66,36 @@ def init_state():
45
  ss.setdefault("speed", "normal") # "fast" | "normal" | "real"
46
 
47
 
 
 
 
 
 
 
 
 
48
  def render_main():
49
  render_header()
50
 
 
 
 
 
51
  col_title, col_progress = st.columns([5, 4])
52
  with col_title:
53
- # TODO(M1) 메인 타이틀 + 4-Tier 워크플로우 서브텍스트
54
- st.markdown("<!-- TODO(M1): 메인 타이틀 -->", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
55
  with col_progress:
56
  render_progress_strip()
57
 
@@ -63,6 +107,7 @@ def render_main():
63
 
64
 
65
  inject_css()
 
66
  init_state()
67
  render_alarm_inbox()
68
  render_main()
 
34
  )
35
 
36
 
37
+ def handle_query_params():
38
+ # 사이드바 FabAgent 로고 클릭 -> ?reset=1로 진입 -> 세션 초기화
39
+ if "reset" in st.query_params:
40
+ for key in list(st.session_state.keys()):
41
+ del st.session_state[key]
42
+ del st.query_params["reset"]
43
+ return
44
+
45
+ # 알람 카드 클릭 -> ?alarm=X로 진입 -> 선택 알람 변경
46
+ if "alarm" in st.query_params:
47
+ alarm_id = st.query_params["alarm"]
48
+ ss = st.session_state
49
+ if alarm_id != ss.get("selected_alarm_id"):
50
+ ss.selected_alarm_id = alarm_id
51
+ ss.stage = 0
52
+ ss.completed_tiers = set()
53
+ ss.approved = False
54
+ ss.animation_pending = True
55
+ del st.query_params["alarm"]
56
+
57
+
58
  def init_state():
59
  ss = st.session_state
60
  ss.setdefault("selected_alarm_id", "A1")
 
66
  ss.setdefault("speed", "normal") # "fast" | "normal" | "real"
67
 
68
 
69
+ def _current_alarm():
70
+ ss = st.session_state
71
+ for a in ss.alarms:
72
+ if a["id"] == ss.selected_alarm_id:
73
+ return a
74
+ return None
75
+
76
+
77
  def render_main():
78
  render_header()
79
 
80
+ alarm = _current_alarm()
81
+ title_text = alarm["title"] if alarm else "알람을 선택하세요"
82
+ lot_id = alarm["lot_id"] if alarm else "-"
83
+
84
  col_title, col_progress = st.columns([5, 4])
85
  with col_title:
86
+ st.markdown(
87
+ f"""
88
+ <h1 class="fab-main-title">
89
+ {title_text} — <span style="font-family:var(--mono); font-weight:700;">{lot_id}</span>
90
+ </h1>
91
+ <div class="fab-main-sub">
92
+ <span>4-Tier 분석 워크플로우</span>
93
+ <span class="sep">·</span>
94
+ <span>이상 탐지 → 원인 분석 → 영향 평가 → 대응 권고</span>
95
+ </div>
96
+ """,
97
+ unsafe_allow_html=True,
98
+ )
99
  with col_progress:
100
  render_progress_strip()
101
 
 
107
 
108
 
109
  inject_css()
110
+ handle_query_params()
111
  init_state()
112
  render_alarm_inbox()
113
  render_main()
components/alarm_inbox.py CHANGED
@@ -1,13 +1,97 @@
1
  """사이드바 알람 인박스 (개발 가이드 6장)
2
 
3
- TODO(M1): 가이드 6장 '권장 대안', HTML 카드 + 바로 아래 st.button 분리 방식
4
- on_alarm_click()에서 stage / completed_tiers / approved 리셋 후
5
- animation_pending=True 로 두고 st.rerun()
6
  """
7
  import streamlit as st
8
 
 
 
9
 
10
  def render_alarm_inbox():
 
11
  with st.sidebar:
12
- # TODO(M1) st.session_state.alarms 순회하며 알람 카드 + 선택 버튼 렌더
13
- st.markdown("<!-- TODO(M1): 알람 인박스 -->", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """사이드바 알람 인박스 (개발 가이드 6장)
2
 
3
+ HTML 카드 + 바로 아래 작은 선택 버튼 분리 방식 (가이드 권장안)
4
+ on_alarm_click()에서 stage/completed_tiers/approved 리셋 후
5
+ animation_pending=True로 두고 st.rerun()
6
  """
7
  import streamlit as st
8
 
9
+ from data.demo import STATUS_LABELS
10
+
11
 
12
  def render_alarm_inbox():
13
+ ss = st.session_state
14
  with st.sidebar:
15
+ _render_logo()
16
+
17
+ critical_count = sum(1 for a in ss.alarms if a["status"] == "critical")
18
+ total = len(ss.alarms)
19
+ badge = f'<span class="badge">긴급 {critical_count}</span>' if critical_count else ""
20
+ st.markdown(
21
+ f"""
22
+ <div class="fab-sidebar-head">
23
+ <div class="fab-sidebar-title">알람 인박스</div>
24
+ <div class="fab-sidebar-count">
25
+ {badge}
26
+ <span>전체 {total}건</span>
27
+ </div>
28
+ </div>
29
+ """,
30
+ unsafe_allow_html=True,
31
+ )
32
+
33
+ for alarm in ss.alarms:
34
+ _render_alarm_card(alarm)
35
+
36
+
37
+ def _render_logo():
38
+ # 클릭 시 ?reset=1 쿼리로 진입 -> app.maybe_handle_home이 세션 초기화
39
+ st.markdown(
40
+ """
41
+ <a href="?reset=1" class="fab-sidebar-logo-link" target="_self">
42
+ <div class="fab-sidebar-logo">
43
+ <div class="fab-logo-mark">
44
+ <svg width="16" height="16" viewBox="0 0 160 160" fill="none">
45
+ <circle cx="80" cy="80" r="60" stroke="#fff" stroke-width="10"/>
46
+ <path d="M70 22 L90 22 L86 32 L74 32 Z" fill="#fff"/>
47
+ <rect x="46" y="58" width="68" height="8" rx="4" fill="#fff"/>
48
+ <rect x="46" y="76" width="52" height="8" rx="4" fill="#fff" opacity="0.7"/>
49
+ <rect x="46" y="94" width="36" height="8" rx="4" fill="#fff" opacity="0.4"/>
50
+ </svg>
51
+ </div>
52
+ <span>FabAgent</span>
53
+ </div>
54
+ </a>
55
+ """,
56
+ unsafe_allow_html=True,
57
+ )
58
+
59
+
60
+ def _render_alarm_card(alarm):
61
+ ss = st.session_state
62
+ is_selected = alarm["id"] == ss.selected_alarm_id
63
+ is_done = alarm["status"] == "done"
64
+
65
+ classes = f'alarm-card {alarm["status"]}'
66
+ if is_selected:
67
+ classes += " selected"
68
+ if is_done:
69
+ classes += " completed"
70
+
71
+ selected_tag = '<span class="alarm-selected-tag">★ 선택됨</span>' if is_selected else ""
72
+ feat_html = ""
73
+ if alarm.get("feature"):
74
+ arrow = alarm.get("feature_arrow") or ""
75
+ feat_html = (
76
+ f'<span class="feat">{alarm["feature"]} '
77
+ f'<span class="alarm-feat-arrow">{arrow}</span></span>'
78
+ )
79
+
80
+ # 카드 전체를 클릭 영역으로, ?alarm=X 쿼리로 진입하면 app.handle_query_params가 처리
81
+ # markdown 파서가 <a> 안의 <div>를 reparse하는 문제를 피하려고 st.html 사용
82
+ st.html(
83
+ f'<a href="?alarm={alarm["id"]}" class="alarm-card-link" target="_self">'
84
+ f'<div class="{classes}">'
85
+ f'<div class="alarm-head">'
86
+ f'<span class="chip chip-{alarm["status"]}"><span class="dot"></span>{STATUS_LABELS[alarm["status"]]}</span>'
87
+ f'{selected_tag}'
88
+ f'</div>'
89
+ f'<div class="alarm-title">{alarm["title"]}</div>'
90
+ f'<div class="alarm-meta">'
91
+ f'<span style="font-family: var(--mono); font-size: 11px;">{alarm["lot_id"]}</span>'
92
+ f'{feat_html}'
93
+ f'</div>'
94
+ f'<div class="alarm-time">{alarm["time"]}</div>'
95
+ f'</div>'
96
+ f'</a>'
97
+ )
components/header.py CHANGED
@@ -1,11 +1,36 @@
1
  """상단 헤더 (개발 가이드 부록 A)
2
 
3
- TODO(M1): 가이드 부록 A의 render_header() 구현, 디자인 헤더와 1:1
4
- 헤더는 st.columns 밖, .block-container 최상단에 둠
5
  """
 
 
6
  import streamlit as st
7
 
8
 
9
  def render_header():
10
- # TODO(M1) 로고 + 공정/라인 컨텍스트 + LIVE 시계 + 사용자 영역
11
- st.markdown("<!-- TODO(M1): FabAgent 헤더 -->", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """상단 헤더 (개발 가이드 부록 A)
2
 
3
+ 로고 + 공정/라인 컨텍스트 + LIVE 시계 + 스템 상태 + 사용자 영역
 
4
  """
5
+ from datetime import datetime
6
+
7
  import streamlit as st
8
 
9
 
10
  def render_header():
11
+ now = datetime.now().strftime("%H:%M:%S")
12
+ # 로고는 사이드바 최상단으로 옮김 (components/alarm_inbox.py)
13
+ st.markdown(
14
+ f"""
15
+ <header class="fab-header">
16
+ <div class="fab-context">
17
+ <span class="ctx-label">공정</span><span>Photo · Lithography</span>
18
+ <span class="ctx-sep"></span>
19
+ <span class="ctx-label">라인</span>
20
+ <span style="font-family: var(--mono); font-weight: 600;">L20240511-N-03</span>
21
+ </div>
22
+ <span class="live-tick">
23
+ <span class="dot"></span> LIVE · {now}
24
+ </span>
25
+ <div class="fab-header-spacer"></div>
26
+ <div class="fab-status">
27
+ <span class="fab-status-dot"></span> 시스템 정상
28
+ </div>
29
+ <div class="fab-user">
30
+ <span>박○○ 운영자 · 야간 교대</span>
31
+ <span class="avatar">박</span>
32
+ </div>
33
+ </header>
34
+ """,
35
+ unsafe_allow_html=True,
36
+ )
components/progress.py CHANGED
@@ -1,11 +1,34 @@
1
  """4단계 진행 스트립 (개발 가이드 부록 C)
2
 
3
- TODO(M1): 가이드 부록 C의 render_progress_strip() 구현
4
- st.session_state.stage 기준으로 단계를 idle / active / done 으로 렌더
 
 
5
  """
6
  import streamlit as st
7
 
8
 
 
 
 
9
  def render_progress_strip():
10
- # TODO(M1) 이상 탐지 -> 원인 분석 -> 영향 평가 -> 권고서 진행 표시
11
- st.markdown("<!-- TODO(M1): 진행 스트립 -->", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """4단계 진행 스트립 (개발 가이드 부록 C)
2
 
3
+ st.session_state.stage 기준으로 단계를 idle / active / done으로 렌더
4
+ - stage > num: done (✓)
5
+ - stage == num: active (현재 분석 중)
6
+ - stage < num: idle
7
  """
8
  import streamlit as st
9
 
10
 
11
+ STEPS = [(1, "이상 탐지"), (2, "원인 분석"), (3, "영향 평가"), (4, "권고서")]
12
+
13
+
14
  def render_progress_strip():
15
+ stage = st.session_state.stage
16
+
17
+ pieces = []
18
+ for i, (num, label) in enumerate(STEPS):
19
+ if stage > num:
20
+ state, mark = "done", "✓"
21
+ elif stage == num:
22
+ state, mark = "active", str(num)
23
+ else:
24
+ state, mark = "idle", str(num)
25
+ pieces.append(
26
+ f'<div class="fab-progress-step {state}"><span class="num">{mark}</span>{label}</div>'
27
+ )
28
+ if i < len(STEPS) - 1:
29
+ pieces.append('<span class="conn"></span>')
30
+
31
+ st.markdown(
32
+ f'<div class="fab-progress">{"".join(pieces)}</div>',
33
+ unsafe_allow_html=True,
34
+ )
components/skeleton.py CHANGED
@@ -1,12 +1,27 @@
1
  """로딩 스켈레톤 (개발 가이드 12장)
2
 
3
- TODO(M1): 12장 render_skeleton() 구현
4
- 스켈레톤 CSS(.skel, .skel-row, .skel-block, @keyframes shimmer)는
5
- 디자인 프로토타입에서 styles/main.css로 복사
6
  """
7
- import streamlit as st
8
 
9
 
10
- def render_skeleton(tier_num: int):
11
- # TODO(M1) tier_num 스켈레톤 레이아웃
12
- st.markdown("<!-- TODO(M1): 스켈레톤 -->", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """로딩 스켈레톤 (개발 가이드 12장)
2
 
3
+ tier_num별 스켈레톤 레아웃을 HTML 문자열로 반환
4
+ tiers.py가 tier-body 안에 그대로 삽입
 
5
  """
 
6
 
7
 
8
+ def skeleton_html(tier_num: int) -> str:
9
+ if tier_num in (1, 3):
10
+ return """
11
+ <div class="t1-grid">
12
+ <div class="skel skel-block" style="height: 148px;"></div>
13
+ <div class="skel-row">
14
+ <div class="skel" style="height: 18px; width: 40%;"></div>
15
+ <div class="skel" style="height: 24px;"></div>
16
+ <div class="skel" style="height: 24px; width: 85%;"></div>
17
+ <div class="skel" style="height: 24px; width: 60%;"></div>
18
+ </div>
19
+ </div>
20
+ """
21
+ rows = 3 if tier_num == 2 else 4
22
+ h = 88 if tier_num == 2 else 48
23
+ blocks = "".join(
24
+ f'<div class="skel" style="height: {h}px; border-radius: 8px; opacity: {1 - i*0.18:.2f};"></div>'
25
+ for i in range(rows)
26
+ )
27
+ return f'<div class="skel-row">{blocks}</div>'
components/tiers.py CHANGED
@@ -1,28 +1,147 @@
1
- """Tier 1~4 순차 등장 렌더러 (개발 가이드 8·9장)
2
 
3
- TODO(M1): 가이드 8장 '접근법 A', st.empty() placeholder 4개 + time.sleep으로
4
- 스켈레톤 -> 콘텐츠 순차 교체, stage>=5 즉시 정적 렌더(새로고침 대응)
5
 
6
- 중요: Tier 데이터는 반드시 core.pipeline.get_tier_data() 한 함수로만 받음
7
- data.demo.TIER_DATA 를 직접 import 하지 말 것, 실제 에이전트 교체가 막힘
8
  """
 
 
 
9
  import streamlit as st
10
 
 
 
 
 
 
 
 
11
  from core.pipeline import get_tier_data
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  def render_tier_cascade():
15
- data = get_tier_data(st.session_state.selected_alarm_id)
 
 
16
  if data is None:
17
  st.markdown(
18
- '<div class="fab-empty">선택한 알람의 분석 데이터가 없습니다.</div>',
 
 
 
 
 
 
 
 
19
  unsafe_allow_html=True,
20
  )
21
  return
22
- # TODO(M1) 가이드 8장 run_sequence(), 스켈레톤 -> 콘텐츠 순차 등장
23
- st.markdown("<!-- TODO(M1): Tier 1~4 cascade -->", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
 
26
  def render_tier(tier_num: int, data, loading: bool, with_actions: bool = False):
27
- # TODO(M1) 가이9장 render_tier(), Tier 프레임 + 본문/스켈레톤 분기
28
- raise NotImplementedError("M1: Tier 프레임 렌더러 구현 예정")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tier 1~4 순차 등장 렌더러 (개발 가이드 8·9·10장)
2
 
3
+ st.empty() placeholder 4개 + time.sleep으로 스켈레톤 → 콘텐츠 순차 교체
4
+ 이미 완료된 상태(stage>=5)거나 animation_pending이 아닐 때는 즉시 정적 렌더
5
 
6
+ Tier 데이터는 반드시 core.pipeline.get_tier_data() 한 함수로만 받음
7
+ data.demo.TIER_DATA를 직접 import 하지 말 것 (실제 에이전트 교체가 막힘)
8
  """
9
+ import time
10
+ from datetime import datetime
11
+
12
  import streamlit as st
13
 
14
+ from components.skeleton import skeleton_html
15
+ from components.tiers_body import (
16
+ tier_1_body_html,
17
+ tier_2_body_html,
18
+ tier_3_body_html,
19
+ tier_4_body_html,
20
+ )
21
  from core.pipeline import get_tier_data
22
 
23
+ SPEED_PRESETS = {
24
+ "fast": {"skel": 0.6, "hold": 0.4},
25
+ "normal": {"skel": 1.4, "hold": 0.7},
26
+ "real": {"skel": 3.0, "hold": 1.5},
27
+ }
28
+
29
+ TIER_NAMES = {1: "이상 탐지", 2: "원인 분석", 3: "공정 간 영향 평가", 4: "대응 권고"}
30
+
31
+ BODY_BUILDERS = {
32
+ 1: tier_1_body_html,
33
+ 2: tier_2_body_html,
34
+ 3: tier_3_body_html,
35
+ 4: tier_4_body_html,
36
+ }
37
+
38
 
39
  def render_tier_cascade():
40
+ ss = st.session_state
41
+ data = get_tier_data(ss.selected_alarm_id)
42
+
43
  if data is None:
44
  st.markdown(
45
+ """
46
+ <div class="fab-empty">
47
+ <div class="fab-empty-inner">
48
+ <div class="fab-empty-icon" style="font-size: 24px;">∅</div>
49
+ <h3>분석 데이터가 없습니다</h3>
50
+ <p>선택한 알람에 매핑된 분석 결과가 없습니다</p>
51
+ </div>
52
+ </div>
53
+ """,
54
  unsafe_allow_html=True,
55
  )
56
  return
57
+
58
+ if ss.animation_pending:
59
+ ss.animation_pending = False
60
+ _run_sequence(data)
61
+ return
62
+
63
+ # 정적 렌더 (재실행/새로고침 대응)
64
+ for t in range(1, 5):
65
+ render_tier(t, data[f"tier{t}"], loading=False, with_actions=(t == 4))
66
+
67
+
68
+ def _run_sequence(data):
69
+ ss = st.session_state
70
+ timing = SPEED_PRESETS[ss.speed]
71
+ slots = [st.empty() for _ in range(4)]
72
+
73
+ for tier_idx in range(1, 5):
74
+ ss.stage = tier_idx
75
+ with slots[tier_idx - 1].container():
76
+ render_tier(tier_idx, None, loading=True)
77
+ time.sleep(timing["skel"])
78
+ with slots[tier_idx - 1].container():
79
+ render_tier(
80
+ tier_idx,
81
+ data[f"tier{tier_idx}"],
82
+ loading=False,
83
+ with_actions=(tier_idx == 4),
84
+ )
85
+ ss.completed_tiers.add(tier_idx)
86
+ if tier_idx < 4:
87
+ time.sleep(timing["hold"])
88
+
89
+ ss.stage = 5
90
 
91
 
92
  def render_tier(tier_num: int, data, loading: bool, with_actions: bool = False):
93
+ """Tier 덩어리를 단일 markdown으로 렌더, Tier 4 액션 바만 별도 위젯"""
94
+ status_html = (
95
+ '<div class="tier-status"><span class="spinner"></span>분석 중…</div>'
96
+ if loading
97
+ else '<div class="tier-status"><span class="check">✓</span>방금</div>'
98
+ )
99
+ body_html = skeleton_html(tier_num) if loading else BODY_BUILDERS[tier_num](data)
100
+
101
+ # st.html을 쓰면 markdown 파서를 거치지 않아 들여쓰기 4칸 짜리 라인이 코드블록으로 변하지 않음
102
+ st.html(
103
+ f'<section class="tier-card tier-{tier_num}">'
104
+ f'<div class="tier-head">'
105
+ f'<div class="tier-head-left">'
106
+ f'<span class="chip chip-t{tier_num}">Tier {tier_num}</span>'
107
+ f'<span class="tier-name">{TIER_NAMES[tier_num]}</span>'
108
+ f'</div>'
109
+ f'{status_html}'
110
+ f'</div>'
111
+ f'<div class="tier-body">{body_html}</div>'
112
+ f'</section>'
113
+ )
114
+
115
+ if tier_num == 4 and with_actions and not loading:
116
+ _render_action_bar()
117
+
118
+
119
+ def _render_action_bar():
120
+ ss = st.session_state
121
+ c1, c2, c3, _ = st.columns([1, 1, 2.2, 4])
122
+ with c1:
123
+ if st.button("✗ 거절", key="btn-reject", disabled=ss.approved):
124
+ st.toast("권고 거절됨, 사유 수집 모달(MVP 범위 외)", icon="✗")
125
+ with c2:
126
+ if st.button("⏸ 보류", key="btn-hold", disabled=ss.approved):
127
+ st.toast("권고 보류됨, 5분 후 재발생 예정", icon="⏸")
128
+ with c3:
129
+ label = "✓ 작업지시서 생성됨" if ss.approved else "✓ 승인 및 작업지시서 생성"
130
+ if st.button(label, key="btn-approve", disabled=ss.approved, type="primary"):
131
+ _on_approve()
132
+
133
+
134
+ def _on_approve():
135
+ ss = st.session_state
136
+ ss.approved = True
137
+ for a in ss.alarms:
138
+ if a["id"] == ss.selected_alarm_id:
139
+ a["status"] = "done"
140
+ a["time"] = "방금 전"
141
+ break
142
+ work_order = f"W-{datetime.now():%Y%m%d}-001"
143
+ st.toast(
144
+ f"✓ 작업 지시서 {work_order} 생성 완료 · 인시던트 DB 자동 기록",
145
+ icon="✅",
146
+ )
147
+ st.rerun()
components/tiers_body.py CHANGED
@@ -1,25 +1,166 @@
1
- """Tier 1~4 본문 (개발 가이드 9·10장)
2
 
3
- TODO(M1): 상호작용 없는 본문 f-string HTML + st.markdown 렌더
4
- Streamlit 위젯을 본문에 쓰지 말 것(레이아웃 깨짐), Tier 4 액션 바만 st.button
5
- 디자인 프로토타입의 tiers.jsx 구조를 1:1로 옮길 것
6
  """
7
  from core.schema import Tier1, Tier2, Tier3, Tier4
8
 
9
 
10
- def render_tier_1_body(data: Tier1):
11
- raise NotImplementedError("M1: Tier 1 본문 구현 예정 (가이드 9장 참고)")
 
 
12
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
- def render_tier_2_body(data: Tier2):
15
- raise NotImplementedError("M1: Tier 2 본문 구현 예정")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
 
18
- def render_tier_3_body(data: Tier3):
19
- raise NotImplementedError("M1: Tier 3 본문 구현 예정")
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
- def render_tier_4_body(data: Tier4, with_actions: bool):
23
- # TODO(M1) 본문 HTML + with_actions면 거절/보류/승인 액션 바
24
- # 승인 시 on_approve, approved=True, 알람 status="done", st.toast, st.rerun
25
- raise NotImplementedError("M1: Tier 4 본문 + 액션 바 구현 예정 (가이드 10장)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tier 1~4 본문 HTML 빌더 (개발 가이드 9·10장)
2
 
3
+ 함수는 본문 HTML 문자열을 반환, tiers.py가 tier-card 합성
4
+ Tier 4 액션 (거절/보류/승인 버튼) Streamlit 위젯이라 tiers.py에서 별도 렌더
 
5
  """
6
  from core.schema import Tier1, Tier2, Tier3, Tier4
7
 
8
 
9
+ def tier_1_body_html(data: Tier1) -> str:
10
+ features = data["features"]
11
+ max_val = features[0]["value"] if features else 1.0
12
+ score = data["score"]
13
 
14
+ feature_rows = "".join(
15
+ f"""
16
+ <div class="feat-row">
17
+ <span class="name">{f['name']}</span>
18
+ <div class="bar-track">
19
+ <div class="bar-fill" style="width: {(f['value']/max_val)*100:.0f}%; opacity: {1 - i*0.15:.2f};"></div>
20
+ </div>
21
+ <span class="val">{f['value']:.2f}</span>
22
+ </div>
23
+ """
24
+ for i, f in enumerate(features)
25
+ )
26
 
27
+ return f"""
28
+ <div class="t1-grid">
29
+ <div class="score-tile">
30
+ <div class="score-tile-label">이상 점수</div>
31
+ <div class="score-tile-value">{score:.2f}</div>
32
+ <div class="score-tile-meta">임계 0.50 · 모델 IsolationForest</div>
33
+ <div class="score-tile-bar"><span style="width: {score*100:.0f}%;"></span></div>
34
+ </div>
35
+ <div class="feat-block">
36
+ <div class="feat-block-title">기여 피처 (Top {len(features)}) <span class="hint">|z-score| 기준</span></div>
37
+ {feature_rows}
38
+ </div>
39
+ </div>
40
+ <div class="lot-strip">
41
+ <div>
42
+ <span class="lab">영향 lot · </span>
43
+ <span class="lot">{data['lot']['id']}</span>
44
+ </div>
45
+ <span class="count">{data['lot']['wafers']}장</span>
46
+ </div>
47
+ """
48
 
49
 
50
+ def tier_2_body_html(data: Tier2) -> str:
51
+ causes = data["causes"]
52
 
53
+ def row(i, c):
54
+ primary = " primary" if i == 0 else ""
55
+ citations_html = "".join(
56
+ f'<span class="cite-tag">📄 {cid}</span>' for cid in c["citations"]
57
+ )
58
+ return f"""
59
+ <div class="cause-row{primary}">
60
+ <div class="cause-rank">{i+1}</div>
61
+ <div class="cause-body">
62
+ <div class="cause-top">
63
+ <span class="cause-name">{c['name']}</span>
64
+ <span class="cause-pct">{c['pct']}%</span>
65
+ </div>
66
+ <div class="cause-bar"><span style="width: {c['pct']}%;"></span></div>
67
+ <div class="cause-evidence">{c['evidence']}</div>
68
+ <div class="cause-cite">{citations_html}</div>
69
+ </div>
70
+ <div></div>
71
+ </div>
72
+ """
73
 
74
+ rows = "".join(row(i, c) for i, c in enumerate(causes))
75
+ return f'<div class="cause-list">{rows}</div>'
76
+
77
+
78
+ def tier_3_body_html(data: Tier3) -> str:
79
+ yield_loss = data["yield_loss"]
80
+ # dep-graph CSS는 3노드(1fr 24px 1fr 24px 1fr) 기준이라 처음 3개만 렌더
81
+ deps = data["dependencies"][:3]
82
+ impact_lots = data["impact_lots"]
83
+
84
+ dep_nodes = []
85
+ for i, d in enumerate(deps):
86
+ dep_nodes.append(
87
+ f"""
88
+ <div class="dep-node {d['kind']}">
89
+ <div class="dep-stage-name">{d['stage']}</div>
90
+ <div class="dep-delta">{d['delta']}</div>
91
+ <div class="dep-tag">{d['tag']}</div>
92
+ </div>
93
+ """
94
+ )
95
+ if i < len(deps) - 1:
96
+ dep_nodes.append('<div class="dep-arrow">→</div>')
97
+
98
+ impact_lots_html = "".join(
99
+ f"""
100
+ <div class="impact-lot">
101
+ <span class="impact-lot-label">{l['label']}</span>
102
+ <span class="impact-lot-value">
103
+ <span class="lots-num">{l['lots']} lot</span>
104
+ <span class="wafer">/ {l['wafers']}장</span>
105
+ </span>
106
+ </div>
107
+ """
108
+ for l in impact_lots
109
+ )
110
+
111
+ return f"""
112
+ <div class="t3-grid">
113
+ <div class="loss-tile">
114
+ <div class="loss-tile-label">예상 수율 손실</div>
115
+ <div class="loss-tile-value">{yield_loss}<span class="unit">%p</span></div>
116
+ <div class="loss-tile-meta">동일 스캐너 처리 lot 전체 기준</div>
117
+ </div>
118
+ <div class="dep-block">
119
+ <div class="feat-block-title">공정 의존성 그래프</div>
120
+ <div class="dep-graph">{"".join(dep_nodes)}</div>
121
+ </div>
122
+ </div>
123
+ <div class="impact-lots">{impact_lots_html}</div>
124
+ """
125
+
126
+
127
+ def tier_4_body_html(data: Tier4) -> str:
128
+ def item(i, a):
129
+ meta_html = (
130
+ f'<span class="action-meta">{a["meta"]}</span>'
131
+ if a.get("meta") else "<span></span>"
132
+ )
133
+ return f"""
134
+ <div class="action-item">
135
+ <span class="action-num">{i+1}</span>
136
+ <span class="action-text">{a['text']}</span>
137
+ {meta_html}
138
+ </div>
139
+ """
140
+
141
+ imm_items = "".join(item(i, a) for i, a in enumerate(data["immediate"]))
142
+ lng_items = "".join(item(i, a) for i, a in enumerate(data["longterm"]))
143
+ refs_html = "".join(
144
+ f'<li><code>{r["id"]}</code> — {r["desc"]}</li>' for r in data["refs"]
145
+ )
146
+
147
+ return f"""
148
+ <div class="action-section">
149
+ <div class="action-section-head">
150
+ <span class="badge imm">즉시 조치</span>
151
+ <span>응급 대응</span>
152
+ </div>
153
+ <div class="action-box urgent">{imm_items}</div>
154
+ </div>
155
+ <div class="action-section">
156
+ <div class="action-section-head">
157
+ <span class="badge lng">중장기</span>
158
+ <span>재발 방지</span>
159
+ </div>
160
+ <div class="action-box">{lng_items}</div>
161
+ </div>
162
+ <div class="refs">
163
+ <div class="refs-title">📚 근거 자료</div>
164
+ <ul>{refs_html}</ul>
165
+ </div>
166
+ """
styles/main.css CHANGED
@@ -1,42 +1,1184 @@
1
- /* FabAgent 전체 스타일
2
- TODO(M1 / feat/css-port): 디자인 프로토타입의 styles.css 전체를 여기에 그대로 복사
3
- 토큰/간격/애니메이션이 모두 안에 검증돼 있으므로 다시 작성하지 말 것
4
- 아래는 디자인 토큰 :root + Streamlit 기본 요소 숨김만 미리 둔 시작점 */
5
 
6
- :root {
7
- --text-primary: #1F2A3A;
8
- --text-secondary: #6B7788;
9
- --text-tertiary: #9AA3B2;
10
- --bg-page: #FAFBFC;
11
- --bg-card: #FFFFFF;
12
- --bg-subtle: #F4F6F9;
13
- --border: #E3E6EC;
14
- --brand: #2C5AB8; /* Tier 1 파랑 */
15
- --t2-text: #6E46A8; /* Tier 2 보라 */
16
- --t3-text: #2A8A55; /* Tier 3 초록 */
17
- --t4-text: #B07718; /* Tier 4 앰버 */
18
- --crit-text: #C04A6E; /* 긴급 핑크 */
19
- }
20
 
21
- /* Streamlit 기본 요소 숨기기 */
22
  #MainMenu { visibility: hidden; }
23
  footer { visibility: hidden; }
24
  header[data-testid="stHeader"] { display: none; }
25
 
26
- /* 상단 여백 제거 + 폭 제한 해제 */
27
  .block-container {
28
- padding-top: 1rem;
29
  padding-bottom: 4rem;
30
- max-width: none;
31
  }
32
 
33
- /* 사이드바 너비 320px 고정 */
34
  section[data-testid="stSidebar"] {
35
  width: 320px !important;
36
  min-width: 320px !important;
 
37
  background: var(--bg-card);
38
  border-right: 1px solid var(--border);
 
 
 
39
  }
40
  section[data-testid="stSidebar"] > div {
41
  padding-top: 0;
42
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ FabAgent Operator Dashboard
3
+ Light, low-saturation, info-dense operations tool aesthetic
4
+ ============================================================ */
5
 
6
+ /* ============== Streamlit chrome 숨김 + 레이아웃 보정 ============== */
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
 
8
  #MainMenu { visibility: hidden; }
9
  footer { visibility: hidden; }
10
  header[data-testid="stHeader"] { display: none; }
11
 
 
12
  .block-container {
13
+ padding-top: 0 !important;
14
  padding-bottom: 4rem;
15
+ max-width: none !important;
16
  }
17
 
 
18
  section[data-testid="stSidebar"] {
19
  width: 320px !important;
20
  min-width: 320px !important;
21
+ max-width: 320px !important;
22
  background: var(--bg-card);
23
  border-right: 1px solid var(--border);
24
+ transform: translateX(0) !important;
25
+ visibility: visible !important;
26
+ display: block !important;
27
  }
28
  section[data-testid="stSidebar"] > div {
29
  padding-top: 0;
30
  }
31
+ section[data-testid="stSidebar"] [data-testid="stSidebarContent"] {
32
+ padding: 0;
33
+ }
34
+ /* 사이드바 상단 빈 헤더(collapse 버튼 자리) 자체를 제거해 로고가 최상단에 붙도록 */
35
+ section[data-testid="stSidebar"] [data-testid="stSidebarHeader"],
36
+ [data-testid="stSidebarCollapseButton"],
37
+ [data-testid="stSidebarCollapsedControl"],
38
+ [data-testid="collapsedControl"] {
39
+ display: none !important;
40
+ }
41
+
42
+ /* Streamlit이 위젯 사이에 자동으로 끼우는 간격 축소 */
43
+ [data-testid="stVerticalBlock"] { gap: 0.4rem; }
44
+
45
+ /* 알람 카드 전체를 클릭 영역으로, <a> 래퍼는 시각적으로 투명 */
46
+ section[data-testid="stSidebar"] [data-testid="stVerticalBlock"] {
47
+ gap: 0 !important;
48
+ }
49
+ a.alarm-card-link {
50
+ display: block;
51
+ text-decoration: none;
52
+ color: inherit;
53
+ margin-top: 12px;
54
+ }
55
+ a.alarm-card-link:last-of-type {
56
+ margin-bottom: 16px;
57
+ }
58
+ a.alarm-card-link:focus,
59
+ a.alarm-card-link:focus-visible {
60
+ outline: none;
61
+ }
62
+
63
+ :root {
64
+ /* Neutrals */
65
+ --text-primary: #1F2A3A;
66
+ --text-secondary: #6B7788;
67
+ --text-tertiary: #9AA3B2;
68
+ --bg-page: #FAFBFC;
69
+ --bg-card: #FFFFFF;
70
+ --bg-subtle: #F4F6F9;
71
+ --border: #E3E6EC;
72
+ --border-strong: #CFD4DC;
73
+
74
+ /* Tier 1 - 파랑 (이상 탐지) */
75
+ --t1-bg: #E8F1FD;
76
+ --t1-border: #A4C2F1;
77
+ --t1-text: #2C5AB8;
78
+ --t1-bg-soft: #F4F8FE;
79
+
80
+ /* Tier 2 - 보라 (원인 분석) */
81
+ --t2-bg: #F1ECF8;
82
+ --t2-border: #C3AEDF;
83
+ --t2-text: #6E46A8;
84
+ --t2-bg-soft: #F8F5FC;
85
+
86
+ /* Tier 3 - 초록 (공정 간 영향) */
87
+ --t3-bg: #E8F5EE;
88
+ --t3-border: #9DCFB3;
89
+ --t3-text: #2A8A55;
90
+ --t3-bg-soft: #F4FAF6;
91
+
92
+ /* Tier 4 - 앰버 (대응 권고) */
93
+ --t4-bg: #FDF2DE;
94
+ --t4-border: #E5C280;
95
+ --t4-text: #B07718;
96
+ --t4-bg-soft: #FEF9EE;
97
+
98
+ /* Critical / 긴급 - 핑크 */
99
+ --crit-bg: #FDECF1;
100
+ --crit-border: #F0A8BC;
101
+ --crit-text: #C04A6E;
102
+
103
+ /* Brand */
104
+ --brand: #2C5AB8;
105
+ --brand-deep: #1F4290;
106
+
107
+ /* Status */
108
+ --ok: #2A8A55;
109
+ --ok-bg: #E8F5EE;
110
+ --warn: #B07718;
111
+ --danger: #C04A6E;
112
+
113
+ /* Layout */
114
+ --header-h: 64px;
115
+ --sidebar-w: 320px;
116
+ --radius: 8px;
117
+ --radius-sm: 6px;
118
+ --shadow-card: 0 1px 2px rgba(31,42,58,0.04), 0 1px 0 rgba(31,42,58,0.02);
119
+ --shadow-pop: 0 8px 24px rgba(31,42,58,0.12), 0 2px 6px rgba(31,42,58,0.06);
120
+
121
+ /* Type */
122
+ --font: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
123
+ --mono: "JetBrains Mono", "SF Mono", ui-monospace, Menlo, monospace;
124
+ }
125
+
126
+ html, body {
127
+ font-family: var(--font);
128
+ color: var(--text-primary);
129
+ background: var(--bg-page);
130
+ -webkit-font-smoothing: antialiased;
131
+ -moz-osx-font-smoothing: grayscale;
132
+ font-feature-settings: "ss01", "ss02", "tnum";
133
+ font-size: 13px;
134
+ line-height: 1.5;
135
+ }
136
+
137
+ /* ============== Header ============== */
138
+
139
+ .fab-header {
140
+ display: flex;
141
+ align-items: center;
142
+ padding: 12px 8px 16px;
143
+ background: var(--bg-card);
144
+ gap: 24px;
145
+ border-bottom: 1px solid var(--border);
146
+ margin-bottom: 18px;
147
+ }
148
+
149
+ .fab-logo {
150
+ display: flex;
151
+ align-items: center;
152
+ gap: 10px;
153
+ font-weight: 800;
154
+ font-size: 17px;
155
+ letter-spacing: -0.02em;
156
+ color: var(--brand);
157
+ }
158
+
159
+ .fab-logo-mark {
160
+ width: 28px;
161
+ height: 28px;
162
+ border-radius: 7px;
163
+ background: linear-gradient(135deg, var(--brand) 0%, #4A7FD9 100%);
164
+ display: grid;
165
+ place-items: center;
166
+ color: white;
167
+ font-size: 14px;
168
+ font-weight: 800;
169
+ box-shadow: 0 1px 2px rgba(44,90,184,0.3);
170
+ }
171
+
172
+ .fab-context {
173
+ display: flex;
174
+ align-items: center;
175
+ gap: 8px;
176
+ padding: 6px 12px;
177
+ background: var(--bg-subtle);
178
+ border: 1px solid var(--border);
179
+ border-radius: 6px;
180
+ font-size: 12px;
181
+ color: var(--text-primary);
182
+ }
183
+
184
+ .fab-context .ctx-label {
185
+ color: var(--text-tertiary);
186
+ font-size: 11px;
187
+ text-transform: uppercase;
188
+ letter-spacing: 0.04em;
189
+ }
190
+
191
+ .fab-context .ctx-sep {
192
+ width: 1px;
193
+ height: 12px;
194
+ background: var(--border-strong);
195
+ }
196
+
197
+ .fab-header-spacer { flex: 1; }
198
+
199
+ .fab-status {
200
+ display: flex;
201
+ align-items: center;
202
+ gap: 8px;
203
+ font-size: 12px;
204
+ color: var(--text-secondary);
205
+ }
206
+
207
+ .fab-status-dot {
208
+ width: 8px;
209
+ height: 8px;
210
+ border-radius: 50%;
211
+ background: var(--ok);
212
+ box-shadow: 0 0 0 3px rgba(42,138,85,0.15);
213
+ animation: pulse-ok 2.4s ease-in-out infinite;
214
+ }
215
+
216
+ @keyframes pulse-ok {
217
+ 0%, 100% { box-shadow: 0 0 0 3px rgba(42,138,85,0.15); }
218
+ 50% { box-shadow: 0 0 0 5px rgba(42,138,85,0.05); }
219
+ }
220
+
221
+ .fab-user {
222
+ display: flex;
223
+ align-items: center;
224
+ gap: 10px;
225
+ padding: 4px 4px 4px 12px;
226
+ background: var(--bg-subtle);
227
+ border-radius: 999px;
228
+ font-size: 12px;
229
+ }
230
+
231
+ .fab-user .avatar {
232
+ width: 26px;
233
+ height: 26px;
234
+ border-radius: 50%;
235
+ background: linear-gradient(135deg, #6E46A8, #C04A6E);
236
+ color: white;
237
+ font-weight: 700;
238
+ font-size: 11px;
239
+ display: grid;
240
+ place-items: center;
241
+ }
242
+
243
+ /* ============== Sidebar — Logo (home) ============== */
244
+
245
+ a.fab-sidebar-logo-link {
246
+ text-decoration: none;
247
+ color: inherit;
248
+ display: block;
249
+ }
250
+ .fab-sidebar-logo {
251
+ display: flex;
252
+ align-items: center;
253
+ gap: 12px;
254
+ padding: 22px 20px;
255
+ font-weight: 800;
256
+ font-size: 22px;
257
+ letter-spacing: -0.02em;
258
+ color: var(--brand);
259
+ cursor: pointer;
260
+ transition: background 0.15s;
261
+ border-bottom: 1px solid var(--border);
262
+ }
263
+ .fab-sidebar-logo .fab-logo-mark {
264
+ width: 38px;
265
+ height: 38px;
266
+ border-radius: 9px;
267
+ }
268
+ .fab-sidebar-logo .fab-logo-mark svg {
269
+ width: 22px;
270
+ height: 22px;
271
+ }
272
+ .fab-sidebar-logo:hover {
273
+ background: var(--bg-subtle);
274
+ }
275
+
276
+ /* ============== Sidebar — Alarm Inbox ============== */
277
+
278
+ .fab-sidebar-head {
279
+ padding: 18px 20px 12px;
280
+ border-bottom: 1px solid var(--border);
281
+ display: flex;
282
+ align-items: center;
283
+ justify-content: space-between;
284
+ }
285
+
286
+ .fab-sidebar-title {
287
+ font-size: 13px;
288
+ font-weight: 700;
289
+ letter-spacing: -0.01em;
290
+ }
291
+
292
+ .fab-sidebar-count {
293
+ display: inline-flex;
294
+ align-items: center;
295
+ gap: 6px;
296
+ font-size: 11px;
297
+ color: var(--text-secondary);
298
+ }
299
+
300
+ .fab-sidebar-count .badge {
301
+ background: var(--crit-bg);
302
+ color: var(--crit-text);
303
+ padding: 2px 7px;
304
+ border-radius: 999px;
305
+ font-weight: 700;
306
+ font-size: 10px;
307
+ }
308
+
309
+ .alarm-card {
310
+ position: relative;
311
+ background: var(--bg-card);
312
+ border: 1px solid var(--border);
313
+ border-radius: 8px;
314
+ padding: 14px 14px 14px 16px;
315
+ cursor: pointer;
316
+ transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
317
+ overflow: hidden;
318
+ margin: 0 12px;
319
+ }
320
+ a.alarm-card-link:hover .alarm-card {
321
+ border-color: var(--border-strong);
322
+ box-shadow: var(--shadow-card);
323
+ }
324
+
325
+ .alarm-card:hover {
326
+ background: var(--bg-subtle);
327
+ border-color: var(--border-strong);
328
+ }
329
+
330
+ .alarm-card.selected {
331
+ background: var(--t1-bg-soft);
332
+ border-color: var(--t1-border);
333
+ }
334
+
335
+ .alarm-card.selected::before {
336
+ content: "";
337
+ position: absolute;
338
+ left: 0; top: 0; bottom: 0;
339
+ width: 4px;
340
+ background: var(--brand);
341
+ }
342
+
343
+ .alarm-card.completed {
344
+ opacity: 0.62;
345
+ }
346
+
347
+ .alarm-head {
348
+ display: flex;
349
+ justify-content: space-between;
350
+ align-items: center;
351
+ margin-bottom: 8px;
352
+ }
353
+
354
+ .alarm-title {
355
+ font-size: 13px;
356
+ font-weight: 700;
357
+ letter-spacing: -0.01em;
358
+ margin: 2px 0 4px;
359
+ }
360
+
361
+ .alarm-meta {
362
+ display: flex;
363
+ flex-direction: column;
364
+ gap: 3px;
365
+ font-size: 11px;
366
+ color: var(--text-secondary);
367
+ }
368
+
369
+ .alarm-meta .feat {
370
+ display: inline-flex;
371
+ align-items: center;
372
+ gap: 4px;
373
+ color: var(--text-primary);
374
+ font-weight: 500;
375
+ }
376
+
377
+ .alarm-feat-arrow {
378
+ display: inline-block;
379
+ color: var(--danger);
380
+ font-weight: 800;
381
+ }
382
+
383
+ .alarm-time {
384
+ font-size: 10px;
385
+ color: var(--text-tertiary);
386
+ margin-top: 8px;
387
+ padding-top: 8px;
388
+ border-top: 1px dashed var(--border);
389
+ }
390
+
391
+ .alarm-selected-tag {
392
+ display: inline-flex;
393
+ align-items: center;
394
+ gap: 3px;
395
+ font-size: 10px;
396
+ color: var(--brand);
397
+ font-weight: 700;
398
+ }
399
+
400
+ .alarm-card.critical:not(.completed):not(.selected) {
401
+ border-color: var(--crit-border);
402
+ background: linear-gradient(180deg, #FFF8FA 0%, #FFFFFF 100%);
403
+ }
404
+
405
+ /* ============== Chip ============== */
406
+
407
+ .chip {
408
+ display: inline-flex;
409
+ align-items: center;
410
+ gap: 4px;
411
+ font-size: 10px;
412
+ font-weight: 700;
413
+ padding: 3px 8px;
414
+ border-radius: 999px;
415
+ border: 1px solid transparent;
416
+ letter-spacing: 0.02em;
417
+ white-space: nowrap;
418
+ }
419
+ .chip-critical { background: var(--crit-bg); color: var(--crit-text); border-color: var(--crit-border); }
420
+ .chip-warn { background: var(--t4-bg); color: var(--t4-text); border-color: var(--t4-border); }
421
+ .chip-done { background: var(--bg-subtle); color: var(--text-secondary); border-color: var(--border); }
422
+ .chip-t1 { background: var(--t1-bg); color: var(--t1-text); border-color: var(--t1-border); }
423
+ .chip-t2 { background: var(--t2-bg); color: var(--t2-text); border-color: var(--t2-border); }
424
+ .chip-t3 { background: var(--t3-bg); color: var(--t3-text); border-color: var(--t3-border); }
425
+ .chip-t4 { background: var(--t4-bg); color: var(--t4-text); border-color: var(--t4-border); }
426
+ .chip-neutral { background: var(--bg-subtle); color: var(--text-secondary); border-color: var(--border); }
427
+ .chip-ok { background: var(--ok-bg); color: var(--ok); border-color: #9DCFB3; }
428
+
429
+ .chip .dot {
430
+ width: 6px; height: 6px; border-radius: 50%; background: currentColor;
431
+ }
432
+
433
+ .chip-critical .dot { animation: pulse-crit 1.2s ease-in-out infinite; }
434
+ @keyframes pulse-crit {
435
+ 0%, 100% { transform: scale(1); opacity: 1; }
436
+ 50% { transform: scale(1.4); opacity: 0.6; }
437
+ }
438
+
439
+ /* ============== Main title + progress ============== */
440
+
441
+ .fab-main-title {
442
+ font-size: 19px;
443
+ font-weight: 800;
444
+ letter-spacing: -0.025em;
445
+ margin: 0 0 4px;
446
+ }
447
+
448
+ .fab-main-sub {
449
+ font-size: 12px;
450
+ color: var(--text-secondary);
451
+ display: flex;
452
+ align-items: center;
453
+ gap: 10px;
454
+ }
455
+
456
+ .fab-main-sub .sep {
457
+ color: var(--text-tertiary);
458
+ }
459
+
460
+ .fab-progress {
461
+ display: flex;
462
+ align-items: center;
463
+ gap: 4px;
464
+ justify-content: flex-end;
465
+ }
466
+
467
+ .fab-progress-step {
468
+ display: flex;
469
+ align-items: center;
470
+ gap: 6px;
471
+ padding: 6px 10px;
472
+ border-radius: 6px;
473
+ font-size: 11px;
474
+ font-weight: 600;
475
+ background: var(--bg-subtle);
476
+ color: var(--text-tertiary);
477
+ border: 1px solid transparent;
478
+ transition: all 0.3s;
479
+ }
480
+
481
+ .fab-progress-step.active {
482
+ background: var(--bg-card);
483
+ color: var(--text-primary);
484
+ border-color: var(--border);
485
+ }
486
+
487
+ .fab-progress-step.done {
488
+ color: var(--ok);
489
+ background: var(--ok-bg);
490
+ }
491
+
492
+ .fab-progress-step .num {
493
+ width: 16px; height: 16px;
494
+ border-radius: 50%;
495
+ background: var(--border);
496
+ color: var(--bg-card);
497
+ display: inline-grid;
498
+ place-items: center;
499
+ font-size: 9px;
500
+ font-weight: 800;
501
+ }
502
+
503
+ .fab-progress-step.active .num { background: var(--brand); color: white; }
504
+ .fab-progress-step.done .num { background: var(--ok); color: white; }
505
+
506
+ .fab-progress .conn {
507
+ width: 12px; height: 1px; background: var(--border);
508
+ }
509
+
510
+ /* ============== Empty state ============== */
511
+
512
+ .fab-empty {
513
+ height: 60vh;
514
+ display: grid;
515
+ place-items: center;
516
+ text-align: center;
517
+ }
518
+ .fab-empty-inner {
519
+ max-width: 360px;
520
+ color: var(--text-secondary);
521
+ }
522
+ .fab-empty-icon {
523
+ width: 64px; height: 64px;
524
+ margin: 0 auto 16px;
525
+ border-radius: 16px;
526
+ background: var(--bg-card);
527
+ border: 1px dashed var(--border-strong);
528
+ display: grid;
529
+ place-items: center;
530
+ color: var(--text-tertiary);
531
+ }
532
+ .fab-empty h3 {
533
+ font-size: 15px;
534
+ color: var(--text-primary);
535
+ margin: 0 0 6px;
536
+ font-weight: 700;
537
+ }
538
+ .fab-empty p { font-size: 12px; margin: 0; }
539
+
540
+ /* ============== Tier card frame ============== */
541
+
542
+ .tier-card {
543
+ background: var(--bg-card);
544
+ border: 1px solid var(--border);
545
+ border-radius: var(--radius);
546
+ margin-bottom: 16px;
547
+ box-shadow: var(--shadow-card);
548
+ overflow: hidden;
549
+ animation: card-fade-in 0.45s cubic-bezier(0.22, 0.61, 0.36, 1);
550
+ }
551
+
552
+ @keyframes card-fade-in {
553
+ from { opacity: 0; transform: translateY(8px); }
554
+ to { opacity: 1; transform: translateY(0); }
555
+ }
556
+
557
+ .tier-head {
558
+ display: flex;
559
+ align-items: center;
560
+ justify-content: space-between;
561
+ padding: 16px 20px;
562
+ border-bottom: 1px solid var(--border);
563
+ background: var(--bg-card);
564
+ position: relative;
565
+ }
566
+
567
+ .tier-head::before {
568
+ content: "";
569
+ position: absolute;
570
+ left: 0; top: 0; bottom: 0;
571
+ width: 3px;
572
+ }
573
+
574
+ .tier-card.tier-1 .tier-head::before { background: var(--t1-text); }
575
+ .tier-card.tier-2 .tier-head::before { background: var(--t2-text); }
576
+ .tier-card.tier-3 .tier-head::before { background: var(--t3-text); }
577
+ .tier-card.tier-4 .tier-head::before { background: var(--t4-text); }
578
+
579
+ .tier-head-left {
580
+ display: flex;
581
+ align-items: center;
582
+ gap: 12px;
583
+ }
584
+
585
+ .tier-name {
586
+ font-size: 14px;
587
+ font-weight: 700;
588
+ letter-spacing: -0.01em;
589
+ }
590
+
591
+ .tier-status {
592
+ display: flex;
593
+ align-items: center;
594
+ gap: 6px;
595
+ font-size: 11px;
596
+ color: var(--text-secondary);
597
+ }
598
+
599
+ .tier-status .check {
600
+ color: var(--ok);
601
+ font-weight: 800;
602
+ }
603
+
604
+ .tier-body { padding: 20px; }
605
+
606
+ /* Loading spinner */
607
+ .spinner {
608
+ width: 14px;
609
+ height: 14px;
610
+ border: 2px solid var(--border);
611
+ border-top-color: var(--brand);
612
+ border-radius: 50%;
613
+ animation: spin 0.7s linear infinite;
614
+ display: inline-block;
615
+ }
616
+ @keyframes spin { to { transform: rotate(360deg); } }
617
+
618
+ /* Skeleton */
619
+ .skel {
620
+ position: relative;
621
+ overflow: hidden;
622
+ background: var(--bg-subtle);
623
+ border-radius: 6px;
624
+ height: 12px;
625
+ }
626
+ .skel::after {
627
+ content: "";
628
+ position: absolute;
629
+ inset: 0;
630
+ background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.7) 50%, transparent 100%);
631
+ animation: shimmer 1.1s linear infinite;
632
+ }
633
+ @keyframes shimmer {
634
+ from { transform: translateX(-100%); }
635
+ to { transform: translateX(100%); }
636
+ }
637
+
638
+ .skel-row {
639
+ display: flex;
640
+ flex-direction: column;
641
+ gap: 10px;
642
+ }
643
+
644
+ .skel-block { height: 80px; border-radius: 8px; }
645
+
646
+ /* ============== Tier 1 body ============== */
647
+
648
+ .t1-grid {
649
+ display: grid;
650
+ grid-template-columns: 220px 1fr;
651
+ gap: 16px;
652
+ }
653
+
654
+ .score-tile {
655
+ background: linear-gradient(180deg, var(--t1-bg) 0%, var(--t1-bg-soft) 100%);
656
+ border: 1px solid var(--t1-border);
657
+ border-radius: var(--radius);
658
+ padding: 22px 20px;
659
+ display: flex;
660
+ flex-direction: column;
661
+ justify-content: space-between;
662
+ position: relative;
663
+ overflow: hidden;
664
+ }
665
+
666
+ .score-tile-label {
667
+ font-size: 11px;
668
+ font-weight: 700;
669
+ color: var(--t1-text);
670
+ text-transform: uppercase;
671
+ letter-spacing: 0.06em;
672
+ }
673
+
674
+ .score-tile-value {
675
+ font-size: 48px;
676
+ font-weight: 800;
677
+ color: var(--t1-text);
678
+ letter-spacing: -0.04em;
679
+ line-height: 1;
680
+ font-variant-numeric: tabular-nums;
681
+ margin: 8px 0;
682
+ }
683
+
684
+ .score-tile-meta {
685
+ font-size: 11px;
686
+ color: var(--t1-text);
687
+ opacity: 0.8;
688
+ display: flex;
689
+ align-items: center;
690
+ gap: 6px;
691
+ }
692
+
693
+ .score-tile-bar {
694
+ margin-top: 10px;
695
+ height: 4px;
696
+ background: rgba(44,90,184,0.15);
697
+ border-radius: 999px;
698
+ overflow: hidden;
699
+ }
700
+ .score-tile-bar > span {
701
+ display: block;
702
+ height: 100%;
703
+ background: var(--t1-text);
704
+ border-radius: 999px;
705
+ animation: bar-grow 0.8s cubic-bezier(0.22, 0.61, 0.36, 1) both;
706
+ }
707
+
708
+ @keyframes bar-grow {
709
+ from { width: 0 !important; }
710
+ }
711
+
712
+ .feat-block {
713
+ background: var(--bg-card);
714
+ border: 1px solid var(--border);
715
+ border-radius: var(--radius);
716
+ padding: 16px 18px;
717
+ }
718
+
719
+ .feat-block-title {
720
+ font-size: 12px;
721
+ font-weight: 700;
722
+ color: var(--text-primary);
723
+ margin-bottom: 14px;
724
+ display: flex;
725
+ align-items: center;
726
+ justify-content: space-between;
727
+ }
728
+
729
+ .feat-block-title .hint {
730
+ font-size: 10px;
731
+ color: var(--text-tertiary);
732
+ font-weight: 500;
733
+ }
734
+
735
+ .feat-row {
736
+ display: grid;
737
+ grid-template-columns: 110px 1fr 48px;
738
+ align-items: center;
739
+ gap: 10px;
740
+ padding: 6px 0;
741
+ font-size: 12px;
742
+ }
743
+
744
+ .feat-row + .feat-row { border-top: 1px dashed var(--border); }
745
+
746
+ .feat-row .name { font-weight: 600; }
747
+ .feat-row .val {
748
+ text-align: right;
749
+ font-variant-numeric: tabular-nums;
750
+ font-weight: 700;
751
+ color: var(--text-primary);
752
+ font-size: 12px;
753
+ }
754
+
755
+ .bar-track {
756
+ height: 8px;
757
+ background: var(--bg-subtle);
758
+ border-radius: 999px;
759
+ position: relative;
760
+ overflow: hidden;
761
+ }
762
+ .bar-fill {
763
+ position: absolute;
764
+ inset: 0 auto 0 0;
765
+ background: linear-gradient(90deg, var(--t1-text) 0%, #5680D5 100%);
766
+ border-radius: 999px;
767
+ animation: bar-grow 0.9s cubic-bezier(0.22, 0.61, 0.36, 1) both;
768
+ }
769
+
770
+ .lot-strip {
771
+ margin-top: 14px;
772
+ background: var(--bg-subtle);
773
+ border: 1px solid var(--border);
774
+ border-radius: var(--radius-sm);
775
+ padding: 10px 14px;
776
+ display: flex;
777
+ align-items: center;
778
+ justify-content: space-between;
779
+ font-size: 12px;
780
+ }
781
+
782
+ .lot-strip .lab {
783
+ color: var(--text-secondary);
784
+ font-size: 11px;
785
+ }
786
+ .lot-strip .lot {
787
+ font-family: var(--mono);
788
+ font-weight: 700;
789
+ font-size: 12px;
790
+ color: var(--text-primary);
791
+ }
792
+ .lot-strip .count {
793
+ background: var(--bg-card);
794
+ border: 1px solid var(--border);
795
+ padding: 2px 8px;
796
+ border-radius: 4px;
797
+ font-variant-numeric: tabular-nums;
798
+ font-weight: 600;
799
+ }
800
+
801
+ /* ============== Tier 2 body ============== */
802
+
803
+ .cause-list {
804
+ display: flex;
805
+ flex-direction: column;
806
+ gap: 12px;
807
+ }
808
+
809
+ .cause-row {
810
+ background: var(--bg-card);
811
+ border: 1px solid var(--border);
812
+ border-radius: var(--radius);
813
+ padding: 14px 18px;
814
+ position: relative;
815
+ display: grid;
816
+ grid-template-columns: 34px 1fr auto;
817
+ gap: 12px;
818
+ align-items: start;
819
+ }
820
+
821
+ .cause-row.primary {
822
+ background: var(--t2-bg-soft);
823
+ border-color: var(--t2-border);
824
+ }
825
+
826
+ .cause-rank {
827
+ width: 28px;
828
+ height: 28px;
829
+ border-radius: 8px;
830
+ background: var(--bg-subtle);
831
+ color: var(--text-secondary);
832
+ display: grid;
833
+ place-items: center;
834
+ font-weight: 800;
835
+ font-size: 13px;
836
+ font-variant-numeric: tabular-nums;
837
+ }
838
+ .cause-row.primary .cause-rank {
839
+ background: var(--t2-text);
840
+ color: white;
841
+ }
842
+
843
+ .cause-body { min-width: 0; }
844
+ .cause-top {
845
+ display: flex;
846
+ justify-content: space-between;
847
+ align-items: center;
848
+ gap: 12px;
849
+ margin-bottom: 8px;
850
+ }
851
+ .cause-name {
852
+ font-size: 14px;
853
+ font-weight: 700;
854
+ letter-spacing: -0.01em;
855
+ }
856
+ .cause-pct {
857
+ font-size: 18px;
858
+ font-weight: 800;
859
+ color: var(--t2-text);
860
+ font-variant-numeric: tabular-nums;
861
+ letter-spacing: -0.02em;
862
+ }
863
+ .cause-row:not(.primary) .cause-pct { color: var(--text-secondary); font-size: 15px; }
864
+
865
+ .cause-bar {
866
+ height: 6px;
867
+ background: var(--bg-subtle);
868
+ border-radius: 999px;
869
+ overflow: hidden;
870
+ margin-bottom: 10px;
871
+ }
872
+ .cause-bar > span {
873
+ display: block;
874
+ height: 100%;
875
+ background: linear-gradient(90deg, var(--t2-text) 0%, #9572CC 100%);
876
+ border-radius: 999px;
877
+ animation: bar-grow 0.9s cubic-bezier(0.22, 0.61, 0.36, 1) both;
878
+ }
879
+ .cause-row:not(.primary) .cause-bar > span {
880
+ background: linear-gradient(90deg, #A8A0BC 0%, #C3AEDF 100%);
881
+ }
882
+
883
+ .cause-evidence {
884
+ font-size: 11.5px;
885
+ color: var(--text-secondary);
886
+ display: flex;
887
+ align-items: flex-start;
888
+ gap: 6px;
889
+ margin-bottom: 6px;
890
+ line-height: 1.55;
891
+ }
892
+ .cause-evidence b { color: var(--text-primary); font-weight: 600; }
893
+
894
+ .cause-cite {
895
+ display: flex;
896
+ align-items: center;
897
+ gap: 6px;
898
+ flex-wrap: wrap;
899
+ font-size: 10.5px;
900
+ color: var(--text-tertiary);
901
+ }
902
+ .cite-tag {
903
+ display: inline-flex;
904
+ align-items: center;
905
+ gap: 4px;
906
+ font-family: var(--mono);
907
+ font-size: 10px;
908
+ font-weight: 600;
909
+ padding: 2px 7px;
910
+ background: var(--bg-card);
911
+ border: 1px solid var(--border);
912
+ border-radius: 4px;
913
+ color: var(--text-primary);
914
+ }
915
+
916
+ /* ============== Tier 3 body ============== */
917
+
918
+ .t3-grid {
919
+ display: grid;
920
+ grid-template-columns: 220px 1fr;
921
+ gap: 16px;
922
+ margin-bottom: 14px;
923
+ }
924
+
925
+ .loss-tile {
926
+ background: linear-gradient(180deg, #FDECF1 0%, #FFF6F9 100%);
927
+ border: 1px solid var(--crit-border);
928
+ border-radius: var(--radius);
929
+ padding: 22px 20px;
930
+ }
931
+
932
+ .loss-tile-label {
933
+ font-size: 11px;
934
+ font-weight: 700;
935
+ color: var(--crit-text);
936
+ text-transform: uppercase;
937
+ letter-spacing: 0.06em;
938
+ }
939
+
940
+ .loss-tile-value {
941
+ font-size: 48px;
942
+ font-weight: 800;
943
+ color: var(--crit-text);
944
+ letter-spacing: -0.04em;
945
+ line-height: 1;
946
+ margin: 8px 0;
947
+ font-variant-numeric: tabular-nums;
948
+ }
949
+ .loss-tile-value .unit { font-size: 22px; margin-left: 4px; }
950
+ .loss-tile-meta { font-size: 11px; color: var(--crit-text); opacity: 0.85; }
951
+
952
+ .dep-block {
953
+ background: var(--bg-card);
954
+ border: 1px solid var(--border);
955
+ border-radius: var(--radius);
956
+ padding: 16px 18px;
957
+ }
958
+
959
+ .dep-graph {
960
+ display: grid;
961
+ grid-template-columns: 1fr 24px 1fr 24px 1fr;
962
+ align-items: center;
963
+ gap: 8px;
964
+ margin-top: 12px;
965
+ }
966
+
967
+ .dep-node {
968
+ background: var(--bg-card);
969
+ border: 1px solid var(--border);
970
+ border-radius: var(--radius-sm);
971
+ padding: 12px;
972
+ text-align: center;
973
+ position: relative;
974
+ }
975
+
976
+ .dep-node.current {
977
+ background: var(--crit-bg);
978
+ border-color: var(--crit-border);
979
+ }
980
+ .dep-node.impacted {
981
+ background: var(--t4-bg);
982
+ border-color: var(--t4-border);
983
+ }
984
+ .dep-node.minor {
985
+ background: var(--bg-subtle);
986
+ }
987
+
988
+ .dep-stage-name {
989
+ font-size: 12px;
990
+ font-weight: 700;
991
+ margin-bottom: 6px;
992
+ letter-spacing: -0.01em;
993
+ }
994
+
995
+ .dep-delta {
996
+ font-size: 14px;
997
+ font-weight: 800;
998
+ font-variant-numeric: tabular-nums;
999
+ letter-spacing: -0.02em;
1000
+ }
1001
+
1002
+ .dep-node.current .dep-delta { color: var(--crit-text); }
1003
+ .dep-node.impacted .dep-delta { color: var(--t4-text); }
1004
+ .dep-node.minor .dep-delta { color: var(--text-secondary); }
1005
+
1006
+ .dep-tag {
1007
+ font-size: 9px;
1008
+ font-weight: 700;
1009
+ text-transform: uppercase;
1010
+ letter-spacing: 0.06em;
1011
+ margin-top: 6px;
1012
+ opacity: 0.7;
1013
+ }
1014
+
1015
+ .dep-arrow {
1016
+ display: grid;
1017
+ place-items: center;
1018
+ color: var(--text-tertiary);
1019
+ }
1020
+
1021
+ .impact-lots {
1022
+ display: grid;
1023
+ grid-template-columns: 1fr 1fr;
1024
+ gap: 10px;
1025
+ }
1026
+
1027
+ .impact-lot {
1028
+ background: var(--bg-subtle);
1029
+ border: 1px solid var(--border);
1030
+ border-radius: var(--radius-sm);
1031
+ padding: 12px 14px;
1032
+ display: flex;
1033
+ align-items: center;
1034
+ justify-content: space-between;
1035
+ }
1036
+
1037
+ .impact-lot-label {
1038
+ font-size: 11px;
1039
+ color: var(--text-secondary);
1040
+ }
1041
+ .impact-lot-value {
1042
+ font-size: 13px;
1043
+ font-weight: 700;
1044
+ font-variant-numeric: tabular-nums;
1045
+ }
1046
+ .impact-lot-value .lots-num { color: var(--text-primary); }
1047
+ .impact-lot-value .wafer { font-size: 11px; color: var(--text-secondary); margin-left: 6px; font-weight: 500; }
1048
+
1049
+ /* ============== Tier 4 body ============== */
1050
+
1051
+ .action-section {
1052
+ margin-bottom: 14px;
1053
+ }
1054
+
1055
+ .action-section-head {
1056
+ display: flex;
1057
+ align-items: center;
1058
+ gap: 8px;
1059
+ font-size: 12px;
1060
+ font-weight: 700;
1061
+ margin-bottom: 10px;
1062
+ color: var(--text-primary);
1063
+ }
1064
+
1065
+ .action-section-head .badge {
1066
+ font-size: 9px;
1067
+ font-weight: 700;
1068
+ letter-spacing: 0.04em;
1069
+ text-transform: uppercase;
1070
+ padding: 2px 7px;
1071
+ border-radius: 4px;
1072
+ }
1073
+ .action-section-head .badge.imm { background: var(--crit-bg); color: var(--crit-text); }
1074
+ .action-section-head .badge.lng { background: var(--bg-subtle); color: var(--text-secondary); border: 1px solid var(--border); }
1075
+
1076
+ .action-box {
1077
+ border: 1px solid var(--border);
1078
+ border-radius: var(--radius);
1079
+ background: var(--bg-card);
1080
+ overflow: hidden;
1081
+ }
1082
+
1083
+ .action-box.urgent {
1084
+ border-color: var(--crit-border);
1085
+ background: linear-gradient(180deg, #FFF6F9 0%, #FFFFFF 100%);
1086
+ }
1087
+
1088
+ .action-item {
1089
+ display: grid;
1090
+ grid-template-columns: 28px 1fr auto;
1091
+ gap: 12px;
1092
+ align-items: center;
1093
+ padding: 12px 16px;
1094
+ font-size: 13px;
1095
+ }
1096
+
1097
+ .action-item + .action-item { border-top: 1px solid var(--border); }
1098
+ .action-box.urgent .action-item + .action-item { border-top: 1px solid rgba(240,168,188,0.5); }
1099
+
1100
+ .action-num {
1101
+ width: 22px; height: 22px;
1102
+ border-radius: 6px;
1103
+ background: var(--bg-subtle);
1104
+ color: var(--text-secondary);
1105
+ font-weight: 800;
1106
+ font-size: 11px;
1107
+ display: grid;
1108
+ place-items: center;
1109
+ font-variant-numeric: tabular-nums;
1110
+ }
1111
+ .action-box.urgent .action-num {
1112
+ background: var(--crit-bg);
1113
+ color: var(--crit-text);
1114
+ }
1115
+
1116
+ .action-text { line-height: 1.5; }
1117
+ .action-meta {
1118
+ font-size: 10px;
1119
+ font-weight: 600;
1120
+ color: var(--text-tertiary);
1121
+ padding: 3px 8px;
1122
+ border-radius: 4px;
1123
+ background: var(--bg-subtle);
1124
+ white-space: nowrap;
1125
+ }
1126
+
1127
+ .refs {
1128
+ margin-top: 14px;
1129
+ font-size: 11.5px;
1130
+ color: var(--text-secondary);
1131
+ background: var(--bg-subtle);
1132
+ border: 1px solid var(--border);
1133
+ border-radius: var(--radius-sm);
1134
+ padding: 12px 14px;
1135
+ }
1136
+ .refs-title {
1137
+ font-size: 11px;
1138
+ font-weight: 700;
1139
+ color: var(--text-primary);
1140
+ margin-bottom: 6px;
1141
+ display: flex;
1142
+ align-items: center;
1143
+ gap: 6px;
1144
+ }
1145
+ .refs ul { margin: 0; padding-left: 18px; }
1146
+ .refs li { margin: 3px 0; }
1147
+ .refs li code {
1148
+ font-family: var(--mono);
1149
+ font-size: 10.5px;
1150
+ background: var(--bg-card);
1151
+ border: 1px solid var(--border);
1152
+ padding: 1px 6px;
1153
+ border-radius: 4px;
1154
+ color: var(--text-primary);
1155
+ }
1156
+
1157
+ /* Tier 4 액션 바 안의 Streamlit 버튼 스타일 */
1158
+ .stButton button[kind="primary"] {
1159
+ background: var(--ok) !important;
1160
+ border-color: var(--ok) !important;
1161
+ height: 40px !important;
1162
+ border-radius: 8px !important;
1163
+ font-weight: 700 !important;
1164
+ box-shadow: 0 1px 2px rgba(42,138,85,0.3), inset 0 1px 0 rgba(255,255,255,0.15) !important;
1165
+ }
1166
+ .stButton button[kind="primary"]:hover {
1167
+ background: #237046 !important;
1168
+ border-color: #237046 !important;
1169
+ }
1170
+
1171
+ .live-tick {
1172
+ display: inline-flex;
1173
+ align-items: center;
1174
+ gap: 6px;
1175
+ font-size: 10px;
1176
+ color: var(--text-tertiary);
1177
+ font-family: var(--mono);
1178
+ }
1179
+ .live-tick .dot {
1180
+ width: 6px; height: 6px;
1181
+ background: var(--danger);
1182
+ border-radius: 50%;
1183
+ animation: pulse-crit 1.2s ease-in-out infinite;
1184
+ }