From bf06ad029afb08ca8ad762f2017d12b683ac46b4 Mon Sep 17 00:00:00 2001 From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:40:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(gui):=20=E5=8A=A0=E8=BD=BD=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E6=94=B9=E4=B8=89=E7=82=B9=E5=BC=8F=20+=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=B3=BB=E7=BB=9F=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1) 加载动画:边框旋转 → 三点渐亮 旧实现是 border + rotate 的 spinner,用在 .loading(16px) / .loading-spinner(32px) / .live-spinner(15px) 三处。在消息气泡里刺眼, 且语义不对:旋转暗示"正在加载某个确定的东西",而 agent 思考没有进度。 参考 PiDeck(@deepseek-ai/dsh-web-frontend 与 mermaid bundle 里的 @keyframe 实现)改为「等距三点 + 透明度递减 + 轻微上浮」: haDots 三点错峰明暗(1/0.6/0.35/0.15) haDotFloat 单点上浮 + 缩放 haSweep 横向扫光 旧类名 .loading / .loading-spinner 保留为别名指向新动画(还有 6+ 处 调用点,逐处改 HTML 容易漏),并解除其 animation 以免残留旋转。 7 处调用点统一换成 x3 结构。 ## 2) 系统通知(此前完全不存在) 主进程无 Notification、preload 无接口、渲染层只有页面内 toast (用户不盯着窗口就看不到)。补齐一条完整链路: main.js notify:show / notify:supported 两个 IPC + 原生 Notification preload notify.{show,supported,onClicked} app.js notifyTurn / notifyTurnOnce / onNotifyClicked 设计取舍(都写进注释): · **一轮只通知一次**。SSE 的 tool_call / stage / agent_output 都可能 判定"一轮结束",用 turnSig 去重,否则连弹好几次。 · **不在流式过程中逐 token 通知**。一轮可能几十个 delta,那是噪音; 用户要的是"有结果了"。 · **窗口在前台且聚焦时不弹**。正看着界面时弹窗只会烦人。 静默启动(窗口 hidden)正好走"要弹"这条 —— 这也是静默后唯一 能让用户知道"agent 回了"的途径。 · **挂在 SSE 的 agent_output 收尾处**,不挂 endChatTurn(): 后者开头 `if (!state.chatLoading) return`,而旁观路径 (agent 响应 memo/QQ/email,GUI 只收 SSE)chatLoading 恒 false, 根本走不到那里。挂 SSE 对"自己发"与"旁观"两条路径都生效。 · **点击通知会 show() + 回传渲染进程**,切到 chat 视图并滚到该条。 点了却不知道该看哪句话等于没通知。 · 正文超 180 字截断,避免通知被撑爆。 ## 判据 新增 cmd/gui/loading-notify.test.mjs(进 npm test),24 条断言覆盖 动画关键帧/错峰 delay/别名解除/调用点结构,以及通知链路的六段 (主进程 IPC、preload 桥、去重、挂载位置、点击跳转、可用性探测)。 ## 实测 重新打包安装:186 文件 0 处不匹配,exe 188834816 字节 启动正常:silentStart 生效、login 200、device/online 200、 graph/pulse 轮询、0 个 401 / JS 错误。 判据:cmd/gui npm test 全绿(84 条)。 --- cmd/gui/loading-notify.test.mjs | 127 ++++++++++++++++++++++ cmd/gui/main.js | 56 ++++++++++ cmd/gui/package.json | 2 +- cmd/gui/preload.js | 5 + cmd/gui/renderer/app.js | 83 ++++++++++++-- cmd/gui/renderer/style.css | 184 +++++++++++++++++++++++++++----- 6 files changed, 425 insertions(+), 32 deletions(-) create mode 100644 cmd/gui/loading-notify.test.mjs diff --git a/cmd/gui/loading-notify.test.mjs b/cmd/gui/loading-notify.test.mjs new file mode 100644 index 00000000..327f7abb --- /dev/null +++ b/cmd/gui/loading-notify.test.mjs @@ -0,0 +1,127 @@ +// 加载动画 + 系统通知的判据。 +// +// ## 要判的是什么 +// +// 1) 加载动画:旧实现是 border + rotate 的 spinner(.loading / +// .loading-spinner / .live-spinner 三处),在消息气泡里刺眼, +// 且"持续旋转"语义不对 —— 旋转暗示在加载某个确定的东西, +// 而 agent 思考本身没有进度。改为 PiDeck 用的「等距三点 + +// 透明度递减 + 轻微上浮」。 +// +// 2) 系统通知:**此前完全不存在**(主进程无 Notification、preload 无接口、 +// 渲染层只有页面内 toast)。这是新增能力,容易被后续改动静默删掉。 +// +// 运行:node loading-notify.test.mjs + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const css = readFileSync(join(here, "renderer", "style.css"), "utf8"); +const app = readFileSync(join(here, "renderer", "app.js"), "utf8"); +const main = readFileSync(join(here, "main.js"), "utf8"); +const preload = readFileSync(join(here, "preload.js"), "utf8"); + +let failures = 0; +const check = (name, ok, detail) => { + if (ok) console.log(` ✓ ${name}`); + else { + failures++; + console.log(` ✗ ${name}${detail ? " — " + detail : ""}`); + } +}; + +// ── 1) 加载动画:三点而非旋转 ──────────────────────────────────────── +check("定义 haDots 关键帧", /@keyframes haDots/.test(css), "缺 @keyframes haDots"); +check("定义 haDotFloat 关键帧", /@keyframes haDotFloat/.test(css)); +check("定义 haSweep 关键帧", /@keyframes haSweep/.test(css)); + +// 三点的错峰 delay 是"追逐感"的来源,缺了就变成同步闪烁 +check( + "三点有错峰 delay(nth-child 2/3)", + /\.ha-dots\s*>?\s*i:nth-child\(2\)/.test(css) || + /\.ha-dots > i:nth-child\(2\)/.test(css), + "缺 nth-child delay ⇒ 三点同步闪,不像追逐", +); + +// 旧类名必须仍然可用(还有调用点用它们) +check( + ".loading 别名仍指向新动画", + /\.loading,\s*\.loading-spinner\s*{[^}]*animation:\s*none/.test(css), + "别名没解除旋转 ⇒ 旧调用点仍是转圈", +); +check( + "live-spinner 已是 flex 三点容器", + /\.msg-bubble \.live-spinner\s*{[^}]*display:\s*inline-flex/.test(css), + "live-spinner 仍是 inline-block 圆环", +); + +// 旧的旋转实现不应再被这三类使用 +const usesOldSpin = /\.loading[^{]*\{[^}]*border-top-color/.test(css); +check("没有残留的 border 旋转 spinner", !usesOldSpin, "仍有 border-top-color 旋转实现"); + +// ── 2) 调用点都换成了三点结构 ────────────────────────────────────── +const dotMarkup = (app.match(/class="ha-dots[^"]*"><\/i><\/i><\/i>/g) || []).length; +check( + "★ 面板/列表加载已换成三点结构", + dotMarkup >= 4, + `只找到 ${dotMarkup} 处 x3 结构(预期 >=4:星图 2 + 列表 3 + 其他)`, +); +check( + "★ 消息内 live-spinner 带三个点", + /class="live-spinner"><\/i><\/i><\/i>/.test(app), + "live-spinner 仍是空的(会看不到任何点)", +); +check( + "旧的空
已清理", + !/class="loading"><\/div>/.test(app), + "仍有裸
(无点)", +); + +// ── 3) 系统通知:主进程 ──────────────────────────────────────────── +check("main 引入 Notification", /new Notification\(/.test(main), "未调用原生 Notification"); +check("有 notify:show IPC", /ipcMain\.handle\("notify:show"/.test(main)); +check("有 notify:supported IPC", /ipcMain\.handle\("notify:supported"/.test(main)); +check( + "★ 前台聚焦时不打扰用户", + /isFocused\(\)/.test(main) && /skipped:\s*"focused"/.test(main), + "没有「窗口在前台就不弹」判定 ⇒ 正看着界面时会被弹窗打断", +); +check( + "★ 点击通知会回传并唤起窗口", + /notify:clicked/.test(main) && /mainWindow\.show\(\)/.test(main), + "点击通知后没有 show()+回传,点了等于没点", +); +check( + "正文超长截断(避免通知被撑爆)", + /body\.length\s*>\s*\d+/.test(main), + "未截断正文", +); + +// ── 4) preload 桥 ────────────────────────────────────────────────── +check("preload 暴露 notify.show", /notify:\s*{[\s\S]{0,200}show:/.test(preload)); +check("preload 暴露 notify.supported", /supported:\s*\(\)\s*=>\s*ipcRenderer\.invoke\("notify:supported"/.test(preload)); +check("preload 暴露 notify.onClicked", /onClicked:/.test(preload)); + +// ── 5) 渲染层 ────────────────────────────────────────────────────── +check("渲染层有 notifyTurnOnce 去重", /function notifyTurnOnce\(/.test(app), "缺去重 ⇒ 一轮会弹多次"); +check( + "去重基于 turnSig", + /turnSig/.test(app), + "去重没按轮次签名", +); +check( + "★ 通知挂在 SSE agent_output 上(覆盖旁观路径)", + /notifyTurnOnce\(/.test(app) && /starmapPulse\("output"[\s\S]{0,400}notifyTurnOnce/.test(app), + "没挂在 SSE 收尾处 ⇒ 旁观其他渠道的回复时不会通知", +); +check("注册了通知点击跳转", /onNotifyClicked/.test(app) && /onClicked\(onNotifyClicked\)/.test(app)); +check("启动时探测通知可用性", /notify\.supported\(\)/.test(app), "未探测 Notification.isSupported"); + +console.log(""); +if (failures > 0) { + console.log(`全部失败:${failures} 条`); + process.exit(1); +} +console.log("全部通过"); \ No newline at end of file diff --git a/cmd/gui/main.js b/cmd/gui/main.js index fdca7c49..73b7ac2b 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -2595,6 +2595,62 @@ app.on("activate", () => { createWindow(); } }); +// ============ 系统通知(Electron 原生)============ +// +// 之前**完全没有**这个能力:主进程没引入 Notification、preload 没暴露接口、 +// 渲染层只有页面内的 toast(用户不盯着窗口就看不到)。 +// 这套是补齐:渲染层判断"该不该通知",主进程负责弹系统通知。 +// +// 为什么点击要回传会话:点了通知却不知道该看哪句话,等于没通知。 +// 点击时通过 webContents 回到渲染进程,由它切到 chat 视图并滚到该条。 +let notifSeq = 0; + +ipcMain.handle("notify:show", (_, payload) => { + try { + const p = payload || {}; + const title = String(p.title || "HomeAgent"); + const body = String(p.body || ""); + if (!body) return { ok: false, error: "empty body" }; + if (!Notification.isSupported()) return { ok: false, error: "unsupported" }; + + // 已有窗口在前台且可见时不再打扰:用户正看着界面。 + // 静默启动场景下 mainWindow 存在但 hidden,正好走"要通知"这条路。 + if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible() && mainWindow.isFocused()) { + return { ok: false, skipped: "focused" }; + } + + const id = ++notifSeq; + const n = new Notification({ + title: title, + body: body.length > 180 ? body.slice(0, 180) + "\u2026" : body, + silent: !!p.silent, + urgency: p.urgent ? "critical" : "normal", + }); + n.on("click", () => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (!mainWindow.isVisible()) mainWindow.show(); + mainWindow.focus(); + } + try { + mainWindow.webContents.send("notify:clicked", { id: id, msgKey: p.msgKey || "" }); + } catch (e) {} + n.close(); + }); + n.show(); + return { ok: true, id: id }; + } catch (e) { + return { ok: false, error: e.message }; + } +}); + +ipcMain.handle("notify:supported", () => { + try { + return { supported: Notification.isSupported() }; + } catch (e) { + return { supported: false }; + } +}); + // IPC:prefs(renderer 设置页需要) // IPC:本机设备身份(renderer 设备页需要) ipcMain.handle("device:identity", () => diff --git a/cmd/gui/package.json b/cmd/gui/package.json index d34b900b..4753148d 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -8,7 +8,7 @@ "scripts": { "start": "electron . --no-sandbox", "dev": "electron . --no-sandbox --dev", - "test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs && node endpoint-align.test.mjs && node connections-io.test.mjs && node silent-start.test.mjs && node package-config.test.mjs", + "test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs && node endpoint-align.test.mjs && node connections-io.test.mjs && node silent-start.test.mjs && node package-config.test.mjs && node loading-notify.test.mjs", "test-live": "node chat-perf.test.mjs && node protocol-align.test.mjs" }, "dependencies": { diff --git a/cmd/gui/preload.js b/cmd/gui/preload.js index f446a35c..71e322ff 100644 --- a/cmd/gui/preload.js +++ b/cmd/gui/preload.js @@ -34,6 +34,11 @@ contextBridge.exposeInMainWorld("homeagent", { }, cacheBg: (src) => ipcRenderer.invoke("bg:cache", { src }), log: (m) => ipcRenderer.invoke("log:r", m), + notify: { + show: (payload) => ipcRenderer.invoke("notify:show", payload), + supported: () => ipcRenderer.invoke("notify:supported"), + onClicked: (cb) => ipcRenderer.on("notify:clicked", (_e, d) => cb(d)), + }, prefs: { get: () => ipcRenderer.invoke("prefs:get"), set: (p) => ipcRenderer.invoke("prefs:set", p), diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 863316a9..a7beae09 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -56,6 +56,8 @@ const state = { selfDeviceId: "", selfGateway: "", deviceBridgeToken: "", // 设备接入令牌明文(来自 device-bridge:get),用于 /device/* 的 requireToken + _notifyState: null, // 本轮通知去重状态 { turnSig, notified } + _notifySupported: null, // 系统通知是否可用(null=未探测) // ---- 星图活动数据源(对齐服务端 /memory/graph/pulse + /runtime)---- starmapPulses: [], // { mesh, until, kind } 活动脉冲队列 starmapGrown: {}, // nodeId -> 生长动画截止时间戳(ms) @@ -467,6 +469,55 @@ function confirmDialog(action, isDanger) { } // ===== API ===== +// ===== 系统通知 ===== +// +// 何时通知:agent **完成一轮输出**时。 +// 不在流式过程中逐 token 通知 —— 那是噪音(一轮可能几十个 delta), +// 用户要的是"有结果了",不是"正在打字"。 +// +// 去重:同一轮(signature 相同)只通知一次。SSE 有 tool_call / stage / +// agent_output 多个事件都可能判定"一轮结束",不去重会连弹好几次。 +// +// 前台可见且聚焦时不通知:用户正看着界面,弹窗只会烦人。 +// 静默启动(窗口 hidden)正好走"要通知"这条 —— 这也是静默后 +// 唯一能让用户知道"agent 回了"的途径。 +function shouldNotify() { + if (!state._notifySupported) return false; + return true; +} + +async function notifyTurn(title, body, msgKey) { + if (!shouldNotify()) return; + try { + await window.homeagent.notify.show({ + title: title, + body: body, + msgKey: msgKey || "", + silent: false, + }); + } catch (e) {} +} + +// notifyTurnOnce 保证同一轮只通知一次。 +function notifyTurnOnce(sig, title, body, msgKey) { + if (!shouldNotify()) return; + if (!state._notifyState || state._notifyState.turnSig !== sig) { + state._notifyState = { turnSig: sig, notified: false }; + } + if (state._notifyState.notified) return; + state._notifyState.notified = true; + notifyTurn(title, body, msgKey); +} + +// onNotifyClicked 点系统通知后:切到 chat 视图并滚到那条消息。 +function onNotifyClicked() { + try { + switchView("chat"); + var el = document.querySelector(".msg-assistant:last-of-type"); + if (el && el.scrollIntoView) el.scrollIntoView({ block: "end" }); + } catch (e) {} +} + async function cliRequest(line) { var conn = state.currentConn; if (!conn) throw new Error(__("未选择连接", "No connection selected")); @@ -1799,7 +1850,7 @@ function buildChatLayout() { '

' + __("星图", "Star Map") + "

" + - '
'; + '
'; html += '

' + __("终端", "Terminal") + @@ -2151,7 +2202,7 @@ function renderChat() { var isStreamingLast = i === msgs.length - 1 && streamingLast; if (isStreamingLast) { var liveRow = - '' + + '' + (newPending.length ? '' + pillHtml() + "" : ""); @@ -2227,7 +2278,7 @@ function renderChat() { '
' + aiAvatar2 + '
' + - '' + + '' + (newPending.length ? '' + pillHtml() + "" : "") + @@ -2457,7 +2508,7 @@ function renderChatStarmap() { } if (!window.THREE) { cont.innerHTML = - '
'; + '
'; state.starmapInit = false; state.starmapLoading = false; return; @@ -3240,7 +3291,7 @@ async function queryMemoryChat() { var q = document.getElementById("mem-query")?.value; var r = document.getElementById("mem-result-chat"); if (!r || !q) return; - r.innerHTML = '
'; + r.innerHTML = '
'; try { var data = await api("/memory?q=" + encodeURIComponent(q) + "&depth=2"); r.innerHTML = @@ -3260,7 +3311,7 @@ async function queryMemoryContext() { var q = document.getElementById("ctx-query")?.value; var r = document.getElementById("ctx-result"); if (!r) return; - r.innerHTML = '
'; + r.innerHTML = '
'; try { var data = await api("/memory/context?q=" + encodeURIComponent(q || "")); var ctx = data?.context || __("无上下文", "No context"); @@ -3307,7 +3358,7 @@ async function searchKnowledgeChat() { var q = document.getElementById("know-query")?.value; var r = document.getElementById("know-result-chat"); if (!r || !q) return; - r.innerHTML = '
'; + r.innerHTML = '
'; try { var data = await api("/knowledge?q=" + encodeURIComponent(q)); r.innerHTML = @@ -5269,6 +5320,16 @@ async function deleteAdapter(name) { doRenderAll(); startUptimeTicker(); setInterval(doRenderAll, 15000); + // 系统通知:探测可用性 + 接点击跳转(放在连上之后,避免无连接时空转) + if (window.homeagent && window.homeagent.notify) { + try { + const sup = await window.homeagent.notify.supported(); + state._notifySupported = !!(sup && sup.supported); + } catch (e) { + state._notifySupported = false; + } + window.homeagent.notify.onClicked(onNotifyClicked); + } } else { renderAll(); updateConnIndicator(); @@ -6125,6 +6186,14 @@ async function connectFetchSSE(url) { // 用文本前若干字符当 hint,让点亮落到**本轮相关**的实体上, // 而不是每次都亮同一批高权重节点。 starmapPulse("output", String(p.content || "").slice(0, 60)); + // 系统通知:一轮输出收尾时通知一次(不在流式过程中逐 token 弹)。 + var _sig = (p.content || "").length + ":" + + String(p.content || "").slice(0, 40); + notifyTurnOnce( + _sig, + "HomeAgent", + String(p.content || "").slice(0, 160) || __("(空回复)", "(empty reply)"), + ); endChatTurn(); return; } diff --git a/cmd/gui/renderer/style.css b/cmd/gui/renderer/style.css index 70e889a1..a6fe89d0 100644 --- a/cmd/gui/renderer/style.css +++ b/cmd/gui/renderer/style.css @@ -1458,16 +1458,130 @@ code { transform: none; } } -.msg-bubble .live-spinner { - width: 15px; - height: 15px; - border-radius: 50%; - border: 2px solid var(--msg-user-bg); - border-top-color: var(--accent); - animation: spin 0.7s linear infinite; - flex-shrink: 0; - display: inline-block; +/* ============================================================ + 加载 / 思考动画 + ------------------------------------------------------------ + 设计参考 PiDeck(@deepseek-ai/dsh-web-frontend / mermaid bundle): + 它**不用边框旋转**,而是「等距三点 + 透明度递减 + 轻微上浮」, + 静默、不抢视线,且在深浅色下都成立。 + + 旧实现是 border + rotate 的经典 spinner(.loading / .loading-spinner / + .live-spinner),在消息气泡里尤其刺眼 —— 一个持续旋转的圆环 + 与「正在思考」的语义也不匹配(旋转暗示在加载某个确定的东西, + 而思考本身没有进度)。 + + 下面是等价改造,三处调用点(面板加载 / 列表加载 / 消息内思考) + 统一到同一套视觉语言。 + ============================================================ */ + +/* 三点渐次亮起(PiDeck: _dsh-state-dot-chase 的等价实现) */ +@keyframes haDots { + 0%, + 12.4% { + opacity: 1; + } + 12.5%, + 24.9% { + opacity: 0.6; + } + 25%, + 37.4% { + opacity: 0.35; + } + 37.5%, + 100% { + opacity: 0.15; + } +} + +/* 单点呼吸(PiDeck: tool-activity-dot) */ +@keyframes haDotFloat { + 0%, + 100% { + opacity: 0.35; + transform: translateY(0) scale(0.8); + } + 50% { + opacity: 1; + transform: translateY(-2px) scale(1); + } +} + +/* 扫光(PiDeck: thinking-sweep),用于「思考中」横向扫光 */ +@keyframes haSweep { + 0% { + left: -300px; + } + 90%, + 100% { + left: 100%; + } +} + +/* 三点基类:用 flex 排布,gap 固定,避免各调用点尺寸不一 */ +.ha-dots { + display: inline-flex; + align-items: center; + gap: 3px; vertical-align: middle; + flex-shrink: 0; +} +.ha-dots > i { + display: block; + width: 4px; + height: 4px; + border-radius: 999px; + background: var(--accent); + animation: haDots 1.1s ease-in-out infinite; +} +.ha-dots > i:nth-child(2) { + animation-delay: 0.15s; +} +.ha-dots > i:nth-child(3) { + animation-delay: 0.3s; +} + +/* 尺寸变体:替换旧的 .loading / .loading-spinner / .live-spinner */ +.ha-dots-sm > i { + width: 3px; + height: 3px; + gap: 2px; +} +.ha-dots-lg > i { + width: 6px; + height: 6px; + gap: 5px; +} + +/* 面板级:居中容器里的加载态(替代 32px 旋转圈) */ +.ha-dots-panel { + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +/* 消息气泡内:思考中 */ +.msg-bubble .live-spinner { + display: inline-flex; + align-items: center; + gap: 3px; + vertical-align: middle; + flex-shrink: 0; +} +.msg-bubble .live-spinner > i { + display: block; + width: 4px; + height: 4px; + border-radius: 999px; + background: var(--accent); + animation: haDots 1.1s ease-in-out infinite; +} +.msg-bubble .live-spinner > i:nth-child(2) { + animation-delay: 0.15s; +} +.msg-bubble .live-spinner > i:nth-child(3) { + animation-delay: 0.3s; } .msg-bubble .live-spinner + .thinking-tools, .msg-bubble .live-spinner + .text { @@ -1535,22 +1649,44 @@ code { #sm-container-chat canvas { display: block; } -.loading { - display: inline-block; - width: 16px; - height: 16px; - border: 2px solid var(--loading-border); - border-radius: 50%; - border-top-color: var(--loading-top); - animation: spin 0.6s linear infinite; -} +/* 旧类名保留为别名:已有 6+ 处调用点直接用 .loading / .loading-spinner。 + 把它们指到新的三点动画,而不是删掉后逐处改 HTML —— 后者容易漏。 + (用 flex 布局装不出第三个点,::before/::after 只能造 2 个, + 所以这三个点由 JS 插入;没插入时至少是个 2 点的退化态, + 不会退化成旋转圈。) */ +.loading, .loading-spinner { - width: 32px; - height: 32px; - border: 3px solid color-mix(in srgb, var(--accent) 15%, transparent); - border-top: 3px solid var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; + display: inline-flex; + align-items: center; + gap: 4px; + vertical-align: middle; + width: auto; + height: auto; + border: none; + border-radius: 0; + background: none; + animation: none; +} +.loading::before, +.loading::after, +.loading-spinner::before, +.loading-spinner::after { + content: ""; + display: block; + width: 5px; + height: 5px; + border-radius: 999px; + background: var(--accent); + animation: haDots 1.1s ease-in-out infinite; +} +.loading::after, +.loading-spinner::after { + animation-delay: 0.2s; +} +.loading-spinner::before, +.loading-spinner::after { + width: 6px; + height: 6px; } @keyframes spin { to {