Keep the top bar as the page's single primary heading and rename the content section to Developer Tools at the secondary level.
This removes duplicate page titles for screen readers while preserving the existing debug layout and feature grouping.
Add edge fades and automatic active-tab reveal to shared horizontal tab lists, including revalidation when asynchronous labels or badges change width.
Give configuration tabs complete tab and panel semantics, keyboard navigation, focus treatment, and 40px desktop or 44px mobile targets without changing their visual hierarchy.
Increase shared dialog and sheet close targets to 40px on desktop and 44px on mobile while preserving the compact 16px icon.
Constrain dialogs to the viewport and scroll their body inside a fixed shell so long forms never move the close control off-screen.
Rebalance the mobile top bar so the page title can shrink independently while navigation and account actions keep stable touch targets.
Align status and description visibility with the actual desktop breakpoint, hide only decorative content on narrow screens, and retain kiosk access on common phone widths without horizontal overflow.
Add explicit title context for truncated labels and preserve the existing desktop density.
Give every theme option a reliable 44-pixel touch target on mobile while retaining the denser desktop presentation.
Adjust the active indicator to preserve visual balance at both sizes and add tactile press feedback that follows the application's reduced-motion settings.
Raise shared button and form-control defaults to provide clearer, more consistent interaction targets across the interface.
Guarantee 44-pixel touch targets and 16-pixel form text on narrow screens, preserve explicit desktop size overrides, and prevent mobile browser focus zoom.
Replace broad transitions with targeted press feedback, respect reduced-motion preferences, and allow callers to opt out without overriding their persistent transforms.
Replace route-local restore writes with the core transaction and map preflight, rollback, and post-commit cleanup failures to precise HTTP responses. Treat only ENOENT as a missing export file and return transaction, restored, skipped, migrated, and restart facts.
Update the settings panel to describe the full sensitive configuration group, avoid restart guidance for empty plans, and reload settings after both successful and failed imports so the UI reflects committed disk state. Add HTTP regression coverage for export and restore error semantics.
Surface whether a OneBot configuration was persisted, applied live, or left degraded. Keep failed adapters and apply errors visible in the config and overview pages instead of reporting a false success.
Give the virtualized log viewer motion without hurting readability or choking at
high rate. A genuinely new tail line fades and rises in (200ms, ease-out-expo);
detection lives in the SSE flush (real arrivals only), is bounded by a 260ms
timer, and never replays when an old row scrolls back into view or when a filter
toggles. A firehose batch (more than 4 new lines in one flush) suppresses the
per-row entrance so a 100 line/s stream never strobes — the scroll flow and edge
fade carry it instead.
Scrolling gets a soft, scroll-position-aware edge fade: a boundary dissolves only
when there is actually hidden content past it (a ResizeObserver + scroll listener
keep it in sync), so a fully-scrolled top/bottom row is never dimmed. The entrance
animation lives on an inner element so it never collides with the virtual row's
own translateY positioning or perturbs measureElement. All of it is gated on the
motion switches: the entrance on reduce/disable-motion, the static edge fade on
the hard 关闭全部动效 only.
The 加载 button added in #189 injected directly, unlike the process-list
load which routes through the confirm dialog. Fire the same
setConfirm({kind:'load'}) flow so both entry points share one
confirmation step.
login-waves ran its requestAnimationFrame loop unconditionally, ignoring
reduced-motion. Honour it (OS `prefers-reduced-motion` or the app's
`data-reduce-motion`): when reduced, paint a single static frame and never start
the loop; otherwise animate. React live to OS preference changes, and repaint the
static frame on resize while reduced so the field doesn't blank out. The loop is
torn down on unmount as before.
deliveries-widget polled /api/notifications/recent every 15s regardless of tab
visibility. Skip the poll while `document.hidden`, refresh immediately on
becoming visible, and add an in-flight guard so a slow request can't stack
overlapping polls.
The log viewer and each dashboard "recent alerts" widget opened their own
EventSource to /api/logs/stream, so N alert tiles + the viewer meant N+1
identical connections (plus a short-lived-connection churn on navigation). Make
openLogStream ref-counted: the first subscriber opens a single shared stream,
every frame and status change fans out to all subscribers (each isolated with
try/catch and re-checked against the live Set so a mid-frame unsubscribe is
skipped), and the last to leave closes it. Late subscribers replay the current
status.
Hardening: a no-token open no longer caches a dead disposer (it would have
blocked every later subscriber from ever connecting); the shared URL bakes in the
token, so setToken() tears down and reopens on any token change (re-login would
otherwise retry a stale-token URL forever); the connecting window seeds
'reconnecting' so a late joiner's badge isn't wrongly 'closed'. Consumers are
unchanged — the dedup is transparent behind api.logs.stream.
The live-activity feed kept its /api/debug/stream EventSource open while paused
and merely dropped frames in the handler, so a paused feed still held a backend
connection slot and processed every event for nothing. Make `paused` a
dependency of the subscription effect: pausing now closes the stream and
resuming reopens it. Events during a pause were already discarded, and the row
buffer is preserved across toggles, so the only change is that a paused feed
stops consuming.
Deliberately NOT gated on tab visibility: debug-page keeps this component mounted
across tabs on purpose (the comment there notes actions fired from other tabs
must still land in the feed), so closing on tab switch would regress that. Only
an explicit pause severs the connection. StatusPill gained a `paused` state so a
deliberate pause reads as 已暂停 rather than 未连接.
The logs page rendered every filtered row, so a full 2000-line view mounted
~10k DOM nodes and cost ~66ms of layout per commit. Switch to a virtualized
list (@tanstack/react-virtual) that mounts only the visible window plus a small
overscan — a few dozen nodes regardless of how many lines the view holds.
Rows are dynamically measured (measureElement) so wrap mode's variable-height
lines need no fixed row-height assumption; the size cache is keyed by row id so
the maxLines front-drop doesn't mis-attribute heights across the index shift.
The column header moved just outside the scroll viewport so the virtual list is
the sole scroll content (no sticky-offset math). Auto-scroll now follows the
tail via scrollToIndex with a next-frame re-pin (freshly-appended rows carry the
size estimate until measured, which otherwise left wrap-mode tails short), and
re-runs on filter/level/wrap/maxLines changes, not just new logs. ScrollArea
gained an optional viewportRef so the virtualizer can drive off the real Radix
scroll element. The per-row entrance fade is dropped (rows mount/unmount on
scroll); divide-y became a per-row border.
The whole segment row in the debug message builder was `draggable`, so an
HTML5 drag started on any pointer gesture inside it — including selecting
part of a pasted URL in a segment input, which dragged the card instead of
selecting text.
Move `draggable` (and onDragStart/onDragEnd) off the row onto the grip
handle only; the row keeps onDragOver/onDrop so it stays the drop target.
The body is now fully selectable/editable. The grip also moves up beside
the segment label, freeing the card's left edge. Nested forward-node
builders keep their stopPropagation drag boundary.
CPU 使用率: replace the flat per-core bar row with an adaptive heatmap
grid — auto-fill square cells whose color maps to per-core load — so it
reads cleanly from a few cores up to many-core (64/128) hosts instead of
squashing into an unreadable strip.
内存使用: replace the single usage bar with a segmented
本进程 / 其他已用 / 空闲 breakdown bar plus a legend, surfacing the
SnowLuma process's own RSS footprint (systemInfo.runtime.rss) that the
card never exposed before.
Removed the now-unused bestGrid() helper. All motion uses transform
(scale) rather than width/height to avoid layout thrash.
The log-level accent colors (info/success/warn/error) are tuned as fills;
as small text on the light canvas they fell below WCAG AA (info 2.6:1,
success 2.4:1, warn 2.0:1, error 4.3:1, trace 2.5:1). Add dedicated
--log-* text tokens that are darkened in light mode (>=4.5:1) while
keeping the bright, already-passing values in dark mode, so the app-wide
accent is untouched. trace moves from /60 to /90 to clear AA yet stay the
faintest level. Applied to both the logs page and overview RecentAlerts.
abef797 misread the request and seeded the value as a bearer session token
(which the operator would have to dig out of localStorage). The intent is
simpler: `?token=<你的WebUI登录密码>` — the value is the plain WebUI login
password, so the link is trivially constructable from something the operator
already knows.
- Revert the client-constructor bearer seeding from abef797.
- AuthBoundary: when no stored token already authed us and the URL carries
?token=, run it through client.login() (the normal password path). A correct
password lands on the home page; a wrong one falls through to the login page
(server-verified, no bypass). The password is carried into the forced
change-password gate, matching the interactive login flow.
- The param is consumed once and stripped from the URL (replaceState) before
the login call — a password in the address bar leaks via history / logs /
Referer.
typecheck 12 包干净。
Two alignment fixes in the 消息构建 tab:
- MessageBuilder is now a bounded "compose canvas" (always bordered) so the
empty hint reads as an intentional empty state inside a defined input
region, instead of a stray centered line floating in the left-aligned
form.
- The 账号 / 目标 label rows are pinned to a fixed h-8 (== the 群聊/私聊
segmented control's height) so both labels and the selectors beneath them
share a baseline — min-h-7 wasn't enough since the segmented is ~32px.
Phase 6 of the debug-console expansion — the four pieces become one
console: 测试台 / 消息构建 / 实时活动 / API 浏览器, behind an Apple-HIG
sliding-indicator tab bar.
- ApiBrowser: a searchable, category-grouped reference for every
declarative action (~520), straight from /api/debug/actions — params /
returns / invariants / role + readOnly + stream badges. Each row's 试一下
prefills the tester and switches to it (ActionTester gains a
presetAction prop keyed by nonce).
- Tester / compose / activity stay MOUNTED and toggle via CSS, so the
live feed's SSE never drops — it keeps capturing events fired while you
invoke from the tester tab (the invoke-then-observe loop) — and
in-progress params/messages survive a peek at another tab. The API
browser, heavy and stateless, mounts on demand.
Adversarially reviewed: no crash / leak / mis-trigger; the review caught
that conditional-mounting silently dropped cross-tab live events and
discarded in-progress work — fixed by keeping the stateful panels mounted.
Phase 5 of the debug-console expansion. A drag-reorderable segment
builder so you never hand-write a OneBot message array: common segments
first-class (text / @ / image / face / reply / record / video), advanced
folded (file / mface / poke / json / xml / markdown / forward-node), with
`node` nesting a full builder (merged forward, depth-capped). @ uses the
group member picker via the sibling group context; media via FileSource.
Each segment carries a stable `_id` key so editing a field never remounts
the row.
- MessageBuilder + toOneBot() serialise to the exact OneBot v11 shapes
SnowLuma's parser reads (verified field-by-field).
- MessageComposer (the 消息构建 tab): pick target, build, preview, send —
routing through send_*_forward_msg when the message has nodes, else
send_*_msg. Refuses a mixed node/non-node forward (the backend would
silently drop the stray segments) and validates incomplete segments
before sending.
- ParamField renders the builder for message-type params in the tester
(raw-text escape hatch kept), emitting the serialized array as the
string value.
Adversarially reviewed: serialization contract and React key/focus both
verified clean (no _id leakage); fixed the mixed-forward silent-drop,
added pre-send segment validation, and made the drag boundary explicit.
Phase 4 of the debug-console expansion — the tester no longer makes you
hand-write JSON. Each param renders the right smart control from its
semantic role: group/friend/member pickers (member linked to the sibling
group_id), FileSource for media, FaceGrid, datetime for timestamps,
switch for bool, select for enum — with the raw-JSON escape hatch kept.
- Read-only actions badge green, side-effecting amber; a name-pattern set
of destructive actions (kick/ban/recall/delete/…) confirms first.
- Stream API actions invoke over /api/debug/invoke-stream and render
frames live, tracked in the app-level task registry with progress; a
clean success requires the terminal response frame, and a failed frame
shows its reason (not just the {type:'error'} marker).
- Every call lands in capped localStorage history with one-click replay;
responses render in a collapsible JsonTree.
- Extracted LiveActivity (filter / search / export) and the Segmented
control into their own components; debug-page is now a thin host.
Adversarially reviewed: stream terminal-frame judging, member/group
linkage, and contract shapes verified; fixed the failed-stream-frame
reason being hidden, a latent required-enum controlled-select trap, stale
member value on group change, and several low UX edges.
Phase 3 of the debug-console expansion — the Apple-HIG foundation the
new tabs build on (reusing the existing ui kit; new controls match its
idiom, no browser-native widgets).
API client + types:
- debug.invokeStream() reads /api/debug/invoke-stream via fetch + a body
reader, parsing data:<json> SSE frames (header auth, cancellable).
- debug.upload() streams a file via XHR for real upload progress.
- DebugActionParam.role, DebugActionDoc.stream/invariants, FieldRole,
DebugStreamFrame, DebugUploadResult.
Primitives:
- Tabs (sliding indicator + roving tabindex), Picker (searchable,
windowed combobox with a raw-value escape hatch), JsonTree (collapsible
response viewer, image-url preview, copy w/ http fallback, node cap),
FileSource (URL / server-path / browser-upload tri-state), FaceGrid.
Infra:
- DebugTaskProvider + TaskBadge: app-level registry of in-flight upload/
stream tasks so they survive in-app tab switches, with a global
progress chip and a beforeunload guard; eviction never drops a running
task. Wired into App.
- use-debug-contacts: lazy friend/group/member data layer, shared module
cache + localStorage (version-stamped, avatars stripped to dodge quota)
+ TTL + request-id race guard, degrading to raw input on error.
Adversarially reviewed (no automated component tests): virtualization
math and SSE framing verified correct; fixed the stale-contacts-on-switch
member-selection hazard, the quota-kills-persistence issue, and several
low UX edges (active-row clamp, filter scroll reset, copy fallback).
The hover-rail flipped its content to a collapsed layout instantly while the
width animated, so on every expand/collapse icons jumped toward the top-left and
labels flashed in, wrapped super-tall, then settled — and the collapsed rail
leaked the first glyph of every label plus hard-clipped highlight bars.
Lay the rail content out at a fixed width and let the aside clip it: nothing
reflows during the width animation. Icons live in a fixed centered column (never
move); labels stay mounted with nowrap and just fade out when collapsed (no
wrap, no leak); the active pill and update banner render their background as an
absolute layer that becomes a centered squircle on the rail instead of a
hard-clipped bar.
Adds the promised pin toggle to the sidebar footer (desktop) — mirrors the
「钉住侧栏展开」 appearance setting: pinned keeps the rail open, unpinned returns
to hover-to-peek.
The flat tab strip mixed three domains — the dashboard itself, the all-accounts
SnowLuma protocol runtime, and the host/platform. Regroup into 3 labelled
sections (macOS System-Settings idiom): section headers show on the desktop
rail; the mobile strip stays a flat wrap via display:contents. Each group is a
role=group + aria-labelledby; the nav gains an aria-label.
This gives '全局配置' (and future global protocol knobs) a principled home and
ends the mixing. Phase 4 (final) of the global-settings split.
Adds a '全局配置' Settings tab that edits config/snowluma.json — the rkey
fallback-server list and the deployment-wide musicSignUrl — backed by a new
bearer-gated GET/POST /api/global-config (load/saveGlobalSettings, then
oneBotManager.reloadGlobalSettings to hot-apply to live instances).
The panel reuses the established settings-panel idiom (Input/Button/Label,
debounced auto-save, flash); rkey servers are an inline add/remove list with
client-side http(s) validation hints. It deliberately does not reconcile inputs
from the server's normalized response (would erase a half-typed URL); a
load-failure shows an error+retry, and the save toast says '无效项已忽略' when
the server would drop an entry.
Removes the now-dead per-UIN musicSignUrl form (moved to global in Phase 2).
Phase 3 of the global-settings split (nav grouping is Phase 4).
The rkey fallback endpoint is deployment infra (one self-hosted server serves
all accounts; fallback rkeys are fungible), so it doesn't belong in per-UIN
onebot_<uin>.json. Introduce config/snowluma.json — a generic home for small
all-accounts SnowLuma settings — and move rkey.fallbackServers there.
- New onebot/global-config.ts: load/save/normalize with section-merge + atomic
write; http(s)+host URL validation; default [] (off). Lives in onebot (not
core) because the onebot manager constructs instances and must not depend on
core.
- OneBotInstance takes globalSettings; manager loads it on session start and
exposes reloadGlobalSettings() to hot-push edits to every live instance via
applyGlobalSettings → RKeyCache.setFallbackServers.
- Remove rkey from per-UIN OneBotConfig + the WebUI per-UIN round-trip (never
released, so no migration). Register snowluma.json in the backup allowlist.
Reworks the per-UIN rkey from 12b0190. Phase 1 of the global-settings split.
On accounts where the native OIDB 0x9067_202 rkey fetch persistently returns
nothing, every image was served as a bare multimedia.nt.qq.com.cn URL the CDN
rejects with invalid rkey — SnowLuma had no fallback, unlike NapCat.
Add config.rkey.fallbackServers (default []: OFF; no third-party server is
contacted unless the operator configures one). When the native fetch leaves the
cache without a usable image rkey, each configured HTTP endpoint is tried in
turn for { group_rkey, private_rkey, expired_time } (NapCat rkey-server format;
an OneBot { retcode, data } wrapper is also accepted), populating the group/
private image rkey types. Values keep any leading &rkey=/?rkey= since
resolveImageUrl strips before re-appending.
WebUI has no form yet but round-trips rkey.fallbackServers verbatim so a hand-
edited value survives an unrelated config save. Part B of #156 (cooldown is A).
The per-account 上下线通知渠道 picker rendered every global channel as a
flat toggle row, so 40+ channels produced an unbounded, messy wall inside
通用设置. Rework it into a collapsible control:
- collapsed: one-line summary — selected count badge + selected channels
as removable chips (cap 8, then "+N 更多")
- expanded: description + name/url search filter + 全选/清空 bulk actions
+ a height-bounded ScrollArea list (max-h-64) so it never blows up
- globally-disabled channels stay locked off; data flow unchanged
Reuses Badge / ScrollArea / ToggleSwitch / Input.