diff --git a/.gitignore b/.gitignore
index d0b005b..18f2aa8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,6 +62,7 @@ data/*.db-shm
data/*.db-wal
data/*.sqlite
data/*.sqlite3
+data/*.lock
database/*.db
database/*.db-journal
database/*.db-shm
@@ -84,6 +85,7 @@ data/excel_exports/
# 上傳檔案
web/static/uploads/
web/static/screenshots/
+templates/__init__.py
# 測試與覆蓋率報告
.pytest_cache/
diff --git a/CONSTITUTION.md b/CONSTITUTION.md
index 401ede8..420eee7 100644
--- a/CONSTITUTION.md
+++ b/CONSTITUTION.md
@@ -2,7 +2,7 @@
> 本文件定義專案開發的核心準則與不可違反的規範
> **建立日期**: 2026-01-12
-> **當前版本**: V10.32 (Frontend v2 visual baseline)
+> **當前版本**: V10.33 (Frontend v2 dashboard feature flag)
> **最後更新**: 2026-04-30
---
@@ -134,6 +134,12 @@
- ❌ **禁止**: 使用純黑色 `#000`(刺眼)
- ❌ **禁止**: 使用低對比度顏色組合
+### 第 14.1 條:真實資料與真實頁面(絕對禁止違反)
+- ✅ **正確**: 所有新版頁面內容必須接正式後端資料、既有 route/service/API 或明確可追溯的資料來源。
+- ✅ **正確**: 尚未完成串接的功能,應顯示真實空狀態、錯誤狀態或「尚未提供資料來源」的可診斷訊息。
+- ❌ **禁止**: 使用 mock data、假商品、假 KPI、假排程、假使用者、假頁面或純展示用 placeholder 冒充已完成。
+- ❌ **禁止**: 為了符合原型畫面而改寫或捏造業務數字。
+
---
## 第五章:系統架構規範
diff --git a/app.py b/app.py
index 45fab0f..6f2955d 100644
--- a/app.py
+++ b/app.py
@@ -95,8 +95,8 @@ except Exception as e:
sys_log.error(f"無法檢測磁碟空間: {e}")
# 🚩 系統版本定義 (備份與顯示用)
-# 🚩 2026-04-30 V10.32: Frontend v2 visual baseline documented
-SYSTEM_VERSION = "V10.32"
+# 🚩 2026-04-30 V10.33: Frontend v2 dashboard feature flag
+SYSTEM_VERSION = "V10.33"
# ==========================================
# 🔒 SQL Injection 防護函數
diff --git a/config.py b/config.py
index 19cf40c..a02908a 100644
--- a/config.py
+++ b/config.py
@@ -254,7 +254,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
-SYSTEM_VERSION = "V10.32"
+SYSTEM_VERSION = "V10.33"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示
diff --git a/docs/guides/frontend_upgrade_roadmap.md b/docs/guides/frontend_upgrade_roadmap.md
index 53a97e1..6d78d30 100644
--- a/docs/guides/frontend_upgrade_roadmap.md
+++ b/docs/guides/frontend_upgrade_roadmap.md
@@ -16,6 +16,7 @@
- Dashboard 採「監控總覽 / 焦點數據 / 商品列表」工作台結構。
- 表格改為安靜米色表頭,不延續舊紫藍漸層表頭。
- 新增或改版頁面不得再以舊紫藍 Bootstrap 後台作為主要視覺基準。
+- 所有頁面內容必須使用真實資料來源;禁止用 mock data、假頁面或展示用 placeholder 冒充完成。
## 2. 現況判斷
@@ -196,7 +197,7 @@
1. 新增 tokens 與 shell CSS。
2. 新增 `_ewoooc_shell.html`,但不替換舊 navbar。
3. 新增一份新版 dashboard template 草稿,先掛在內部測試路由或 feature flag。
-4. 用同一份後端資料渲染新版 Dashboard。
+4. 用同一份後端真實資料渲染新版 Dashboard,不建立假商品或假統計。
5. 本機以瀏覽器檢查桌面與手機 viewport。
完成後再決定是否將 `/` 切到新版。
diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md
index b72f66e..5a0c867 100644
--- a/docs/memory/history_logs.md
+++ b/docs/memory/history_logs.md
@@ -55,6 +55,8 @@
- **CD sync mount drift guard**: 發現舊 app 容器未掛載 `app.py/config.py` 時,rsync 後服務檔已更新但 `/health` 版本仍卡 image 內舊檔;CD sync 會檢查 mount,僅 drift 時一次性 recreate momo-app,其餘維持 HUP 熱重載。
- **CD 單檔 bind mount inode 修復**: `app.py/config.py` 單檔 bind mount 會被 rsync/tar 的 inode replacement 卡住舊檔,CD rsync 改用 `--inplace`,避免 HUP reload 後仍讀到舊版本。
- **Frontend V2 視覺基準立案**: `MOMO Pro/` prototype 與 `docs/guides/frontend_upgrade_roadmap.md` 成為前端更版依據,AGENTS/CONSTITUTION 改以米色工作台、暖墨文字、焦糖橘 accent 與新版 shell 規範作為後續 UI 基準。
+- **Frontend V2 Phase 0 assets**: 新增 `web/static/css/ewoooc-tokens.css`、`web/static/css/ewoooc-shell.css`、`templates/ewoooc_base.html` 與 `_ewoooc_shell.html`,先建立可重用 shell,不替換既有頁面;憲章補「真實資料與真實頁面」紅線,禁止假資料冒充完成。
+- **Dashboard V2 feature flag**: `/` 預設仍走既有 `dashboard.html`,`/?ui=v2` 才渲染 `dashboard_v2.html`;新版頁沿用既有 dashboard 真實資料與篩選/排序參數,不建立 mock 商品或假 KPI。
### 2026-04-28~29:Phase 3e 重構大戰 + daily_sales cache 隱形 bug 根除
- **app.py 縮減 -10.8%**: 7,386 → 6,590 行,11 commits 全綠零 502。
diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py
index 9bb61a6..2f68bf2 100644
--- a/routes/dashboard_routes.py
+++ b/routes/dashboard_routes.py
@@ -555,7 +555,7 @@ def index():
filtered_items = [i for i in base_items if i['record'].product_id in new_product_ids]
elif filter_type == 'delisted':
for item in today_delisted_items:
- class MockRecord:
+ class DelistedRecord:
def __init__(self, p, price):
self.product = p
self.price = price
@@ -563,7 +563,7 @@ def index():
if not search_query or search_query.lower() in item['product'].name.lower():
filtered_items.append({
- 'record': MockRecord(item['product'], item['last_price']),
+ 'record': DelistedRecord(item['product'], item['last_price']),
'stats': {'1d_diff': 0, '7d_diff': 0, '30d_diff': 0},
'yesterday_diff': 0,
'today_changes': [],
@@ -619,7 +619,9 @@ def index():
category_name = item['record'].product.category
item['category_color'] = get_color_for_string(category_name)
- return render_template('dashboard.html',
+ template_name = 'dashboard_v2.html' if request.args.get('ui') == 'v2' else 'dashboard.html'
+
+ return render_template(template_name,
total_products=total_products_history,
today_new_products=today_new_products,
total_price_records=total_price_records,
diff --git a/templates/components/_ewoooc_shell.html b/templates/components/_ewoooc_shell.html
new file mode 100644
index 0000000..efec0a7
--- /dev/null
+++ b/templates/components/_ewoooc_shell.html
@@ -0,0 +1,101 @@
+{#
+ EwoooC Frontend V2 shell.
+
+ 使用方式:
+ {% include 'components/_ewoooc_shell.html' %}
+
+ 呼叫頁需提供 active_page;未提供時會以空字串處理。
+#}
+
+{% set _active_page = active_page|default('') %}
+{% set _scheduler = scheduler_stats|default({}) %}
+{% set _momo_runs = _scheduler.get('momo_task', []) if _scheduler is mapping else [] %}
+{% set _latest_run = _momo_runs[0] if _momo_runs else {} %}
+{% set _has_scheduler_data = _latest_run is mapping and _latest_run %}
+{% set _last_run = _latest_run.get('last_run', '--') if _has_scheduler_data else '--' %}
+{% set _scanned = _latest_run.get('scanned_count', _latest_run.get('total_products', '--')) if _latest_run is mapping else '--' %}
+{% set _added = _latest_run.get('new_records', _latest_run.get('added', '--')) if _latest_run is mapping else '--' %}
+{% set _run_status = _latest_run.get('status', '') if _has_scheduler_data else '' %}
+{% set _status_label = '尚無紀錄' if not _has_scheduler_data else ('最近成功' if _run_status in ['Success', 'success', 'SUCCESS'] else (_run_status or '已有紀錄')) %}
+{% set _next_run = next_run|default(None) %}
+{% set _session_username = session.get('username') if session is defined else None %}
+{% set _session_role = session.get('role') if session is defined else None %}
+{% set _is_logged_in = session.get('logged_in') if session is defined else false %}
+
+
+
+
diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html
new file mode 100644
index 0000000..02122d4
--- /dev/null
+++ b/templates/dashboard_v2.html
@@ -0,0 +1,700 @@
+{% extends 'ewoooc_base.html' %}
+
+{% block title %}EwoooC 商品看板{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block ewooo_content %}
+
+
+
+ 01
+ 監控總覽
+ LIVE · 更新於 {{ datetime_now }}
+
+
+
+
監控總數
+
{{ total_products | number_format }}
+
本週 +{{ week_new_products }}
+
+
+
今日變動
+
{{ active_count | number_format }}
+
活躍度 {{ activity_rate | round(1) }}%
+
+
+
漲價
+
{{ cnt_increase | number_format }}
+
平均 +${{ avg_increase | abs | int | number_format }}
+
+
+
降價
+
{{ cnt_decrease | number_format }}
+
平均 -${{ avg_decrease | abs | int | number_format }}
+
+
+
+
+
+
+ 02
+ 焦點數據
+ {{ today_date }}
+
+
+
+
最活躍分類
+ {% if most_active_category %}
+
{{ most_active_category }}
+
{{ most_active_count }} 件商品變動
+ {% else %}
+
尚無分類變動
+
今日沒有可彙整的分類異動
+ {% endif %}
+
+
+
+
最大變動
+ {% if max_change_item %}
+
+ {% if max_change_value > 0 %}+{% else %}-{% endif %}${{ max_change_value | abs | int | number_format }}
+
+
+ {{ max_change_item.record.product.name }}
+
+ {% else %}
+
尚無最大變動
+
今日沒有價格異動
+ {% endif %}
+
+
+
+
爬蟲排程
+ {% set momo_stats_list = scheduler_stats.get('momo_task', []) %}
+ {% if momo_stats_list %}
+ {% set latest_run = momo_stats_list[0] %}
+
{{ latest_run.last_run }}
+
+ 狀態 {{ latest_run.status | default('未標記') }}
+ {% if latest_run.scraped_count is defined %} · 掃描 {{ latest_run.scraped_count }} 筆{% endif %}
+ {% if latest_run.new_products is defined %} · 新增 +{{ latest_run.new_products }}{% endif %}
+
+ {% else %}
+
尚無排程紀錄
+
未讀到 scheduler_stats.json 的 momo_task 紀錄
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 分類
+ 商品名稱
+
+ 當天價格
+
+
+ 昨日漲跌
+
+
+ 週漲跌
+
+
+ 更新時間
+
+ 上架時間
+
+
+
+ {% for item in items %}
+ {% set product = item.record.product %}
+ {% set image_url = product.image_url or ('https://m.momoshop.com.tw/moscdn/goods/' ~ product.i_code ~ '_m.webp') %}
+
+ {{ product.category or '未分類' }}
+
+
+
+
+
+
+
+ ${{ item.record.price | int | number_format }}
+
+
+ {% if item.yesterday_diff > 0 %}
+ ▲ +{{ item.yesterday_diff | abs | int | number_format }}
+ {% elif item.yesterday_diff < 0 %}
+ ▼ -{{ item.yesterday_diff | abs | int | number_format }}
+ {% else %}
+ --
+ {% endif %}
+
+
+ {% set week_diff = item.stats.get('7d_diff', 0) %}
+ {% if week_diff > 0 %}
+ +{{ week_diff | int | number_format }}
+ {% elif week_diff < 0 %}
+ -{{ week_diff | abs | int | number_format }}
+ {% else %}
+ --
+ {% endif %}
+
+
+ {{ item.record.timestamp.strftime('%m-%d %H:%M') if item.record.timestamp else '--' }}
+
+
+ {{ item.safe_created_at.strftime('%m-%d %H:%M') if item.safe_created_at else '--' }}
+
+
+ {% else %}
+
+
+
+ {% if search_query %}
+ 找不到與「{{ search_query }}」相關的商品
+ {% else %}
+ 目前沒有符合條件的商品
+ {% endif %}
+
+
+
+ {% endfor %}
+
+
+
+
+ {% if total_pages > 1 %}
+
+ {% endif %}
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/templates/ewoooc_base.html b/templates/ewoooc_base.html
new file mode 100644
index 0000000..8f9ca81
--- /dev/null
+++ b/templates/ewoooc_base.html
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+ {% block title %}EwoooC{% endblock %}
+
+
+
+
+
+
+
+
+ {% block extra_css %}{% endblock %}
+
+
+
+ {% include 'components/_ewoooc_shell.html' %}
+
+ {% set _next_run = next_run|default(None) %}
+ {% set _session_username = session.get('username') if session is defined else None %}
+ {% set _session_role = session.get('role') if session is defined else None %}
+ {% set _is_logged_in = session.get('logged_in') if session is defined else false %}
+
+
+
+
+
+ {% block ewooo_content %}{% endblock %}
+
+
+
+
+
+
+ {% block extra_js %}{% endblock %}
+
+
diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py
new file mode 100644
index 0000000..bebafe0
--- /dev/null
+++ b/tests/test_frontend_v2_assets.py
@@ -0,0 +1,52 @@
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_frontend_v2_shell_assets_exist_and_are_indexed():
+ assert (ROOT / "web/static/css/ewoooc-tokens.css").exists()
+ assert (ROOT / "web/static/css/ewoooc-shell.css").exists()
+ assert (ROOT / "templates/ewoooc_base.html").exists()
+ assert (ROOT / "templates/components/_ewoooc_shell.html").exists()
+
+ agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
+ constitution = (ROOT / "CONSTITUTION.md").read_text(encoding="utf-8")
+ roadmap = (ROOT / "docs/guides/frontend_upgrade_roadmap.md").read_text(encoding="utf-8")
+
+ assert "docs/guides/frontend_upgrade_roadmap.md" in agents
+ assert "前端 V2 視覺基準" in constitution
+ assert "禁止用 mock data" in roadmap
+
+
+def test_frontend_v2_shell_uses_real_runtime_context():
+ shell = (ROOT / "templates/components/_ewoooc_shell.html").read_text(encoding="utf-8")
+ base = (ROOT / "templates/ewoooc_base.html").read_text(encoding="utf-8")
+
+ assert "scheduler_stats" in shell
+ assert "session.get('username')" in shell
+ assert "next_run|default(None)" in shell
+ assert "components/_ewoooc_shell.html" in base
+
+ forbidden_markers = [
+ "mockProducts",
+ "mockData",
+ "fake",
+ "假商品",
+ "假 KPI",
+ ]
+ combined = shell + "\n" + base
+ assert all(marker not in combined for marker in forbidden_markers)
+
+
+def test_dashboard_v2_is_feature_flagged_and_uses_real_dashboard_data():
+ route_source = (ROOT / "routes/dashboard_routes.py").read_text(encoding="utf-8")
+ dashboard = (ROOT / "templates/dashboard_v2.html").read_text(encoding="utf-8")
+
+ assert "request.args.get('ui') == 'v2'" in route_source
+ assert "template_name = 'dashboard_v2.html'" in route_source
+ assert "get_full_dashboard_data()" in route_source
+ assert "MockRecord" not in route_source
+ assert "{% for item in items %}" in dashboard
+ assert "mockProducts" not in dashboard
+ assert "假商品" not in dashboard
diff --git a/web/static/css/ewoooc-shell.css b/web/static/css/ewoooc-shell.css
new file mode 100644
index 0000000..3e38ab4
--- /dev/null
+++ b/web/static/css/ewoooc-shell.css
@@ -0,0 +1,537 @@
+body.momo-v2-body {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--momo-bg-body);
+ color: var(--momo-text-primary);
+}
+
+.momo-shell {
+ display: grid;
+ grid-template-columns: var(--momo-sidebar-width) minmax(0, 1fr);
+ min-height: 100vh;
+ background: var(--momo-bg-body);
+}
+
+.momo-sidebar {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ z-index: 20;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: var(--momo-bg-paper);
+ border-right: 1px solid var(--momo-border-light);
+}
+
+.momo-sidebar-logo {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ height: var(--momo-topbar-height);
+ padding: 0 20px;
+ color: var(--momo-text-primary);
+ text-decoration: none;
+ border-bottom: 1px solid var(--momo-border-light);
+}
+
+.momo-logo-mark {
+ display: grid;
+ flex: 0 0 auto;
+ width: 32px;
+ height: 32px;
+ grid-template-columns: repeat(3, 1fr);
+ grid-template-rows: repeat(3, 1fr);
+ gap: 1.5px;
+ padding: 5px;
+ color: var(--momo-text-inverse);
+ background: var(--momo-ink);
+ border-radius: 2px;
+}
+
+.momo-logo-mark span {
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.momo-logo-mark span:nth-child(5) {
+ background: transparent;
+}
+
+.momo-brand-word {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.05;
+}
+
+.momo-brand-name {
+ color: var(--momo-text-primary);
+ font-size: 18px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+}
+
+.momo-brand-subtitle {
+ margin-top: 3px;
+ color: var(--momo-text-secondary);
+}
+
+.momo-nav {
+ flex: 1;
+ overflow-y: auto;
+ padding: 12px 8px;
+}
+
+.momo-nav-group {
+ margin-bottom: 4px;
+}
+
+.momo-nav-group-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 14px 12px 8px;
+ color: var(--momo-text-tertiary);
+}
+
+.momo-nav-group-title::after {
+ content: "";
+ flex: 1;
+ height: 1px;
+ background: var(--momo-border-light);
+}
+
+.momo-nav-link {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-height: 38px;
+ margin-bottom: 2px;
+ padding: 9px 12px;
+ border: 1px solid transparent;
+ border-radius: var(--momo-radius-md);
+ color: var(--momo-text-primary);
+ font-size: var(--momo-font-size-sm);
+ font-weight: var(--momo-font-weight-medium);
+ text-decoration: none;
+ transition: var(--momo-transition-base), transform var(--momo-duration-fast) var(--momo-ease-out);
+}
+
+.momo-nav-link:hover {
+ color: var(--momo-text-primary);
+ background: var(--momo-accent-soft);
+}
+
+.momo-nav-link.is-active {
+ color: var(--momo-text-inverse);
+ background: var(--momo-accent);
+ font-weight: var(--momo-font-weight-semibold);
+}
+
+.momo-nav-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ color: inherit;
+}
+
+.momo-nav-label {
+ flex: 1;
+ min-width: 0;
+}
+
+.momo-nav-code {
+ color: currentColor;
+ font-size: 10px;
+ font-weight: 700;
+ opacity: 0.48;
+}
+
+.momo-nav-link.is-active .momo-nav-code {
+ opacity: 0.8;
+}
+
+.momo-nav-badge {
+ min-width: 20px;
+ padding: 1px 6px;
+ border-radius: 2px;
+ color: var(--momo-text-inverse);
+ background: var(--momo-accent);
+ font-size: 10px;
+ font-weight: 800;
+ text-align: center;
+}
+
+.momo-status-card {
+ position: relative;
+ margin: 12px;
+ padding: 14px;
+ overflow: hidden;
+ color: var(--momo-text-inverse);
+ background: var(--momo-ink-strong);
+ border: 1px solid rgba(201, 100, 66, 0.35);
+ border-radius: var(--momo-radius-md);
+}
+
+.momo-status-card::before {
+ position: absolute;
+ inset: 0;
+ content: "";
+ background-image: radial-gradient(circle, rgba(201, 100, 66, 0.12) 1px, transparent 1px);
+ background-size: 6px 6px;
+}
+
+.momo-status-card > * {
+ position: relative;
+}
+
+.momo-status-title {
+ margin-bottom: 8px;
+ color: rgba(250, 247, 240, 0.55);
+}
+
+.momo-status-active {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 10px;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.momo-live-dot {
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--momo-accent);
+ box-shadow: 0 0 8px var(--momo-accent);
+ animation: momo-pulse-dot 2s infinite;
+}
+
+.momo-status-meta {
+ color: rgba(250, 247, 240, 0.62);
+ font-size: 10px;
+ line-height: 1.7;
+}
+
+.momo-main-shell {
+ min-width: 0;
+ min-height: 100vh;
+}
+
+.momo-topbar {
+ position: sticky;
+ top: 0;
+ z-index: 15;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ height: var(--momo-topbar-height);
+ padding: 0 24px;
+ background: var(--momo-bg-surface);
+ border-bottom: 1px solid var(--momo-border-light);
+}
+
+.momo-mobile-menu-button {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border: 0;
+ border-radius: var(--momo-radius-md);
+ color: var(--momo-text-secondary);
+ background: transparent;
+ transition: var(--momo-transition-base);
+}
+
+.momo-mobile-menu-button:hover {
+ background: var(--momo-bg-subtle);
+}
+
+.momo-search-box {
+ display: flex;
+ flex: 1 1 320px;
+ align-items: center;
+ max-width: 480px;
+ min-width: 0;
+ height: 38px;
+ padding: 0 12px;
+ overflow: hidden;
+ color: var(--momo-text-secondary);
+ background: var(--momo-bg-paper);
+ border: 1px solid var(--momo-border);
+ border-radius: var(--momo-radius-md);
+ font-size: var(--momo-font-size-sm);
+ transition: var(--momo-transition-base);
+}
+
+.momo-search-box:hover {
+ background: var(--momo-bg-elevated);
+}
+
+.momo-search-box i {
+ margin-right: 10px;
+}
+
+.momo-search-input {
+ flex: 1;
+ min-width: 0;
+ padding: 0;
+ overflow: hidden;
+ color: var(--momo-text-primary);
+ background: transparent;
+ border: 0;
+ outline: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.momo-search-input::placeholder {
+ color: var(--momo-text-secondary);
+ opacity: 1;
+}
+
+.momo-shortcut {
+ padding: 2px 6px;
+ color: var(--momo-text-inverse);
+ background: var(--momo-accent);
+ border-radius: 2px;
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.momo-topbar-spacer {
+ flex: 1;
+}
+
+.momo-topbar-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ color: var(--momo-text-inverse);
+ background: var(--momo-ink-strong);
+ border: 1px solid rgba(201, 100, 66, 0.35);
+ border-radius: var(--momo-radius-md);
+ font-size: 11px;
+}
+
+.momo-icon-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border: 0;
+ border-radius: var(--momo-radius-md);
+ color: var(--momo-text-secondary);
+ background: transparent;
+ transition: var(--momo-transition-base);
+}
+
+.momo-icon-button:hover {
+ color: var(--momo-text-primary);
+ background: var(--momo-bg-subtle);
+}
+
+.momo-user-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ height: 40px;
+ padding: 4px 10px 4px 4px;
+ border: 0;
+ border-radius: var(--momo-radius-pill);
+ color: var(--momo-text-primary);
+ background: transparent;
+ transition: var(--momo-transition-base);
+}
+
+.momo-user-chip:hover {
+ background: var(--momo-bg-subtle);
+}
+
+.momo-avatar {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ color: var(--momo-text-inverse);
+ background: var(--momo-ink);
+ border-radius: var(--momo-radius-circle);
+ font-size: 13px;
+ font-weight: 800;
+}
+
+.momo-user-meta {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ min-width: 0;
+ line-height: 1.2;
+}
+
+.momo-user-name {
+ max-width: 96px;
+ overflow: hidden;
+ color: var(--momo-text-primary);
+ font-size: 13px;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.momo-user-role {
+ color: var(--momo-text-tertiary);
+ font-size: 11px;
+}
+
+.momo-content {
+ min-width: 0;
+ padding: 28px 32px 40px;
+}
+
+.momo-page-title {
+ margin: 0;
+ color: var(--momo-text-primary);
+ font-size: var(--momo-font-size-xl);
+ font-weight: 800;
+ letter-spacing: 0;
+ line-height: var(--momo-line-height-tight);
+}
+
+.momo-page-subtitle {
+ margin-top: 6px;
+ color: var(--momo-text-secondary);
+ font-size: var(--momo-font-size-sm);
+}
+
+.momo-shell-backdrop {
+ display: none;
+}
+
+@media (max-width: 1180px) {
+ .momo-shell {
+ grid-template-columns: var(--momo-sidebar-collapsed-width) minmax(0, 1fr);
+ }
+
+ .momo-sidebar-logo {
+ justify-content: center;
+ padding: 0;
+ }
+
+ .momo-brand-word,
+ .momo-nav-group-title,
+ .momo-nav-label,
+ .momo-nav-code,
+ .momo-status-card {
+ display: none;
+ }
+
+ .momo-nav-link {
+ justify-content: center;
+ padding: 10px;
+ }
+
+ .momo-nav-badge {
+ position: absolute;
+ width: 8px;
+ min-width: 8px;
+ height: 8px;
+ padding: 0;
+ overflow: hidden;
+ color: transparent;
+ border: 2px solid var(--momo-bg-paper);
+ border-radius: 50%;
+ transform: translate(12px, -10px);
+ }
+}
+
+@media (max-width: 820px) {
+ .momo-shell {
+ display: block;
+ }
+
+ .momo-sidebar {
+ position: fixed;
+ left: 0;
+ top: 0;
+ width: var(--momo-sidebar-width);
+ transform: translateX(-100%);
+ transition: transform var(--momo-duration-normal) var(--momo-ease-in-out);
+ }
+
+ .momo-shell.is-sidebar-open .momo-sidebar {
+ transform: translateX(0);
+ }
+
+ .momo-shell-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 18;
+ display: none;
+ background: var(--momo-bg-backdrop);
+ }
+
+ .momo-shell.is-sidebar-open .momo-shell-backdrop {
+ display: block;
+ }
+
+ .momo-brand-word,
+ .momo-nav-group-title,
+ .momo-nav-label,
+ .momo-nav-code,
+ .momo-status-card {
+ display: flex;
+ }
+
+ .momo-status-card {
+ display: block;
+ }
+
+ .momo-nav-link {
+ justify-content: flex-start;
+ padding: 9px 12px;
+ }
+
+ .momo-nav-badge {
+ position: static;
+ width: auto;
+ min-width: 20px;
+ height: auto;
+ padding: 1px 6px;
+ overflow: visible;
+ color: var(--momo-text-inverse);
+ border: 0;
+ border-radius: 2px;
+ transform: none;
+ }
+
+ .momo-topbar {
+ padding: 0 14px;
+ }
+
+ .momo-mobile-menu-button {
+ display: inline-flex;
+ }
+
+ .momo-search-box {
+ flex-basis: 160px;
+ }
+
+ .momo-shortcut,
+ .momo-topbar-pill,
+ .momo-user-meta {
+ display: none;
+ }
+
+ .momo-content {
+ padding: 20px 16px 32px;
+ }
+}
diff --git a/web/static/css/ewoooc-tokens.css b/web/static/css/ewoooc-tokens.css
new file mode 100644
index 0000000..8fa1f98
--- /dev/null
+++ b/web/static/css/ewoooc-tokens.css
@@ -0,0 +1,193 @@
+/**
+ * EwoooC Frontend V2 tokens.
+ * Source of truth: MOMO Pro/design-tokens.css.
+ */
+
+:root {
+ --momo-bg-body: #ebe6dc;
+ --momo-bg-surface: #faf7f0;
+ --momo-bg-elevated: #fdfaf3;
+ --momo-bg-subtle: #e2dccf;
+ --momo-bg-muted: #cfc7b5;
+ --momo-bg-paper: #f3eee2;
+
+ --momo-ink: #2a2520;
+ --momo-ink-strong: #1a1612;
+ --momo-ink-soft: #3d362f;
+ --momo-line: #2a2520;
+ --momo-line-soft: rgba(42, 37, 32, 0.18);
+ --momo-line-faint: rgba(42, 37, 32, 0.10);
+
+ --momo-accent: #c96442;
+ --momo-accent-50: #fbf2ef;
+ --momo-accent-100: #f5e1d9;
+ --momo-accent-200: #ecc3b3;
+ --momo-accent-500: #c96442;
+ --momo-accent-600: #b1543a;
+ --momo-accent-700: #8f4530;
+ --momo-accent-soft: rgba(201, 100, 66, 0.12);
+
+ --momo-primary: var(--momo-accent);
+ --momo-primary-50: var(--momo-accent-50);
+ --momo-primary-100: var(--momo-accent-100);
+ --momo-primary-200: var(--momo-accent-200);
+ --momo-primary-500: var(--momo-accent-500);
+ --momo-primary-600: var(--momo-accent-600);
+ --momo-primary-700: var(--momo-accent-700);
+
+ --momo-success: #2a7a3f;
+ --momo-success-bg: #e3ebd9;
+ --momo-success-border: #c5d4b0;
+ --momo-success-text: #1f5a2d;
+ --momo-danger: #b5342f;
+ --momo-danger-bg: #f0d8d4;
+ --momo-danger-border: #d9b1ac;
+ --momo-danger-text: #7d2520;
+ --momo-warning: #b88416;
+ --momo-warning-bg: #f3e7c4;
+ --momo-warning-border: #d9c590;
+ --momo-warning-text: #6e500e;
+ --momo-info: #2d5d80;
+ --momo-info-bg: #d8e2ea;
+ --momo-info-border: #b5c5d2;
+ --momo-info-text: #1d3e54;
+
+ --momo-text-primary: #2a2520;
+ --momo-text-secondary: #645c52;
+ --momo-text-tertiary: #9b9081;
+ --momo-text-disabled: #c4baa8;
+ --momo-text-inverse: #faf7f0;
+ --momo-text-link: #c96442;
+ --momo-text-link-hover: #8f4530;
+
+ --momo-border: #2a2520;
+ --momo-border-light: rgba(42, 37, 32, 0.16);
+ --momo-border-dark: #2a2520;
+ --momo-border-focus: #c96442;
+ --momo-divider: rgba(42, 37, 32, 0.12);
+ --momo-bg-overlay: rgba(26, 26, 26, 0.70);
+ --momo-bg-backdrop: rgba(26, 26, 26, 0.30);
+
+ --momo-font-display: "JetBrains Mono", "Space Mono", "SF Mono", Menlo, Consolas, monospace;
+ --momo-font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang TC", "Noto Sans TC", "Microsoft JhengHei", sans-serif;
+ --momo-font-family-mono: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
+
+ --momo-font-size-xs: 0.75rem;
+ --momo-font-size-sm: 0.8125rem;
+ --momo-font-size-base: 0.9375rem;
+ --momo-font-size-lg: 1.0625rem;
+ --momo-font-size-xl: 1.625rem;
+ --momo-font-size-2xl: 2.25rem;
+
+ --momo-font-weight-normal: 400;
+ --momo-font-weight-medium: 500;
+ --momo-font-weight-semibold: 600;
+ --momo-font-weight-bold: 700;
+
+ --momo-line-height-tight: 1.15;
+ --momo-line-height-base: 1.5;
+ --momo-line-height-loose: 1.7;
+
+ --momo-space-1: 0.25rem;
+ --momo-space-2: 0.5rem;
+ --momo-space-3: 0.75rem;
+ --momo-space-4: 1rem;
+ --momo-space-5: 1.5rem;
+ --momo-space-6: 2rem;
+ --momo-space-7: 3rem;
+ --momo-space-8: 4rem;
+
+ --momo-shadow-sm: 0 0 0 1px rgba(26, 26, 26, 0.08);
+ --momo-shadow-md: 0 0 0 1px rgba(26, 26, 26, 0.10);
+ --momo-shadow-lg: 0 12px 40px -8px rgba(26, 26, 26, 0.18), 0 0 0 1px rgba(26, 26, 26, 0.10);
+ --momo-shadow-colored: 0 0 0 2px rgba(201, 100, 66, 0.25);
+
+ --momo-radius-sm: 0.125rem;
+ --momo-radius-md: 0.25rem;
+ --momo-radius-lg: 0.375rem;
+ --momo-radius-pill: 50rem;
+ --momo-radius-circle: 50%;
+
+ --momo-duration-fast: 0.12s;
+ --momo-duration-normal: 0.2s;
+ --momo-duration-slow: 0.4s;
+ --momo-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+ --momo-ease-out: cubic-bezier(0, 0, 0.2, 1);
+ --momo-transition-base:
+ color var(--momo-duration-fast) var(--momo-ease-in-out),
+ background-color var(--momo-duration-fast) var(--momo-ease-in-out),
+ border-color var(--momo-duration-fast) var(--momo-ease-in-out),
+ box-shadow var(--momo-duration-fast) var(--momo-ease-in-out);
+
+ --momo-sidebar-width: 240px;
+ --momo-sidebar-collapsed-width: 72px;
+ --momo-topbar-height: 64px;
+}
+
+.momo-app,
+.momo-app * {
+ box-sizing: border-box;
+}
+
+.momo-app {
+ min-height: 100vh;
+ background: var(--momo-bg-body);
+ color: var(--momo-text-primary);
+ font-family: var(--momo-font-family);
+ font-size: var(--momo-font-size-base);
+ line-height: var(--momo-line-height-base);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.momo-display {
+ font-family: var(--momo-font-display);
+ font-feature-settings: "tnum", "ss01";
+}
+
+.momo-mono {
+ font-family: var(--momo-font-family-mono);
+ font-feature-settings: "tnum";
+}
+
+.momo-label {
+ font-family: var(--momo-font-display);
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.momo-dot-bg {
+ background-image: radial-gradient(circle, rgba(26, 26, 26, 0.12) 1px, transparent 1px);
+ background-size: 8px 8px;
+}
+
+.momo-scroll::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+.momo-scroll::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.momo-scroll::-webkit-scrollbar-thumb {
+ background: rgba(26, 26, 26, 0.18);
+ border-radius: var(--momo-radius-pill);
+}
+
+.momo-scroll::-webkit-scrollbar-thumb:hover {
+ background: rgba(26, 26, 26, 0.32);
+}
+
+@keyframes momo-pulse-dot {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.35;
+ }
+}