un2266 commited on
Commit
3ced5f9
·
1 Parent(s): ac51b6c

Phase 8-E:nav-col 桌面收合 icon bar + 手機漢堡抽屜

Browse files

nav-col 收合成窄 icon bar(桌面,60px,localStorage 記住狀態)與
off-canvas 抽屜(手機 ≤900px,漢堡鈕開/X、背景遮罩、Esc 都能關)。
提前補上 Heroicons 風格圖示(plus/home/trending-up/document,assets/
icons/*.svg + gr.Button(icon=);統計比較分析用 CSS ::before 掛
chart-bar,Accordion 沒有 icon 參數)——因為收合後純文字按鈕沒有圖示
會看不懂,跟千卉確認後決定把原訂「下一輪視覺打磨」的圖示需求提前
到這次一起做,範圍限縮在 nav-col 這批新按鈕。

gr.Button(icon=) 這版 Gradio 是渲染成 <img>,不是行內 svg,深色模式
用 filter 反相+去彩處理(img 不吃 currentColor)。收合狀態下重置
按鈕若被點擊,JS 會先自動展開 nav-col 再顯示確認彈窗,避免彈窗擠在
窄欄裡;nav-col 原本 gr.Column(min_width=200) 的 inline min-width 需要
額外用 !important 覆寫才能真正收窄(min-width 優先於 max-width)。

已用 Playwright 驗證 375/768/900/1024/1440 五個斷點無跑版無橫向捲動、
淺色/深色模式圖示皆正常、桌面收合展開/手機抽屜開關四種關閉路徑
(X、背景、Esc、點導覽項目自動關)、重置流程收合狀態下正確自動展開。

app_agent.py CHANGED
@@ -2473,6 +2473,146 @@ div[role="log"][aria-label="chatbot conversation"],
2473
  .homepage-footnote { margin-top: 2rem; text-align: center; font-size: .82rem; color: var(--c-text-subtle); }
2474
  @media (prefers-reduced-motion: reduce) { .scenario-card { transition: none; } }
2475
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2476
  /* ── 6. UPLOAD AREA(醒目卡片)─────────────────────────────────── */
2477
  /* 注意:max-width + margin auto 是舊版單欄置中版面留下的樣式,現在
2478
  upload-zone 是右側 action-col 裡的元件,要跟同一欄的 scenario-buttons
@@ -2894,12 +3034,13 @@ a:hover { color: var(--c-link-hover); }
2894
 
2895
  /* ── 17. RESPONSIVE ────────────────────────────────────────────── */
2896
  /* 三欄式工作區(nav-col/chat-col/canvas-col)僅限桌面寬度;手機或窄視窗
2897
- (≤900px)改回上下堆疊,且各欄不再各自獨立捲動(避免小螢幕出現多層
2898
- 巢狀捲軸,體驗更差),改用頁面本身捲動。桌面版收合成 icon bar、手機版
2899
- 漢堡抽屜留給 Phase 8-E 一起做 */
 
 
2900
  @media (max-width: 900px) {
2901
  .main-layout-row { flex-direction: column !important; }
2902
- .main-layout-row .nav-col,
2903
  .main-layout-row .chat-col,
2904
  .main-layout-row .canvas-col {
2905
  width: 100% !important;
@@ -3066,15 +3207,17 @@ _APP_HEAD_SCRIPT = """
3066
  }
3067
  document.addEventListener('click', function (e) {
3068
  if (e.target.closest('#nav_top_btn')) {
 
3069
  var scrollBox = document.querySelector('.canvas-scroll');
3070
  if (scrollBox) scrollBox.scrollTo({ top: 0, behavior: 'smooth' });
3071
  return;
3072
  }
3073
- if (e.target.closest('#nav_idx_btn')) { scrollToId('canvas_idx'); return; }
3074
- if (e.target.closest('#nav_report_btn')) { scrollToId('canvas_report'); return; }
3075
- if (e.target.closest('#nav_stat_accordion .label-wrap')) { scrollToId('canvas_stat'); return; }
3076
  for (var i = 0; i < 4; i++) {
3077
  if (e.target.closest('#nav_stat_sub_' + i)) {
 
3078
  scrollToId('canvas_stat');
3079
  /* 注意:這版 Gradio 的巢狀分頁 DOM 結構是
3080
  .tabs > .tab-wrapper > .tab-container(兩個,
@@ -3096,6 +3239,87 @@ _APP_HEAD_SCRIPT = """
3096
  }
3097
  bindResultNav();
3098
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3099
  /* 持續監聽 DOM 變化(每次訊息更新後重設) */
3100
  var mo = new MutationObserver(function(mutations) {
3101
  var needsFix = mutations.some(function(m) {
@@ -3141,9 +3365,30 @@ _APP_HEAD_SCRIPT = """
3141
  (見陷阱:內部捲動 + 頁面捲動疊在一起看起來像兩條捲軸),所以不再
3142
  需要 el.scrollTop 這種內部捲到底部的邏輯,避免又跟使用者手動捲動
3143
  搶主導權(同樣的教訓見對話視窗捲不到底那次的修正)。 */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3144
  setInterval(function () {
3145
  _watchZone('upload_zone');
3146
  _watchZone('metadata_zone');
 
 
3147
  }, 400);
3148
  }
3149
  if (document.readyState === 'loading') {
@@ -3234,11 +3479,41 @@ def create_app():
3234
  sc_btn_4 = gr.Button("4 不同時期重譯本的歷時比較", variant="secondary",
3235
  elem_id="sc_btn_4", elem_classes=["sc-bridge-btn"])
3236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3237
  # Phase 8-B:三欄式工作區——nav-col(窄,導覽/重置)+chat-col
3238
  # (對話視窗+輸入框,獨立捲動)+canvas-col(步驟列+上傳/表單/
3239
  # 執行紀錄,獨立捲動)。手機寬度見 CSS 的 media query 改上下堆疊。
3240
  with gr.Row(elem_classes=["main-layout-row"], visible=False) as workspace_row:
3241
  with gr.Column(scale=2, min_width=200, elem_classes=["nav-col"]) as nav_col:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3242
  # 研究情境是封閉式的固定四選一,改用可點擊按鈕取代打字輸入
3243
  # 數字再解析,選定後即隱藏,對話視窗回歸純輸出角色
3244
  # Phase 8:情境選擇按鈕已搬到 homepage_col(見上方,理由是
@@ -3252,7 +3527,10 @@ def create_app():
3252
  # 「開始新分析」與其確認彈窗:換情境/重新開始都走這一套共用
3253
  # 機制。情境是封閉式按鈕選擇,不透過對話框打字,所以「換
3254
  # 情境」也統一走這顆按鈕,不恢復文字解析路徑。
3255
- reset_btn = gr.Button("🆕 開始新分析", elem_classes=["reset-btn"], variant="secondary")
 
 
 
3256
  with gr.Column(visible=False, elem_classes=["reset-confirm"]) as reset_confirm_row:
3257
  gr.Markdown("確定要開始新分析嗎?目前進度將會遺失。")
3258
  with gr.Row(elem_classes=["upload-actions"]):
@@ -3268,10 +3546,14 @@ def create_app():
3268
  # 下一輪視覺打磨(Heroicons 圖示)一起補,這次先求導覽功能
3269
  # 正確可用。
3270
  with gr.Column(visible=False, elem_id="result_nav_col", elem_classes=["result-nav"]) as result_nav_col:
3271
- nav_top_btn = gr.Button("回到上傳/表單", elem_id="nav_top_btn",
3272
- elem_classes=["result-nav-btn"], variant="secondary")
3273
- nav_idx_btn = gr.Button("語言指標分析", elem_id="nav_idx_btn",
3274
- elem_classes=["result-nav-btn"], variant="secondary")
 
 
 
 
3275
  with gr.Accordion("統計比較分析", open=False, elem_id="nav_stat_accordion",
3276
  elem_classes=["result-nav-accordion"]):
3277
  nav_stat_sub_0 = gr.Button("敘述統計", elem_id="nav_stat_sub_0",
@@ -3282,8 +3564,10 @@ def create_app():
3282
  elem_classes=["result-nav-subbtn"], variant="secondary")
3283
  nav_stat_sub_3 = gr.Button("非監督式學習", elem_id="nav_stat_sub_3",
3284
  elem_classes=["result-nav-subbtn"], variant="secondary")
3285
- nav_report_btn = gr.Button("LLM輔助分析", elem_id="nav_report_btn",
3286
- elem_classes=["result-nav-btn"], variant="secondary")
 
 
3287
 
3288
  with gr.Column(scale=4, min_width=300, elem_classes=["chat-col"]):
3289
  chatbot = gr.Chatbot(
 
2473
  .homepage-footnote { margin-top: 2rem; text-align: center; font-size: .82rem; color: var(--c-text-subtle); }
2474
  @media (prefers-reduced-motion: reduce) { .scenario-card { transition: none; } }
2475
 
2476
+ /* ── 5e. NAV-COL 收合/手機漢堡抽屜(Phase 8-E)────────────────── */
2477
+ /* gr.Button(icon=...) 這版 Gradio 實測是渲染成 <img src="...">,不是行內
2478
+ <svg>,CSS currentColor 對它沒有作用(img 不會繼承文字顏色)。因此
2479
+ assets/icons/*.svg 檔案裡直接把 stroke 寫死成淺色模式的 --c-text-muted
2480
+ (#475569),深色模式改用 filter 把整張圖反相+去彩,逼近深色模式的
2481
+ --c-text-muted (#94a3b8) 色調,這是唯一能讓「檔案路徑圖示」跟著主題
2482
+ 變色的辦法(不用另外準備一份深色版 svg 檔案)。 */
2483
+ img.button-icon { opacity: .82; }
2484
+ .dark img.button-icon {
2485
+ filter: grayscale(1) invert(1) brightness(1.55) contrast(.85);
2486
+ }
2487
+ /* nav-col 收合/手機抽屜控制鈕:桌面收合鈕與手機關閉鈕互斥顯示,
2488
+ 預設(>900px)只顯示收合鈕;@media (max-width:900px) 那組規則會反過來
2489
+ (見下方)。這兩顆是純 gr.HTML() 刻的按鈕,行內 SVG 用 _icon(),
2490
+ currentColor 正常運作,不受上面 img.button-icon 的限制。 */
2491
+ .nav-collapse-row { display: flex; justify-content: flex-end; margin-bottom: .5rem; }
2492
+ .nav-drawer-close-row { display: none; justify-content: flex-end; margin-bottom: .5rem; }
2493
+ .nav-collapse-toggle, .nav-drawer-close, .mobile-nav-toggle {
2494
+ all: unset; box-sizing: border-box; cursor: pointer;
2495
+ display: inline-flex; align-items: center; justify-content: center;
2496
+ width: 32px; height: 32px; border-radius: 8px;
2497
+ color: var(--c-text-muted);
2498
+ transition: background-color 150ms ease-out, color 150ms ease-out;
2499
+ }
2500
+ .nav-collapse-toggle:hover, .nav-drawer-close:hover, .mobile-nav-toggle:hover {
2501
+ background: rgba(var(--c-accent-rgb),.08); color: var(--c-text);
2502
+ }
2503
+ .nav-collapse-toggle:focus-visible, .nav-drawer-close:focus-visible, .mobile-nav-toggle:focus-visible {
2504
+ outline: 2px solid var(--c-accent); outline-offset: 2px;
2505
+ }
2506
+ .nav-collapse-toggle svg { transition: transform 200ms ease-out; }
2507
+ .nav-col.collapsed .nav-collapse-toggle svg { transform: rotate(180deg); }
2508
+ @media (prefers-reduced-motion: reduce) {
2509
+ .nav-collapse-toggle svg { transition: none; }
2510
+ .nav-col { transition: none !important; }
2511
+ }
2512
+
2513
+ /* 桌面收合成窄 icon bar:文字用 font-size:0 隱藏(不是 display:none,
2514
+ 避免影響按鈕的 accessible name/可點擊區域),圖示尺寸另外鎖死不受
2515
+ font-size 影響。統計比較分析的 4 個子按鈕是純文字、沒有各自圖示,
2516
+ 收合時整組 Accordion 直接隱藏(窄版 icon bar 塞不下二層選單);重置
2517
+ 按鈕若在收合狀態被點擊,JS 會先自動展開 nav-col 再讓確認彈窗顯示
2518
+ (見 _APP_HEAD_SCRIPT setNavCollapsed 呼叫處),避免確認彈窗擠在
2519
+ 60px 窄欄裡。這整組「收合外觀」規則只在桌面寬度生效(min-width:901px)
2520
+ ——手機沒有「窄 icon bar」這個中間態,nav-col 在手機是 off-canvas
2521
+ 抽屜,要嘛整個滑出來、要嘛整個收在畫面外,即使 localStorage 殘留
2522
+ collapsed=1 也不該套用這組窄化樣式(見下方 900px 媒體查詢改用
2523
+ position:fixed 抽屜,兩者規則互斥,不需要互相 revert)。 */
2524
+ .nav-col { transition: flex-basis 200ms ease-out, max-width 200ms ease-out; }
2525
+ @media (min-width: 901px) {
2526
+ /* gr.Column(min_width=200) 會在元件上寫死 inline style="min-width:200px",
2527
+ 這個 inline min-width 會蓋掉外部 stylesheet 的 max-width(flexbox
2528
+ 規範:min-width 與 max-width 衝突時 min-width 優先),光設
2529
+ max-width:60px 收合欄寬不會有效果,撐在 200px 不動——必須額外用
2530
+ !important 覆寫 min-width 本身(stylesheet 的 !important 蓋得過
2531
+ 沒加 !important 的 inline style,這點跟蓋不過 inline !important
2532
+ 不一樣)。 */
2533
+ .nav-col.collapsed { flex: 0 0 60px !important; max-width: 60px !important; min-width: 60px !important; }
2534
+ .nav-col.collapsed .nav-collapse-row { justify-content: center; }
2535
+ .nav-col.collapsed .scenario-buttons,
2536
+ .nav-col.collapsed .result-nav-accordion,
2537
+ .nav-col.collapsed .reset-confirm { display: none !important; }
2538
+ .nav-col.collapsed .reset-btn,
2539
+ .nav-col.collapsed .result-nav-btn {
2540
+ justify-content: center !important;
2541
+ padding: .6rem !important;
2542
+ font-size: 0 !important;
2543
+ }
2544
+ .nav-col.collapsed .button-icon { width: 20px !important; height: 20px !important; }
2545
+ .nav-col.collapsed .result-nav { padding-top: .8rem !important; }
2546
+ .nav-col.collapsed #nav_stat_accordion { display: none !important; }
2547
+ }
2548
+
2549
+ /* 統計比較分析 Accordion 沒有 icon= 參數,改用 CSS ::before 掛圖示
2550
+ (data-URI,同一份 chart-bar 線稿,��值同步 assets/icons/*.svg 的
2551
+ #475569/深色模式同樣靠 filter 反相,寫法比照上面 img.button-icon)。 */
2552
+ #nav_stat_accordion .label-wrap {
2553
+ display: flex !important; align-items: center !important; gap: .5rem !important;
2554
+ }
2555
+ #nav_stat_accordion .label-wrap::before {
2556
+ content: "";
2557
+ width: 18px; height: 18px; flex: none;
2558
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23475569' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='4' y1='20' x2='20' y2='20'/%3E%3Crect x='5' y='12' width='3' height='8'/%3E%3Crect x='10.5' y='7' width='3' height='13'/%3E%3Crect x='16' y='14' width='3' height='6'/%3E%3C/svg%3E");
2559
+ background-size: contain; background-repeat: no-repeat;
2560
+ opacity: .82;
2561
+ }
2562
+ .dark #nav_stat_accordion .label-wrap::before {
2563
+ filter: grayscale(1) invert(1) brightness(1.55) contrast(.85);
2564
+ }
2565
+
2566
+ /* 手機漢堡開關(mobile_nav_toggle)與抽屜遮罩(nav_backdrop):兩者都是
2567
+ 放在 workspace_row 外層、一開始就 visible=True 的獨立元件(理由見
2568
+ create_app() 該處註解),CSS 預設整個不顯示,只在手機寬度且 nav-col
2569
+ 已掛載(情境已選定)時,由 _APP_HEAD_SCRIPT 的 _syncMobileHamburger()
2570
+ 切換 display,避免首頁或桌面寬度下平白多一顆浮動按鈕。 */
2571
+ .mobile-nav-toggle {
2572
+ display: none;
2573
+ position: fixed; top: 14px; left: 14px; z-index: 45;
2574
+ background: var(--c-surface) !important;
2575
+ border: 1px solid var(--c-border) !important;
2576
+ box-shadow: 0 4px 16px rgba(15,23,42,.18);
2577
+ }
2578
+ .nav-backdrop {
2579
+ display: none;
2580
+ position: fixed; inset: 0; z-index: 30;
2581
+ background: rgba(2,6,23,.5);
2582
+ opacity: 0; pointer-events: none;
2583
+ transition: opacity 200ms ease-out;
2584
+ }
2585
+ .nav-backdrop.visible { opacity: 1; pointer-events: auto; }
2586
+ @media (prefers-reduced-motion: reduce) { .nav-backdrop { transition: none; } }
2587
+
2588
+ @media (max-width: 900px) {
2589
+ /* 手機寬度:nav-col 不再跟著 chat-col/canvas-col 上下堆疊(那條規則
2590
+ 見「17. RESPONSIVE」,已把 nav-col 排除),改成 off-canvas 抽屜,
2591
+ 預設收在畫面左側外面,漢堡鈕/背景遮罩點擊才滑入。 */
2592
+ .nav-col {
2593
+ position: fixed !important;
2594
+ top: 0; left: 0; bottom: 0;
2595
+ width: 78vw !important; max-width: 300px !important;
2596
+ flex: none !important; height: 100vh !important;
2597
+ overflow-y: auto !important;
2598
+ background: var(--c-surface) !important;
2599
+ box-shadow: 4px 0 24px rgba(15,23,42,.28) !important;
2600
+ z-index: 40;
2601
+ transform: translateX(-100%);
2602
+ transition: transform 220ms ease-out;
2603
+ padding: 1.1rem 1rem !important;
2604
+ }
2605
+ .nav-col.mobile-open { transform: translateX(0) !important; }
2606
+ /* 桌面收合是縮寬窄 icon bar 那組規則整段包在 @media(min-width:901px)
2607
+ 裡(見「5e」上方),手機寬度完全不會命中,這裡不需要另外
2608
+ revert——即使 localStorage 殘留 collapsed=1,.nav-col.collapsed
2609
+ 在手機寬度就是普通展開的抽屜外觀,不會被窄化。 */
2610
+ .nav-collapse-row { display: none !important; }
2611
+ .nav-drawer-close-row { display: flex !important; }
2612
+ .mobile-nav-toggle { display: inline-flex; }
2613
+ .nav-backdrop { display: block; }
2614
+ }
2615
+
2616
  /* ── 6. UPLOAD AREA(醒目卡片)─────────────────────────────────── */
2617
  /* 注意:max-width + margin auto 是舊版單欄置中版面留下的樣式,現在
2618
  upload-zone 是右側 action-col 裡的元件,要跟同一欄的 scenario-buttons
 
3034
 
3035
  /* ── 17. RESPONSIVE ────────────────────────────────────────────── */
3036
  /* 三欄式工作區(nav-col/chat-col/canvas-col)僅限桌面寬度;手機或窄視窗
3037
+ (≤900px)chat-col/canvas-col 改回上下堆疊,不再各自獨立捲動(避免
3038
+ 小螢幕出現多層巢狀捲軸),改用頁面本身捲動。nav-col 這裡刻意不列
3039
+ 進來:Phase 8-E 把它改成手機 off-canvas 抽屜(position:fixed,見
3040
+ 「5e. NAV-COL 收合/手機漢堡抽屜」區塊),跟著上下堆疊會跟抽屜的
3041
+ fixed 定位互相打架。 */
3042
  @media (max-width: 900px) {
3043
  .main-layout-row { flex-direction: column !important; }
 
3044
  .main-layout-row .chat-col,
3045
  .main-layout-row .canvas-col {
3046
  width: 100% !important;
 
3207
  }
3208
  document.addEventListener('click', function (e) {
3209
  if (e.target.closest('#nav_top_btn')) {
3210
+ closeMobileDrawerIfOpen();
3211
  var scrollBox = document.querySelector('.canvas-scroll');
3212
  if (scrollBox) scrollBox.scrollTo({ top: 0, behavior: 'smooth' });
3213
  return;
3214
  }
3215
+ if (e.target.closest('#nav_idx_btn')) { closeMobileDrawerIfOpen(); scrollToId('canvas_idx'); return; }
3216
+ if (e.target.closest('#nav_report_btn')) { closeMobileDrawerIfOpen(); scrollToId('canvas_report'); return; }
3217
+ if (e.target.closest('#nav_stat_accordion .label-wrap')) { closeMobileDrawerIfOpen(); scrollToId('canvas_stat'); return; }
3218
  for (var i = 0; i < 4; i++) {
3219
  if (e.target.closest('#nav_stat_sub_' + i)) {
3220
+ closeMobileDrawerIfOpen();
3221
  scrollToId('canvas_stat');
3222
  /* 注意:這版 Gradio 的巢狀分頁 DOM 結構是
3223
  .tabs > .tab-wrapper > .tab-container(兩個,
 
3239
  }
3240
  bindResultNav();
3241
 
3242
+ /* Phase 8-E:桌面收合 icon bar/手機漢堡抽屜。跟 bindResultNav()
3243
+ 同一套「事件代理綁在 document」手法,理由相同——nav-col 建構時
3244
+ 在 visible=False 的 workspace_row 裡,情境選定前不存在,不能用
3245
+ setTimeout 抓固定時間點的元素。 */
3246
+ function setNavCollapsed(collapsed) {
3247
+ var navCol = document.querySelector('.nav-col');
3248
+ if (!navCol) return;
3249
+ navCol.classList.toggle('collapsed', collapsed);
3250
+ var toggleBtn = document.getElementById('nav_collapse_toggle');
3251
+ if (toggleBtn) {
3252
+ toggleBtn.setAttribute('aria-label', collapsed ? '展開導覽列' : '收合導覽列');
3253
+ toggleBtn.setAttribute('aria-expanded', String(!collapsed));
3254
+ }
3255
+ /* 收合後按鈕文字用 font-size:0 隱藏(見 CSS),accessible name
3256
+ 會跟著消失,改用 aria-label 補回原始文字,展開時移除即可
3257
+ 讓瀏覽器繼續用按鈕原生文字內容當 accessible name。 */
3258
+ ['reset_btn', 'nav_top_btn', 'nav_idx_btn', 'nav_report_btn'].forEach(function (id) {
3259
+ /* elem_id 在 gr.Button 上這版 Gradio 是直接寫在 <button>
3260
+ 標籤本身,不是外層 wrapper div(跟 result_nav_col 那組
3261
+ 純 gr.HTML() 按鈕、或 nav_top_btn 用 elem_id 做 CSS
3262
+ 選擇器點擊代理時假設的「wrapper 帶 id」不是同一種
3263
+ 結構)——用 devtools 量過 outerHTML 才發現,兩者都要
3264
+ 兼容。 */
3265
+ var el = document.getElementById(id);
3266
+ var btn = el && (el.tagName === 'BUTTON' ? el : el.querySelector('button'));
3267
+ if (!btn) return;
3268
+ if (collapsed) {
3269
+ if (!btn.dataset.navLabel) btn.dataset.navLabel = btn.textContent.trim();
3270
+ btn.setAttribute('aria-label', btn.dataset.navLabel);
3271
+ } else {
3272
+ btn.removeAttribute('aria-label');
3273
+ }
3274
+ });
3275
+ try { localStorage.setItem('spectra_nav_collapsed', collapsed ? '1' : '0'); } catch (err) {}
3276
+ }
3277
+ function setMobileDrawer(open) {
3278
+ var navCol = document.querySelector('.nav-col');
3279
+ var backdrop = document.getElementById('nav_backdrop');
3280
+ var toggle = document.getElementById('mobile_nav_toggle');
3281
+ if (navCol) navCol.classList.toggle('mobile-open', open);
3282
+ if (backdrop) backdrop.classList.toggle('visible', open);
3283
+ if (toggle) toggle.setAttribute('aria-expanded', String(open));
3284
+ }
3285
+ function closeMobileDrawerIfOpen() {
3286
+ var navCol = document.querySelector('.nav-col');
3287
+ if (navCol && navCol.classList.contains('mobile-open')) setMobileDrawer(false);
3288
+ }
3289
+ function bindNavControls() {
3290
+ if (window._navControlsBound) return;
3291
+ window._navControlsBound = true;
3292
+ document.addEventListener('click', function (e) {
3293
+ if (e.target.closest('#nav_collapse_toggle')) {
3294
+ var navCol = document.querySelector('.nav-col');
3295
+ setNavCollapsed(!(navCol && navCol.classList.contains('collapsed')));
3296
+ return;
3297
+ }
3298
+ if (e.target.closest('#mobile_nav_toggle')) {
3299
+ var navColEl = document.querySelector('.nav-col');
3300
+ setMobileDrawer(!(navColEl && navColEl.classList.contains('mobile-open')));
3301
+ return;
3302
+ }
3303
+ if (e.target.closest('#nav_drawer_close') || e.target.closest('#nav_backdrop')) {
3304
+ setMobileDrawer(false);
3305
+ return;
3306
+ }
3307
+ if (e.target.closest('#reset_btn')) {
3308
+ /* 收合狀態下點重置,先展開 nav-col 再讓 Gradio 原生
3309
+ .click() 觸發確認彈窗,避免彈窗擠在 60px 窄欄裡;
3310
+ 這裡不 preventDefault/stopPropagation,事件照常
3311
+ 冒泡給 Gradio。 */
3312
+ var navCol2 = document.querySelector('.nav-col');
3313
+ if (navCol2 && navCol2.classList.contains('collapsed')) setNavCollapsed(false);
3314
+ closeMobileDrawerIfOpen();
3315
+ }
3316
+ });
3317
+ document.addEventListener('keydown', function (e) {
3318
+ if (e.key === 'Escape') closeMobileDrawerIfOpen();
3319
+ });
3320
+ }
3321
+ bindNavControls();
3322
+
3323
  /* 持續監聽 DOM 變化(每次訊息更新後重設) */
3324
  var mo = new MutationObserver(function(mutations) {
3325
  var needsFix = mutations.some(function(m) {
 
3365
  (見陷阱:內部捲動 + 頁面捲動疊在一起看起來像兩條捲軸),所以不再
3366
  需要 el.scrollTop 這種內部捲到底部的邏輯,避免又跟使用者手動捲動
3367
  搶主導權(同樣的教訓見對話視窗捲不到底那次的修正)。 */
3368
+ /* Phase 8-E:mobile_nav_toggle(手機漢堡鈕)是放在 workspace_row
3369
+ 外層、頁面一開始就存在的元件,但它只有在 nav-col 真正掛載
3370
+ (情境已選定)時才該顯示;同理,桌面收合狀態要在 nav-col 剛
3371
+ 掛載那一刻套用 localStorage 記住的值,早一步做(例如 DOMContentLoaded
3372
+ 當下)nav-col 根本還不存在,套不上。都改成搭這裡既有的
3373
+ setInterval 一起輪詢,不額外開新計時器。 */
3374
+ function _syncMobileHamburger() {
3375
+ var hamburger = document.getElementById('mobile_nav_toggle');
3376
+ if (!hamburger) return;
3377
+ hamburger.style.display = document.querySelector('.nav-col') ? '' : 'none';
3378
+ }
3379
+ function _initNavCollapseIfNeeded() {
3380
+ var navCol = document.querySelector('.nav-col');
3381
+ if (!navCol || navCol.dataset.navInit) return;
3382
+ navCol.dataset.navInit = '1';
3383
+ var saved;
3384
+ try { saved = localStorage.getItem('spectra_nav_collapsed'); } catch (err) { saved = null; }
3385
+ if (saved === '1') setNavCollapsed(true);
3386
+ }
3387
  setInterval(function () {
3388
  _watchZone('upload_zone');
3389
  _watchZone('metadata_zone');
3390
+ _syncMobileHamburger();
3391
+ _initNavCollapseIfNeeded();
3392
  }, 400);
3393
  }
3394
  if (document.readyState === 'loading') {
 
3479
  sc_btn_4 = gr.Button("4 不同時期重譯本的歷時比較", variant="secondary",
3480
  elem_id="sc_btn_4", elem_classes=["sc-bridge-btn"])
3481
 
3482
+ # Phase 8-E:手機漢堡開關+抽屜背景遮罩。跟 sc_btn_1..4 同一類坑:
3483
+ # 建構時 visible=False 的容器整段不掛載進 DOM,而這兩個元件要在
3484
+ # nav-col(在 workspace_row 裡,情境選定前不存在)尚未掛載前就能
3485
+ # 被點到,所以放在最外層、一開始就 visible=True 的位置;CSS 預設
3486
+ # display:none,只在手機寬度且 workspace 啟用時由 JS 顯示(見
3487
+ # _APP_HEAD_SCRIPT 的 _syncMobileHamburger()),桌面寬度/首頁
3488
+ # 狀態下完全不佔版面也不可點。
3489
+ mobile_nav_toggle_html = gr.HTML(
3490
+ f'<button type="button" id="mobile_nav_toggle" class="mobile-nav-toggle" '
3491
+ f'aria-label="開啟導覽選單" aria-expanded="false">{_icon("bars-3", 22)}</button>'
3492
+ )
3493
+ nav_backdrop_html = gr.HTML('<div id="nav_backdrop" class="nav-backdrop"></div>')
3494
+
3495
  # Phase 8-B:三欄式工作區——nav-col(窄,導覽/重置)+chat-col
3496
  # (對話視窗+輸入框,獨立捲動)+canvas-col(步驟列+上傳/表單/
3497
  # 執行紀錄,獨立捲動)。手機寬度見 CSS 的 media query 改上下堆疊。
3498
  with gr.Row(elem_classes=["main-layout-row"], visible=False) as workspace_row:
3499
  with gr.Column(scale=2, min_width=200, elem_classes=["nav-col"]) as nav_col:
3500
+ # Phase 8-E:收合/抽屜控制列。兩顆按鈕互斥顯示(CSS
3501
+ # media query 切換,見 5e 區塊):桌面寬度顯示收合鈕
3502
+ # (nav_collapse_toggle,縮成 icon bar);手機寬度顯示
3503
+ # 關閉鈕(nav_drawer_close,收起抽屜)。純畫面操作,
3504
+ # 不���動任何後端狀態,沿用 result_nav_col 同一套「事件
3505
+ # 代理綁在 document」手法(見 _APP_HEAD_SCRIPT)。
3506
+ gr.HTML(
3507
+ '<div class="nav-collapse-row">'
3508
+ f'<button type="button" id="nav_collapse_toggle" class="nav-collapse-toggle" '
3509
+ f'aria-label="收合導覽列" aria-expanded="true">{_icon("chevron-double-left", 18)}</button>'
3510
+ '</div>'
3511
+ '<div class="nav-drawer-close-row">'
3512
+ f'<button type="button" id="nav_drawer_close" class="nav-drawer-close" '
3513
+ f'aria-label="關閉導覽選單">{_icon("x", 18)}</button>'
3514
+ '</div>'
3515
+ )
3516
+
3517
  # 研究情境是封閉式的固定四選一,改用可點擊按鈕取代打字輸入
3518
  # 數字再解析,選定後即隱藏,對話視窗回歸純輸出角色
3519
  # Phase 8:情境選擇按鈕已搬到 homepage_col(見上方,理由是
 
3527
  # 「開始新分析」與其確認彈窗:換情境/重新開始都走這一套共用
3528
  # 機制。情境是封閉式按鈕選擇,不透過對話框打字,所以「換
3529
  # 情境」也統一走這顆按鈕,不恢復文字解析路徑。
3530
+ reset_btn = gr.Button(
3531
+ "開始新分析", icon=str(BASE_DIR / "assets" / "icons" / "plus.svg"),
3532
+ elem_id="reset_btn", elem_classes=["reset-btn"], variant="secondary",
3533
+ )
3534
  with gr.Column(visible=False, elem_classes=["reset-confirm"]) as reset_confirm_row:
3535
  gr.Markdown("確定要開始新分析嗎?目前進度將會遺失。")
3536
  with gr.Row(elem_classes=["upload-actions"]):
 
3546
  # 下一輪視覺打磨(Heroicons 圖示)一起補,這次先求導覽功能
3547
  # 正確可用。
3548
  with gr.Column(visible=False, elem_id="result_nav_col", elem_classes=["result-nav"]) as result_nav_col:
3549
+ nav_top_btn = gr.Button(
3550
+ "回到上傳/表單", icon=str(BASE_DIR / "assets" / "icons" / "home.svg"),
3551
+ elem_id="nav_top_btn", elem_classes=["result-nav-btn"], variant="secondary",
3552
+ )
3553
+ nav_idx_btn = gr.Button(
3554
+ "語言指標分析", icon=str(BASE_DIR / "assets" / "icons" / "trending-up.svg"),
3555
+ elem_id="nav_idx_btn", elem_classes=["result-nav-btn"], variant="secondary",
3556
+ )
3557
  with gr.Accordion("統計比較分析", open=False, elem_id="nav_stat_accordion",
3558
  elem_classes=["result-nav-accordion"]):
3559
  nav_stat_sub_0 = gr.Button("敘述統計", elem_id="nav_stat_sub_0",
 
3564
  elem_classes=["result-nav-subbtn"], variant="secondary")
3565
  nav_stat_sub_3 = gr.Button("非監督式學習", elem_id="nav_stat_sub_3",
3566
  elem_classes=["result-nav-subbtn"], variant="secondary")
3567
+ nav_report_btn = gr.Button(
3568
+ "LLM輔助分析", icon=str(BASE_DIR / "assets" / "icons" / "document.svg"),
3569
+ elem_id="nav_report_btn", elem_classes=["result-nav-btn"], variant="secondary",
3570
+ )
3571
 
3572
  with gr.Column(scale=4, min_width=300, elem_classes=["chat-col"]):
3573
  chatbot = gr.Chatbot(
assets/icons/document.svg ADDED
assets/icons/home.svg ADDED
assets/icons/plus.svg ADDED
assets/icons/trending-up.svg ADDED