t193 三修:每庫一頁+狀態時間軸+預設淺色(leo 六點回饋)

## ① logo 尺寸不對導致白邊
真兇:我寫死 height:22px **沒給 width:auto** ⇒ 容器一壓就變形、露出底板邊緣。
修:照 portal 側欄同一條 clamp(22px,5vw,28px) + width:auto + max-width:100%,
    並依 CIS「clear space = one chevron height」在四邊留白。
實測:logo 底板與品牌區落差 **0**(原本是明顯方塊)。

## ② 深色跟網頁不同/「它有淺色佈景?」
是——**portal 預設淺色**,深色由使用者切換並存 localStorage。
我卻硬跟系統走 ⇒ 兩邊當然不同。
修:改成與 portal 同一套(預設淺色+切換鈕+localStorage),
    按鈕規格也逐字對齊 portal 的 .btn/.btn3(padding/font-size/radius/border)。
實測淺色:品牌區(253,252,251)=Paper、側欄(242,241,237)=Canvas,與 CIS 一致。

## ③ 「開啟知識庫網頁」開到錯的庫
真兇:它在首頁、寫死取 accounts[0] ⇒ 有兩個庫必然開錯。
修:移到**各庫頁的上方**,開的就是那個庫。

## ④⑤ 30 個資料夾放不下/「加入資料夾」會加到哪個帳號?
修:**每個知識庫一個獨立分頁**(側邊欄動態列出,附資料夾數)。
    「加入資料夾」在庫頁裡,**作用對象就是那個庫**,不會加錯。

## ⑥ 首頁要顯示 status(看守/發現變化/萃取/上傳)
修:首頁改成**狀態時間軸**四步,進行中那步的圓點會呼吸。
    誠實邊界:collector 目前回報「整輪」而非逐檔階段,
    所以同步中時後三步一起標進行中,**不假裝有更細的進度**。

兩支機械閘全過(check-cis.sh 14 項/check-render.sh 深淺色皆驗)。
style.css 仍是 0 個 hex(顏色全走 var)。
This commit is contained in:
2026-08-04 23:35:49 +08:00
parent 3d13e5a54e
commit 751ee74f19
4 changed files with 224 additions and 100 deletions
+45
View File
@@ -138,6 +138,50 @@ type UIState struct {
Engine string `json:"engine"` // "workers-ai" | "gemma"
GeminiKey string `json:"geminiKey"` // 只回遮罩,不回真值
ExtractedOK int `json:"extractedOK"` // 首頁「已整理幾份」
Steps []Step `json:"steps"` // 首頁狀態時間軸(leo #6
}
// Step 是首頁狀態時間軸的一格。
// 🔴 leo 08-04:「首頁要顯示的應該是 status,如果**看守、發現變化、萃取、上傳…
// 不同 status 在哪裡顯示**?」
// ⇒ 把一輪同步拆成四步,讓使用者看得到「現在走到哪」,而不是只有一句「看守中」。
type Step struct {
Title string `json:"title"`
Meta string `json:"meta,omitempty"`
State string `json:"state"` // "done"(已完成)|"now"(進行中)|""(還沒輪到)
}
// buildSteps 依 status.json 與訊號檔推出四步的狀態。
// 誠實邊界:collector 目前回報的是「整輪」而非逐檔階段,所以同步中時
// 「發現變化→萃取→上傳」一起標進行中;不假裝有更細的進度。
func buildSteps(s syncStatus, syncing bool) []Step {
watch := Step{Title: "看守資料夾", Meta: "有變動就自動開始", State: "done"}
if syncing {
return []Step{
watch,
{Title: "發現變化", State: "done"},
{Title: "用 AI 整理成知識卡", Meta: "進行中", State: "now"},
{Title: "上傳到你的知識庫", State: ""},
}
}
done := ""
if s.ExtractedOK > 0 || s.LastSync != "" {
done = "done"
}
meta := ""
if s.ExtractedOK > 0 {
meta = fmt.Sprintf("上一輪 %d 份", s.ExtractedOK)
}
up := Step{Title: "上傳到你的知識庫", Meta: meta, State: done}
if s.ExtractFailed > 0 {
up.Meta = fmt.Sprintf("%s · ⚠ %d 份失敗", meta, s.ExtractFailed)
}
return []Step{
watch,
{Title: "發現變化", Meta: "等待中", State: done},
{Title: "用 AI 整理成知識卡", State: done},
up,
}
}
// GetState 是前端每秒拉一次的單一入口。
@@ -164,6 +208,7 @@ func (a *App) GetState() UIState {
sync := loadSyncStatus()
st.ExtractedOK = sync.ExtractedOK
st.Syncing, st.StatusBig, st.StatusSub = describeStatus(sync)
st.Steps = buildSteps(sync, st.Syncing)
return st
}
+3 -6
View File
@@ -19,12 +19,8 @@
<img class="wm-paper" src="./src/assets/arcrun-lockup-h-paper-on-ink.png" alt="Arcrun"/>
</div>
<nav>
<div class="nav on" data-page="home"> <span class="ic"></span>首頁</div>
<div class="nav" data-page="folders"> <span class="ic"></span>同步資料夾</div>
<div class="nav" data-page="ai"> <span class="ic"></span>AI 設定</div>
<div class="nav" data-page="update"> <span class="ic"></span>版本與更新</div>
</nav>
<!-- 側邊欄由 JS 依帳號數動態產生(每個知識庫一頁,leo 08-04 指定) -->
<nav id="nav"></nav>
<div class="side-foot">
<div class="ver" id="ver"></div>
@@ -38,6 +34,7 @@
<div class="big" id="statusBig">啟動中…</div>
<div class="sub" id="statusSub"></div>
</div>
<button id="themeBtn" title="切換淺色/深色"></button>
<button class="primary" id="btnSync">立刻同步</button>
</header>
<main id="page"></main>
+97 -77
View File
@@ -1,8 +1,11 @@
// Arcrun 桌面 App — 前端(t193
//
// 版面leo 08-04 指定附 Google Drive 截圖):
// 「可以完全模仿 gdrive,**左方側邊欄,在右方替換頁面**」
// ⇒ 設定不再開 popup,全部是右側的頁;只有「確認刪除」才用覆蓋層。
// 版面leo 08-04 指定附 Google Drive 截圖):左側邊欄、右側換頁。
// 🔴 08-04 二輪回饋(本次處理):
// ③「開啟知識庫網頁」不該在首頁(會開到錯的庫)⇒ 移到**各庫頁的上方**
// ④ 一個庫 30 個資料夾放不下 ⇒ **每個知識庫一個獨立分頁**
// ⑤「加入資料夾」會加到哪個帳號?⇒ 在庫頁裡加,**作用對象就是那個庫**,不會加錯
// ⑥ 首頁要顯示 status:看守/發現變化/萃取/上傳 ⇒ **狀態時間軸**
import './arcrun-cis.css'; // 共用底層(色票/字體/紋理)——唯一真相源在 arcrun-cis/css/
import './style.css'; // 本 App 的版面
@@ -11,73 +14,96 @@ const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
// 跟隨系統深淺色(CIS 兩套色票都在 arcrun-cis.css 定義好了)
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const applyTheme = () =>
document.documentElement.setAttribute('data-theme', mq.matches ? 'dark' : 'light');
mq.addEventListener('change', applyTheme);
applyTheme();
// 主題:**與 portal 同一套規則**——預設淺色,使用者切換後存 localStorage
// (leo 問「它有淺色佈景?」⇒ 是,portal 預設就是淺色)
const THEME_KEY = 'arcrun_app_theme';
function applyTheme(t) {
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem(THEME_KEY, t); } catch (e) {}
const b = $('themeBtn'); if (b) b.textContent = t === 'dark' ? '☀' : '☾';
}
applyTheme((() => { try { return localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; } })());
$('themeBtn').onclick = () =>
applyTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
let state = null;
let page = 'home';
let updateInfo = null; // { current, latest, available, notes, staged }
let page = 'home'; // 'home' | 'ai' | 'update' | 'lib:<idx>'
let updateInfo = null;
// ── 覆蓋層(只給必須打斷的確認)──
function openSheet(html, wire) {
$('sheet').innerHTML = html; $('overlay').classList.add('on'); if (wire) wire();
}
function openSheet(html, wire) { $('sheet').innerHTML = html; $('overlay').classList.add('on'); if (wire) wire(); }
function closeSheet() { $('overlay').classList.remove('on'); }
$('overlay').addEventListener('click', (e) => { if (e.target.id === 'overlay') closeSheet(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheet(); });
// ── 側邊欄換頁 ──
document.querySelectorAll('#side .nav').forEach((el) => {
el.onclick = () => {
page = el.dataset.page;
document.querySelectorAll('#side .nav').forEach((n) => n.classList.toggle('on', n === el));
renderPage();
};
});
// ── 側邊欄:每個知識庫一項(leo:「每個帳號有獨立的一個頁面」)──
function renderNav() {
const accs = (state && state.accounts) || [];
$('nav').innerHTML = `
<div class="nav" data-p="home"><span class="ic">◫</span><span class="nm">首頁</span></div>
${accs.length ? `<div class="sec">知識庫</div>` : ''}
${accs.map((a, i) => `
<div class="nav" data-p="lib:${i}">
<span class="ic">▤</span><span class="nm">${esc(a.name)}</span>
<span class="cnt">${(a.folders || []).length}</span>
</div>`).join('')}
<div class="sec">設定</div>
<div class="nav" data-p="ai"><span class="ic">✧</span><span class="nm">AI 設定</span></div>
<div class="nav" data-p="update"><span class="ic">↧</span><span class="nm">版本與更新</span></div>`;
$('nav').querySelectorAll('.nav').forEach((el) => {
el.classList.toggle('on', el.dataset.p === page);
el.onclick = () => { page = el.dataset.p; renderNav(); renderPage(); };
});
}
// ── 各頁 ──
// ── 首頁:狀態時間軸(leo:「看守、發現變化、萃取、上傳… 不同 status 在哪裡顯示?」)──
function pageHome(s) {
const folders = (s.accounts || []).reduce((n, a) => n + (a.folders || []).length, 0);
if (!s.accounts || !s.accounts.length) return onboarding();
const st = s.steps || [];
return `
<div class="card">
<h3>目前狀況</h3>
<div class="kv" style="margin-top:10px">
<div><div class="big-num">${s.accounts.length}</div><div class="k">個知識庫</div></div>
<div><div class="big-num">${folders}</div><div class="k">個資料夾在看守</div></div>
<div><div class="big-num">${s.extractedOK || 0}</div><div class="k">份已整理</div></div>
<h3>現在的狀態</h3>
<div class="steps">
${st.map((x) => `
<div class="step ${esc(x.state)}">
<span class="dot"></span>
<span class="t">${esc(x.title)}</span>
<span class="m">${esc(x.meta || '')}</span>
</div>`).join('')}
</div>
</div>
<div class="card">
<h3>快速動作</h3>
<div class="acts">
<button id="qAdd">加入資料夾</button>
<button id="qPortal">開啟知識庫網頁</button>
<button id="qDocs">使用說明</button>
<h3>總計</h3>
<div class="kv" style="margin-top:10px">
<div><div class="big-num">${s.accounts.length}</div><div class="k">個知識庫</div></div>
<div><div class="big-num">${s.accounts.reduce((n,a)=>n+(a.folders||[]).length,0)}</div><div class="k">個資料夾在看守</div></div>
<div><div class="big-num">${s.extractedOK || 0}</div><div class="k">份已整理</div></div>
</div>
</div>`;
}
function pageFolders(s) {
if (!s.accounts || !s.accounts.length) return onboarding();
return (s.accounts).map((a) => `
<div class="acct"><span class="name">${esc(a.name)}</span><span class="host">${esc(a.host)}</span></div>
// ── 各庫頁:動作全部作用在這個庫(不會加錯帳號)──
function pageLib(s, idx) {
const a = s.accounts[idx];
if (!a) return `<div class="empty"><div class="t">找不到這個知識庫</div></div>`;
const portal = 'https://' + a.host.replace('arcrun-cypher-executor.', 'arcrun-rag-ui.') + '/portal/';
return `
<div class="libhead">
<div class="g">
<div class="nm">${esc(a.name)}</div>
<div class="host">${esc(a.host)}</div>
</div>
<button data-portal="${esc(portal)}">開啟知識庫網頁</button>
<button class="primary" data-addto="${idx}">加入資料夾</button>
</div>
${(a.folders || []).map((f) => `
<div class="folder">
<span class="path" title="${esc(f.path)}">${esc(f.path)}</span>
<span class="tag">自動同步中</span>
<button class="ghost" data-rm="${esc(f.path)}" data-acc="${f.accIdx}">移除</button>
</div>`).join('')
|| `<div class="folder"><span class="path dim">還沒有資料夾</span></div>`}
`).join('') + `
<div class="acts">
<button class="primary" id="fAdd">加入資料夾</button>
<button id="fAcct">新增知識庫帳號</button>
</div>`;
|| `<div class="empty"><div class="t">這個知識庫還沒有資料夾</div>
<div class="d">按右上的「加入資料夾」,選一個要自動整理的資料夾。</div></div>`}`;
}
function pageAI(s) {
@@ -100,11 +126,9 @@ function pageAI(s) {
</div>`;
}
// leo:「檢查更新連到 docs 的檢查更新,不正確,**是直接在這裡進行檢查更新**,
// 應該是跳到一個頁面顯示**現在版本與最新版本**,然後**下載可以進行安裝**」
function pageUpdate(s) {
const u = updateInfo;
const latest = u ? (u.latest || '查詢中…') : '查詢中…';
const latest = u ? (u.latest || '查詢中…') : '按「檢查更新」查詢';
let action = `<button class="primary" id="uCheck">檢查更新</button>`;
let note = '';
if (u && u.staged) {
@@ -149,32 +173,30 @@ function onboarding() {
</div>`;
}
// ── 渲染 ──
function renderPage() {
if (!state) return;
const el = $('page');
el.innerHTML = page === 'folders' ? pageFolders(state)
: page === 'ai' ? pageAI(state)
: page === 'update' ? pageUpdate(state)
: pageHome(state);
let html;
if (page.startsWith('lib:')) html = pageLib(state, Number(page.slice(4)));
else if (page === 'ai') html = pageAI(state);
else if (page === 'update') html = pageUpdate(state);
else html = pageHome(state);
// 每個庫頁底下都給「新增知識庫帳號」入口
if (page === 'home' && state.accounts && state.accounts.length) {
html += `<div class="acts"><button id="hAcct">新增知識庫帳號</button></div>`;
}
$('page').innerHTML = html;
wire();
}
function wire() {
const on = (id, fn) => { const e = $(id); if (e) e.onclick = fn; };
on('qAdd', addFolder); on('fAdd', addFolder);
on('qDocs', () => go.OpenURL('https://rag.arcrun.dev/docs/'));
on('uDocs', () => go.OpenURL('https://rag.arcrun.dev/docs/use/update/'));
on('qPortal', () => {
const a = state.accounts && state.accounts[0];
if (a) go.OpenURL('https://' + a.host.replace('arcrun-cypher-executor.', 'arcrun-rag-ui.') + '/portal/');
});
on('fAcct', showConnect); on('obConnect', showConnect);
on('uDocs', () => go.OpenURL('https://rag.arcrun.dev/docs/'));
on('hAcct', showConnect); on('obConnect', showConnect);
on('obInstall', () => go.OpenURL('https://install.arcrun.dev/'));
on('aiSave', saveAI);
on('uCheck', checkUpdate);
on('uDownload', downloadUpdate);
on('uApply', applyUpdate);
on('uCheck', checkUpdate); on('uDownload', downloadUpdate); on('uApply', applyUpdate);
document.querySelectorAll('[data-portal]').forEach((b) => { b.onclick = () => go.OpenURL(b.dataset.portal); });
document.querySelectorAll('[data-addto]').forEach((b) => { b.onclick = () => addFolder(Number(b.dataset.addto)); });
document.querySelectorAll('[data-rm]').forEach((b) => {
b.onclick = () => confirmRemove(Number(b.dataset.acc), b.dataset.rm);
});
@@ -182,28 +204,27 @@ function wire() {
function render(s) {
const first = !state;
const navChanged = state && JSON.stringify((state.accounts||[]).map(a=>[a.name,(a.folders||[]).length]))
!== JSON.stringify((s.accounts||[]).map(a=>[a.name,(a.folders||[]).length]));
state = s;
$('ver').textContent = s.version ? s.version : '';
$('ver').textContent = s.version || '';
$('statusBig').textContent = s.statusBig;
$('statusBig').classList.toggle('syncing', !!s.syncing);
$('statusSub').textContent = s.statusSub;
// 只有狀態列每秒更新;頁面內容不重繪,避免打斷輸入
if (first) renderPage();
if (first || navChanged) { renderNav(); renderPage(); }
else if (page === 'home') renderPage(); // 首頁的狀態時間軸要跟著跳
}
async function tick() {
try { render(await go.GetState()); } catch (e) { /* 後端還沒起來 */ }
}
async function tick() { try { render(await go.GetState()); } catch (e) {} }
// ── 動作 ──
$('btnSync').onclick = async () => { await go.SyncNow(); tick(); };
async function addFolder() {
if (!state || !state.accounts.length) { showConnect(); return; }
async function addFolder(accIdx) {
const p = await go.PickFolder();
if (!p) return;
await go.AddFolder(0, p);
state = await go.GetState(); renderPage();
await go.AddFolder(accIdx, p);
state = await go.GetState(); renderNav(); renderPage();
}
function confirmRemove(accIdx, path) {
@@ -215,7 +236,7 @@ function confirmRemove(accIdx, path) {
$('c1').onclick = closeSheet;
$('c2').onclick = async () => {
await go.RemoveFolder(accIdx, path); closeSheet();
state = await go.GetState(); renderPage();
state = await go.GetState(); renderNav(); renderPage();
};
});
}
@@ -236,7 +257,7 @@ function showConnect() {
$('c2').onclick = async () => {
try {
await go.Connect($('u').value.trim(), $('e').value.trim(), $('p').value);
closeSheet(); state = await go.GetState(); renderPage();
closeSheet(); state = await go.GetState(); renderNav(); renderPage();
} catch (ex) { $('err').textContent = String(ex); $('err').style.display = 'block'; }
};
});
@@ -250,7 +271,6 @@ async function saveAI() {
} catch (ex) { $('aiErr').textContent = String(ex); $('aiErr').style.display = 'block'; }
}
// 更新:**在 App 裡完成**leo 指定),不是丟去 docs
async function checkUpdate() {
updateInfo = { latest: '查詢中…' }; renderPage();
try { updateInfo = await go.CheckUpdate(); } catch (ex) { updateInfo = { err: String(ex) }; }
+79 -17
View File
@@ -21,14 +21,21 @@ html, body { overflow: hidden; height: 100%; }
padding: 22px 0 14px;
border-right: 1px solid rgba(var(--ink-rgb), .1);
}
/* 🔴 lockup 底色(實測發現):`arcrun-lockup-h-paper-on-ink.png` **自帶不透明 Ink 底板**
(左上像素 RGBA=23,24,26,255),它的設計前提是「貼在純 Ink 表面上」。
我們的背景有紙張紋理 ⇒ 直接放會看到一個突兀的方塊(leo 截圖裡那個白/黑框)。
⇒ 深色模式下把品牌區做成純 Ink 表面,與該圖的底板無縫接合。
淺色模式用 `-ink.png`(左上像素 alpha=0,透明)⇒ 不需要處理。 */
#side .brand { padding: 14px 22px 18px; }
#side .brand img { height: 22px; display: block; }
:root[data-theme="dark"] #side .brand { background: var(--ink); }
/* 🔴 lockupCIS 硬要求,leo 兩次點名):
① 尺寸照 portal 側欄同一條:clamp(22px,5vw,28px) **width:auto**
(只寫 height 不寫 width,容器一壓就變形/露出底板邊緣=leo 看到的白邊)
② `-paper-on-ink.png` **自帶不透明 Ink 底板**(實測左上像素 RGBA=23,24,26,255),
設計前提是貼在**純 Ink 表面** ⇒ 深色模式把品牌區做成純 Ink,並留 clear space
CISclear space = one chevron height on all four sides)。
③ 淺色的 `-ink.png` 是透明的,不需要底板。 */
#side .brand { padding: 16px 22px 18px; }
#side .brand img {
height: clamp(22px, 5vw, 28px); width: auto; max-width: 100%; display: block;
}
:root[data-theme="dark"] #side .brand {
background: var(--ink);
padding: 16px 22px 18px; /* 上下左右都留白=clear space,底板不會貼邊 */
}
#side .brand .wm-paper { display: none; }
:root[data-theme="dark"] #side .brand .wm-ink { display: none; }
:root[data-theme="dark"] #side .brand .wm-paper { display: block; }
@@ -67,19 +74,32 @@ header {
main { flex: 1; overflow-y: auto; padding: 20px 28px 28px; }
/* ── 元件 ── */
/* ── 元件:規格**逐字對齊 portal**(leo:「網頁的按鈕比較精緻,跟現有的差異較大」)──
portal: .btn { padding:13px; font-size:16px; font-weight:600; border-radius:10px; border:none; background:var(--amber) }
.btn3 { padding:11px 16px; font-size:15px; border-radius:10px; border:1px solid rgba(ink,.25); background:none }
這裡只把 padding 依桌面視窗略收(網頁是手機優先、桌面不需要那麼大的點擊區)。 */
button {
border: 1px solid rgba(var(--ink-rgb), .16);
background: transparent; color: var(--ink);
padding: 8px 15px; border-radius: 8px; font-size: 14px; cursor: pointer;
transition: background .12s, border-color .12s;
padding: 10px 16px; font-size: 14.5px; border-radius: 10px;
border: 1px solid rgba(var(--ink-rgb), .25);
background: none; color: rgba(var(--ink-rgb), .7);
cursor: pointer; transition: background .12s, color .12s, border-color .12s;
}
button:hover { background: var(--well); color: var(--ink); }
button:disabled { opacity: .45; cursor: default; }
button.primary {
border: none; background: var(--amber); color: var(--knob); font-weight: 600;
}
button.primary:hover { filter: brightness(1.06); background: var(--amber); }
button.ghost {
border-color: transparent; background: none;
color: rgba(var(--ink-rgb), .45); padding: 7px 10px; font-size: 13px;
}
button:hover { background: var(--well); }
button.primary { background: var(--amber); border-color: var(--amber); color: var(--knob); font-weight: 600; }
button.primary:hover { filter: brightness(1.06); }
button.ghost { border-color: transparent; color: rgba(var(--ink-rgb), .45); padding: 6px 9px; font-size: 13px; }
button.ghost:hover { color: var(--err); background: transparent; }
/* 主題切換(portal 有,daemon 也要有——leo:「它有淺色佈景?」
⇒ 是,portal **預設淺色**,深色由使用者切換並存 localStorage。 */
#themeBtn { padding: 7px 11px; font-size: 13px; }
.card {
background: var(--well); border-radius: 12px;
padding: 18px 20px; margin-bottom: 14px;
@@ -138,3 +158,45 @@ input[type=text], input[type=password] {
.sheet h2 { margin: 0 0 6px; font-size: 16.5px; }
.sheet p { margin: 0 0 4px; font-size: 14px; color: rgba(var(--ink-rgb), .55); line-height: 1.7; }
.sheet .acts { justify-content: flex-end; }
/* ── 每個知識庫一個獨立分頁(leo 08-04)──
「同步資料夾那個頁面如果有 2 個庫,每個庫有 30 個同步資料夾,**放得下嗎**?
應該**一個庫有一個頁簽**或是其他方法可以切換。」
「『加入資料夾』按下後**會加在哪個帳號**?**有必要每個帳號有獨立的一個頁面**。」
⇒ 側邊欄直接列出每個知識庫;點進去就是那個庫的頁,動作全部作用在它身上,不會加錯。 */
#side .sec {
padding: 16px 26px 6px; font-size: 11.5px; letter-spacing: .08em;
color: rgba(var(--ink-rgb), .35);
}
#side .nav .cnt {
margin-left: auto; font-size: 11.5px; opacity: .7;
font-family: ui-monospace, Menlo, monospace;
}
#side .nav .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 每個庫的頁首:庫名+網址+「開啟知識庫網頁」(leo:不該放首頁,要在各庫上方) */
.libhead {
display: flex; align-items: center; gap: 14px;
padding-bottom: 14px; margin-bottom: 14px;
border-bottom: 1px solid rgba(var(--ink-rgb), .1);
}
.libhead .g { flex: 1; min-width: 0; }
.libhead .nm { font-size: 16px; font-weight: 600; }
.libhead .host {
font-size: 12px; color: rgba(var(--ink-rgb), .4);
font-family: ui-monospace, Menlo, monospace;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* 狀態時間軸(leo:「如果看守、發現變化、萃取、上傳… 不同 status 在哪裡顯示?」) */
.steps { display: flex; flex-direction: column; gap: 2px; margin-top: 12px; }
.step { display: flex; align-items: center; gap: 11px; padding: 7px 0; font-size: 14px; }
.step .dot {
width: 9px; height: 9px; border-radius: 50%; flex: none;
background: rgba(var(--ink-rgb), .18);
}
.step.done .dot { background: var(--ok); }
.step.now .dot { background: var(--amber); animation: pulse 1.1s ease-in-out infinite; }
.step.now { font-weight: 600; }
.step .t { flex: 1; }
.step .m { font-size: 12.5px; color: rgba(var(--ink-rgb), .45); }