Reshape the download-rkey list (type 10=private, 20=group, same 0x9067
source) into NapCat's GetRkeyServer shape {private_rkey, group_rkey,
expired_time, name}, expired_time = now + min present ttl. get_rkey /
nc_get_rkey already existed (raw list) and are left unchanged.
Add the file-rename slot to the group-file OIDB envelope (req/resp field 5),
a RenameGroupFile service mirroring MoveGroupFile, a groupFile.rename() api
method, and the rename_group_file action (params group_id/file_id/
current_parent_directory/new_name, NapCat-aligned), replacing the stub.
Wire fields (busId=102, parentFolder=current dir, newFileName) verified
against NapCat's Oidb.0x6D6 transformer; subCommand 4 cross-checked against
the move op (sub 5 on both sides). Serialize + envelope round-trip test locks
the pb tags.
Second adversarial pass: the previous 'unsupported' wording over-corrected
— it mislabeled a genuinely-uncached image file_id as 'unsupported',
mirroring the original defect. A double cache miss is runtime-indistinguishable
between a group-file id and an evicted image/voice, so the message now states
the cache miss neutrally and offers get_group_file_url as guidance only. The
prior 'group-file id' test took the same code path as the cache-miss test
(a fake test); consolidated into one honest assertion.
Adversarial review of Wave 1: get_file's 'file not found in cache' was
misleading for group-file file_ids (unsupported here, not uncached).
Distinguish the two in the error message, surface the limitation in the
summary, and lock the contract with a negative test. Also annotate
get_group_shut_list's ~60s cache-delay tradeoff and mark _mark_all_as_read
as an explicit no-op pending an RE'd one-shot read cmd.
Resolve file_id against the image cache, then the record cache (both
already mint a fresh url + carry file_size/file_name), replacing the
not-yet-implemented stub. Document-type files have no id-resolvable
cache yet, so they are intentionally left uncovered rather than faked.
Derive the muted-member list from the TTL-cached group member list
(shutUpTime = absolute expiry epoch seconds), replacing the placeholder
that always returned []. NapCat-shaped output {user_id,nickname,shut_up_time}.
Settings → 通知 now mirrors the node-config UX: info-only channel cards
(name / id / url / template + an inline enable toggle), create + edit via a
dialog, and all changes debounced-auto-save (no save button). The save path
is generation-guarded so a superseded in-flight save can't clobber a newer
edit; the test action flushes the pending save first so it never hits an
unsaved channel. The channel id is locked while editing to keep per-account
channelIds references from orphaning.
Per-account opt-in: a globally-disabled channel's toggle is now locked +
forced off (it can never fire). The id is kept in channelIds — the backend
already filters disabled channels — so re-enabling globally restores the
prior selection.
New: notification-channel-dialog.tsx. webui typecheck + build green.
Adversarial review: 0 blockers / 0 should-fix (2 nits applied).
- API client: notifications surface (getConfig/saveConfig/recent/test) + types.
- Settings → 通知 tab: global channel CRUD (id/name/url/body-template/enabled) +
global debounce window, per-channel test-send, dirty-tracked save; invalid
channels (bad slug / non-http URL) are dropped server-side and surfaced.
- Per-account 通用设置: opt-in toggles bound to notifications.channelIds, riding
the ConfigPage's existing debounced autosave; stale opt-ins preserved.
- Overview: opt-in 最近投递 widget (polls /api/notifications/recent, read-only).
Hidden-by-default set is now DERIVED from the catalogue (defaultVisible) and
shared by desktop + mobile reconcile — replaces the hardcoded STATIC_WIDGET_IDS
so a new opt-in widget can't reappear unbidden on existing mobile dashboards.
Typecheck + build green. Adversarial review: 1 should-fix (mobile hidden-set) +
2 nits found, fixed/triaged, and re-verified clean.
Global singleton observing every account's online/offline edge:
- Pure per-UIN debounce state machine (debounce.ts): an offline surviving
debounceSeconds fires "offline"; recovery fires the paired "online"; a
self-heal within the window fires nothing; a cold online fires nothing.
- NotificationManager (manager.ts): renders each opted-in + enabled channel's
body template and POSTs it (single attempt + timeout, JSON), recording the
outcome in a bounded in-memory history (lost on restart). Side effects
(config/channelIds/post/clock) are injected for testability.
- BridgeManager: single-callback setters → additive listeners so OneBotManager
and NotificationManager observe independently; close edge now carries the
bridge (read nickname before dispose); fire-all with per-listener try/catch.
- Wired into the composition root, bound after OneBotManager.
Tests: debounce machine (pure, all misfire sequences) + manager (selectChannels,
dispatch, history cap, fake-timer self-heal/pairing). 23 green, typecheck clean.
Adversarial review: 0 blockers / 0 should-fix (2 NITs applied).
Global channel store at config/notifications.json, mirroring ui-config's
total-normalize + atomic-write + cached-load conventions: NotificationsConfig
(version, debounceSeconds clamp [0,3600] default 30, channels[]), a total
normalizeNotificationsConfig (drops malformed/non-http channels, dedupes by
id, strips unknown fields), and the pure renderTemplate ({key} substitution;
missing keys left as-is; no re-expansion of substituted values).
Per-UIN opt-in: OneBotConfig.notifications.channelIds (validated slugs,
last-write-wins merge across global+per-uin, deduped) in the onebot package.
Tests: 19 (core notifications-config) + 12 (onebot config), all green.
Adversarial review: 0 blockers / 0 should-fix.
The read-only push-body probe in Bridge has served its purpose; the KickNT
push body and its reason fields are captured for the upcoming notification
reason-enrichment. Bridge back to baseline.
Conclude the spike on the inbound
`trpc.qq_new_tech.status_svc.StatusService.KickNT` push and remove its
throwaway instrumentation:
- Bridge: drop the outbound send probe.
- HookSession: drop the loggedIn-gate bypass; restore `if (!this.loggedIn) return;`.
- HookManager: drop the disconnect-timing log.
Keep one read-only `[KICK-BODY]` probe in Bridge that logs the KickNT push
body (hex + utf8) so the reason fields (tipsTitle / tipsDesc) can be reversed
for the upcoming notification reason-enrichment. Remove once captured.
Reworks the desktop overview edit UX from per-card show/hide toggles to an
Apple-widgets model:
- The grid shows only the widgets that are on it; hidden ones live in a new
right-side 部件库 ("store") panel that slides in during edit mode.
- Add: drag a gallery tile onto the canvas (HTML5 DnD) — dropped at the cursor
cell, driven entirely through React state.
- Remove ("hide"): drag a card back onto the gallery (gridstack `removable`).
Move/resize/remove all funnel through one onChange writer that derives removal
from a block's absence in the grid snapshot — no separate handler, no state
race.
- Card affordance: the big top "notch" bar is gone. Edit mode now shows two
small square corner icons — a subtle semi-transparent drag hint (top-left, the
whole card stays draggable) and the settings gear (top-right, configurable
widgets only). The eye toggle is removed.
Mobile is unchanged (single-column reorder panel) — the gallery drag UX is
desktop-only by design.
- 导出当前视图: a toolbar button dumps the current (filtered) log view to a
client-side `.log` text file — no server endpoint, respects the active level
filter + search.
- 视图预设: 开发 / 运维 / 精简 presets bundle the existing display prefs
(visibleLevels / maxLines / autoScroll / wrap); a new `UiLogsPrefs.preset`
field stores the active id. Any hand edit to those prefs drops to 自定义.
The client owns the preset→prefs bundles; the server just stores + validates
the id (oneOf, defaults custom). No non-chronological reordering, per plan.
A session-sticky kiosk mode for showing the dashboard on a wall display:
- Entry: `?kiosk=1` URL param (stripped after read) or a top-bar button
(toggleable via topbarItems 'kiosk').
- Hides the sidebar + top bar (MainLayout) and any in-content edit entry marked
data-kiosk-hide (the overview 编辑布局 toolbar) via an html[data-kiosk] CSS rule.
- Exit: Esc key or a dim corner button.
Purely cosmetic — never touches auth and issues no token. A new KioskProvider
(sessionStorage-backed, mounted under LayoutProvider) owns the state.
Completes the B2 dashboard/components batch.
Three opt-in static widgets join the GRID_WIDGETS catalogue (hidden by default
— enabled from the layout editor), with content stored in block.config:
- 便签 (note): pre-wrap plain text, capped at 4000 chars (under the server's
4096-byte config cap). No Markdown.
- 链接卡 (link): label + http(s)-only url (javascript:/data: rejected) + an
icon from a curated lucide set; renders as a clickable card.
- 账号快捷卡 (account): a specified uin's avatar/nickname/online dot, with a
datalist of connected accounts in the config form.
GridWidgetSpec gains `defaultVisible`, honored by defaultOverviewGrid/Mobile and
migrateOverviewBlocks; reconcileLayoutItems gains a `hiddenByDefault` set so the
mobile catalogue appends these hidden too. parse* validators keep config total
over corrupt input.
Adds the gear config form to two more overview widgets (stat tiles stay
form-less, per plan):
- 主机资源 (host): toggle which resource panels show (CPU / 内存 / 运行进程);
the grid column count tracks the visible panel count, and an all-off config
falls back to all-on (no empty card).
- OneBot 连接 (connections): filter by account nickname/uin, an "仅显示异常端点"
toggle (drops healthy adapters and emptied accounts), and sort by 默认 /
名称 / 异常优先 (worst status first).
Both register in CONFIGURABLE_WIDGETS with parse* validators, render via the
existing dialog, and surface on mobile through the same per-widget config map.
The top-bar's optional right-side elements (connection-status badge, theme
toggle) are now toggleable via `topbarItems`. Essential controls — the
menu/collapse button, page title, and logout — always render and aren't listed.
- A TOPBAR_CATALOGUE (id+label) drives both the render gating and the settings
toggles; TopBar reconciles `topbarItems` against it so a new element defaults
to shown.
- A 顶栏 group in settings → 外观 exposes one switch per catalogue element.
Phones now render the overview from the dedicated `overviewMobile` ordering
instead of deriving a 1-column view from the desktop grid — the two layouts are
fully disjoint, so reordering on a phone never touches grid coords and vice
versa.
- View mode reuses DashboardGrid (drag disabled, cols=1), stacking each widget
at its catalogue height via a pure prefix-sum, so cards keep their cell
sizing. Per-widget config is shared with the desktop blocks.
- Edit mode surfaces a motion `Reorder` drag-sort panel (grip + show/hide eye +
config gear for configurable widgets) — no 2D drag on mobile, per plan.
- The 编辑布局 toggle now appears on mobile too, with a mobile-specific hint.
Order/visibility reconciles against the live widget catalogue, so a widget
added later just appears on phones as well.
Schema foundation for the B2 dashboard batch, mirroring the navItems shape:
- `overviewMobile: UiLayoutItem[]` — a separate single-column (id+visible)
ordering for phones, kept fully disjoint from the desktop 2D `overviewBlocks`
grid so mobile ordering never pollutes grid coords.
- `topbarItems: UiLayoutItem[]` — show/hide list for toggleable top-bar
elements (status badge, theme toggle, kiosk button); essential elements stay
pinned in the consumer.
Server `normalizeLayout` round-trips both (idOr + dedup, total over corrupt
input); defaults seed first run. `LayoutContext` exposes them with
setOverviewMobile/setTopbarItems setters and reconcile-on-consume semantics, so
adding a widget/element later just appears. Old configs lacking the fields fall
back to defaults on load.
Consumers (mobile render, topbar render + config, drag-sort panel) land in
follow-up B2 commits.
Keyboard-a11y polish to match the codebase's standard
`focus-visible:ring-[3px] focus-visible:ring-ring/40` treatment:
- ToggleSwitch (role=switch, used across every settings/config surface) had
no focus-visible ring — keyboard users got no focus indicator.
- Sheet's close button used a bare `focus:outline-none`; now mirrors the
Dialog close button's ring.
Slider, Button, Input, Select, Tabs, Checkbox, Switch already carry the ring.
- Custom-CSS editor stays a plain <textarea> (native Cmd/Ctrl+Z undo) but
gains insert-at-cursor template buttons (string-constant snippets) and a
best-effort on-blur validation that warns on unbalanced braces or CSS that
parses to zero rules. Never blocks saving; ?safe-mode=1 remains the escape.
- Accent picker: choosing a custom colour whose contrast against the canvas
falls below WCAG 3:1 surfaces a one-time (per-session) advisory offering to
revert. Pure-advisory — applies immediately, no persistent banner, no nag.
Adds two appearance customizations from the WebUI plan's B1 batch:
- Custom fonts: new `fontSansCustom`/`fontMonoCustom` free-form family
fields (mirroring accent's preset/custom dual-field). A "自定义" segment
reveals a text input; on Chromium it gets a native <datalist> of installed
fonts via `queryLocalFonts()`, feature-detected with a silent fallback to
a plain text box on Firefox/Safari/mobile. No font-file uploads.
- Theme-variable panel: new `cssVars` map lets the operator override
individual theme tokens (background/foreground/primary/…) with colour
pickers, layered last over palette+accent. Both keys (token whitelist) and
values (hex/named/functional colour) are validated, so — unlike customCss —
this stays safe to serve to the unauthenticated login page.
Server `normalizeAppearance` gains `fontFamilyOr` (rejects control
chars/injection) and `normalizeCssVars` (whitelist keys + colour-only values,
total over corrupt input). Client `ThemeContext` re-validates defensively
(the localStorage cache is locally tamperable) before injecting into CSS.
Tests for the new normalizers land with the B4 batch.
Upgrades the blank-body heuristic to NT's exact discriminator. RE of
wrapper.linux.node (msg_header_codec_helper.cc::DecodeRoutingHead, sub_3B42650)
shows the official client classifies a C2C push by (svr_msg_type, c2c_cmd):
msgType ∈ {141,166,167} with c2c_cmd ∈ {1,73,75,129,131,133,135,192} is routed
through OnRecvSysMsg (long_cnn_msg_mgr.cc) as a system/control signal and never
shown as a chat bubble — the group-invite "[空消息]" phantom is one of these.
- proto-defs: ContentHead field 3 was mislabelled `divSeq`; it is `c2c_cmd`.
Renamed (only other use was forward.ts, which zeroes it on a fake send).
- MsgPushHead now carries c2cCmd; new isC2cControlPush() in blank-filter.ts.
- parseMsgPush + both history fetches drop control pushes by (msgType, c2cCmd)
regardless of body, so a control signal carrying stray content is caught too
(not just the empty case). isBlankMessage stays as the fallback for
content-less pushes we don't yet classify by cmd. Live path debug-logs a
control push that carried elements, in case the table ever eats real content.
NOTE: the c2c_cmd set is taken from NT; we have not captured #24's actual cmd
(our diagnostic never logged field 3), so the blank fallback remains load-bearing
until a live capture confirms which cmd the invite phantom uses.
The #102 fix dropped content-less control pushes only in parseMsgPush (live).
History fetches (fetchC2cMessageRange / fetchGroupMessageRange) decoded straight
into the result, so the same phantom resurfaced as "[空消息]" on backfill. RE of
wrapper.linux.node confirms QQ NT filters blanks on BOTH paths — live/roam C2C
via c2c_roam_msg_mgr.cc::FilterBlankMsgAndRetryFetch and group roam via
group_roam_msg_worker.cc::FilterBlankSeqsMsg.
Extracts the blank test into blank-filter.ts (bodyHasDecodableContent +
isBlankMessage) and applies it in all three message-producing paths. A message
that decoded to 0 elements but whose body carried content is a missing decoder,
not a blank — isBlankMessage returns false there so it's kept (live path still
warns).
A group invite makes QQ emit a content-less c2c message (msgType=166/0, body
richText.elems=[]) right after the invite card. The TRACE log from #102's
diagnostic confirmed the body is truly empty — no commonElem, no msgContent,
no ptt/file — so it's a phantom control push, not an element type we fail to
decode. It was reaching OneBot clients as a confusing "[空消息]".
parseMsgPush now drops a message-kind event that decodes to zero elements when
its body carries nothing decodable. When the body *did* carry content but still
decoded to nothing (a real missing decoder), the event is kept and a warning
fires so the gap gets noticed rather than silently swallowed — replacing the
temporary #102 diagnostic with the actual fix.
A group invite surfaces a content-less "[空消息]" friend message (#102). Before
deciding whether to drop or decode it, we need to know what its body actually
carries — SnowLuma's decoder covers fewer element types than Lagrange, so an
empty decode may be a missing decoder (real content) rather than a genuinely
empty control push.
parseMsgPush now warns, on any message that decodes to zero elements, with a
summary of the body: each element's field names, every commonElem's
serviceType/businessType + payload hex, and any msgContent. One log line is
enough to identify the culprit element offline.
No behavior change — the message still dispatches as before. Temporary; remove
once the #102 culprit is identified and handled.
A group invite push carries no group name (confirmed across SnowLuma / Lagrange
/ NapCat — all three invite protos only have groupUin + invitorUid), and
get_group_info only resolved joined groups (fetchGroupList → roster), so an
invited group rendered "未知群名". Add a by-id server lookup so the name
resolves even when the bot isn't a member.
- proto-defs: 0x88D_0 request (field1 + config.uin + a request-mask `flags`
block) + response (groupInfo.results: name / member counts / owner / …),
field tags transcribed from
dev/Lagrange.Core/.../OidbSvcTrpcTcp0x88D_0{,Response}.cs.
- protocol: FetchGroupDetail oidb-service. Envelope reserved=0 (NO uinForm) —
Lagrange's GetGroupInfoService builds it with isUid=false.
- core: ContactsApi.fetchGroupDetail(gid) → QQGroupInfo | null; deliberately
NOT remembered into the joined-groups roster (mustn't pollute get_group_list).
- onebot: get_group_info falls back to fetchGroupDetail for non-member groups,
behind a 5-min per-id TTL cache so a burst of invites for one group can't
hammer 0x88D_0 (rate-limit / kick risk). noCache bypasses it.
- tests: request/envelope round-trip (reserved=0, uin, command), response
decode; + non-member resolve / cache-hit / noCache-bypass at the onebot layer.
Live note: byte-identical to Lagrange's 0x88D_0, but not verified against a real
account on this host.
When an incoming reply quotes a message SnowLuma's store doesn't have (e.g. one
from before it was running, or never observed), the receive pipeline now fetches
just that message from the server and persists it before dispatch — so a
consumer's get_msg on the quote resolves instead of returning "message not
found". Works for both group and private (c2c).
- core: MessageApi.getGroupMessageBySeq / getC2cMessageBySeq — fetch a single
message over a 1-wide [seq,seq] range, through the shared history throttle
gate (same rate-limit as the bulk history APIs — no flood / kick risk).
- onebot: backfillReplyTarget — on a group/friend reply whose target isn't in
the store, fetch (group SsoGetGroupMsg / c2c SsoGetC2cMsg, uid resolved for
c2c) and persist under the EXACT id the reply resolves to, so get_msg hits
regardless of who sent the quoted message (a bot self-sent c2c message
converts with user_id=self but is keyed under the peer session). Awaited
before dispatch so the consumer sees it; best-effort — a back-fill failure
never blocks delivery of the live message.
- tests: gating (already-stored / no-reply / nothing-on-server) + keying
(group, c2c, c2c self-sent under peer session).
Only fires on a store miss, so the common path (no reply, or quote already
stored) stays zero-cost.
Replying to a bot self-sent private message still failed get_msg on the quoted
id ("无法解析申请信息"). Root cause is the reply DECODE, not the keying: for a
c2c (friend) reply the canonical replied-to sequence is carried in
srcMsg.pbReserve.friendSequence (tag 8), while origSeqs[0] holds the per-sender
clientSequence. The decoder read origSeqs[0], so the reply resolved to a
sequence that never matches how the original message is keyed (by its server /
private sequence) — get_msg missed. Mirrors Lagrange's incoming-reply rule
`Sequence = reserve.FriendSequence ?? OrigSeqs[0]` (ForwardEntity.cs +
SourceMsg.PbPreserve.cs ProtoMember(8)).
- proto-defs: +SrcMsgPbReserve { receiverUid@7, friendSequence@8 }
- protocol: decodeRichBody reads friendSequence for c2c replies (group keeps
origSeqs[0], the shared group seq); fall back to origSeqs[0] when absent
- tests: c2c→friendSequence, group→origSeqs[0], no-reserve→fallback
This supersedes the keying approach in 4571864 (self-sent c2c keyed by
clientSequence), which is fully reverted here: the message is keyed by its
private sequence as before, and the fix is purely on the decode side so it also
covers replies to *received* old c2c messages, not just self-sent ones.
A friend replying to a bot self-sent private message → bot get_msg(quoted id)
returned "message not found" (bot then answered "无法解析申请信息"). c2c replies
reference a message by the sender's clientSequence, not the server sequence
(Lagrange: OrigSeqs = ClientSequence || Sequence), but self-sent private
messages were keyed by the server (private) sequence — so the incoming reply's
resolved id hash(clientSequence, peer, private) never matched the stored event.
Reply-SENDING still worked because it only needs the meta row (resolveReplySequence
queries by hash, no data), not the full event (findEvent requires data IS NOT
NULL) — which is exactly why get_msg / "引用解析" failed but replying didn't.
Key self-sent c2c messages by clientSequence (fallback to sequence, mirroring
Lagrange's `||`) via a shared c2cSelfMessageId helper, across all three c2c
self-sent paths (send_private_msg, private forward, forward-single-to-user).
Group messages unchanged (their replies reference the shared group sequence).
message_id is a signed int32 hash (hashMessageIdInt32) and is frequently
NEGATIVE, but both history actions declared it as `f.int({min:0}).default(0)`
— so a real anchor like message_id=-497311472 was rejected at param
validation (retcode 1400, 0ms) before run() fired, and the server-side
history fetch never happened (the client then reports "未找到足够的群聊记录").
Drop the min:0 bound → `f.int().default(0)`: negatives pass, absent/present-0
still mean "fetch latest", count stays ≥0. Matches every other action, which
uses f.messageId() for signed message IDs.
Regression tests: negative group/friend anchors now reach the handler;
absent message_id still defaults to 0.
Market faces (商城表情) now decode to an OneBot `image` segment carrying
NapCat-style markers (emoji_id/emoji_package_id/key/summary) plus a gxh
gif URL, so clients that don't special-case `mface` still render the
sticker while aware clients can reproduce it.
Adds the previously-missing send path: a dedicated `mface` segment OR an
`image` segment carrying `emoji_id` rebuilds the wire `marketFace`
(faceId=hex(emoji_id), itemType 6 / subType 3 / 300×300 /
pbReserve.field8=1) — byte-matched against NapCat and Lagrange.Core.
- proto-defs: MarketFace.key bytes→string; +MarketFacePbReserve{field8@8}
- protocol: decode faceId→emojiId(hex)/tabId/key; makeMarketFaceElem +
buildSendElems 'mface' case; format preview keeps the sticker name
- onebot: to-segment mface→image; message-parser mface + image(emoji_id)
- tests: wire decode + full proton round-trip; parse→build; segment shape
Reply/forward round-trips preserved (element keeps type 'mface' + text).
Mirror the group-history fetch for private chats via SsoGetC2cMsg
(trpc.msg.register_proxy.RegisterProxy.SsoGetC2cMsg, fields per Lagrange:
flat friendUid/start/end request, messages@7 response). get_friend_msg_history
now resolves the anchor sequence + the friend's UID, fetches real history from
the server, converts + persists each message, and falls back to the local
store on unknown anchor / no uid / empty / error.
- protocol: fetchC2cMessageRange (decodes via the friend decoder).
- core: the group + c2c fetch now share a generic walkBackHistory loop
(same throttle gate + caps); added MessageApi.getC2cHistory.
- onebot: getFriendHistory + a generalized persistHistoryEvent (keys private
by sender uin, exactly like the live path — so reply to old private
messages resolves too).
Tests: SsoGetC2cMsg request/response round-trip + friend-decode reuse.
get_group_msg_history was served only from the local observed-message store,
so it could never return more than what SnowLuma saw live (the "only 100
messages" symptom). It now fetches real history from the server.
- protocol: new SsoGetGroupMsg packet (trpc.msg.register_proxy.RegisterProxy
.SsoGetGroupMsg, sequence-range fetch; fields per Lagrange). Each returned
PushMsgBody is decoded by the existing group decoder via a factored-out
buildContextFromMessage.
- core: MessageApi.getGroupHistory walks backward in small sequence windows
with rate-limiting + data-volume guards — a process-wide throttle (≥300ms
between sends), 30-seq chunks, ≤12 requests, ≤200 messages per call — so a
hammering client can't make us flood the server and get kicked.
- onebot: get_group_msg_history resolves the anchor sequence (requested
message_id or latest observed), fetches from the server, converts + returns,
and persists each fetched message. Falls back to the local store on
unknown anchor / empty / error.
Persisting fetched history also fixes replying to pre-injection messages:
the reply path resolves message_id→sequence via the store, which now contains
the fetched messages.
Tests: SsoGetGroupMsg request/response round-trip + decode reuse (protocol);
walk-back chunking/dedup/cap/floor (core).
- Grid row height 80→108px so a 1-row stat tile's icon + 3 text lines clear
the padding with real breathing room (the previous fix still clipped ~8px);
stat tile gets an airier layout (size-10 icon, text-lg value, py-3.5).
- Carry the app's Apple language across the homepage: inner surfaces and
empty-state containers round to xl, empty-state icons calm to size-8/opacity,
edit hint + first-run nudge rounded consistently.
- OLED: toggle switch off-track is now a neutral foreground tint with a white
knob (was invisible bg-muted + bg-background on pure black); the OLED sidebar
override is scoped to sidebar-style=follow so panel/accent work in OLED too.
- Accent: the chosen accent now also tints the selected-state surfaces
(--accent / --sidebar-accent) so the sidebar active item and settings nav
rail follow it — default palette only, respecting accent scope.
- Palettes: add 6 more soft schemes (Rosé Pine / Moon / Dawn, Nord, Everforest
dark + light) via a pal() builder; schema unions + normalizer + bootstrap
light/dark classification updated to match.
- Light/dark switch now animates: a circular View Transitions reveal from the
click (mode/palette/intensity changes only; gated off under reduced/disabled
motion; instant fallback where the API is absent).
The settings sub-tab now lives in the URL (typed `?tab=` search param on the
/settings route, validated against the tab whitelist; default 外观 omits it).
The active tab is derived from the URL and tab clicks navigate (replace), so
tabs are shareable/back-button friendly. The sidebar "有新版本可用" banner now
links to /settings?tab=about, landing directly on 关于 (where 软件更新 lives)
instead of the default settings tab.
- Frosted "vibrancy" card (bg-card/65 + backdrop-blur-2xl) floating over the
wavy background, with a centered brand focal point (logo tile, name,
version, subtitle).
- Larger rounded fields/button (h-12, rounded-xl) for a calmer iOS feel.
- The "我觉得这个界面很卡怎么办?" help line becomes a 灵动岛-style frosted
capsule (rounded-full, blur, float, spring on hover/tap).
- New entrance/hover motion gated on reduceMotion/disableMotion.
The login password field showed two eye icons — our custom show/hide
toggle plus Edge/IE's native ::-ms-reveal. Hide the native reveal/clear
pseudo-elements so only our toggle remains.
Login page now has a mouse-reactive wavy-line background (ported a-waves +
embedded Perlin noise, themed to the accent, rendered behind the card which
sits on a 95%-opaque blurred surface so content stays readable). A "我觉得这个
界面很卡怎么办?" link opens a dialog that toggles it off — a device-local
preference (localStorage, works pre-auth, defaults off when the OS prefers
reduced motion), independent of the system motion settings.
Motion settings are now three:
- 关闭登录页的动效 — the login background toggle above (device-local).
- 减弱动效 (reduceMotion) — soft: transforms/springs off, durations
collapsed, page/sidebar instant; gentle entrance fades remain.
- 关闭全部动效 (disableMotion, NEW appearance field) — everything reduce does
plus data-no-motion CSS (kills pulse dots; spinners excluded so "busy"
stays legible) and the entrance opacity fades gated off across
overview/processes/logs/config lists.
- Scrollbars: theme native scrollbars (thin, token-colored thumb) for plain
overflow containers; Radix ScrollArea keeps its own custom bar (it hides
the native one, so no double bar).
- Sliders: new <Slider> component + `.slider` CSS replaces the native range
inputs (UI scale / mask opacity / blur). Filled track + ring-bordered
thumb, themed; input height matches the track so the thumb centers in every
engine; Firefox fills via ::-moz-range-progress.
- Stat tiles: tighten the h=1 tile (py-2 + mt-0.5) so the three text lines +
padding fit the 80px cell without hugging the border.
- Reduce motion: gate the always-present sidebar-width + page-transition
animations to instant when enabled (Framer's reducedMotion only suppresses
transforms, so these needed an explicit opt-out); kill the slider thumb
transition too.
Split the single centered card into a clean three-section stack consistent
with the rest of settings:
- Brand hero card: logo glow + name + version pill + tagline/description.
- "软件更新" grouped list (reuses Group/SettingRow): current-version row +
check-update row (status dot/spinner + 重新检查); when an update exists, a
tinted detail block with version, notes, platform asset hint and 前往下载.
- Support card: refined Star CTA + repo/releases/issues links.
UpdateStatus → UpdateGroup; all update states (checking/disabled/error/
latest) preserved.
Add a "配色方案" picker offering the four Catppuccin flavors as full color
schemes alongside the default sky theme.
- Schema: new `palette` field on UiAppearance (core + client mirror),
normalized to a 5-value whitelist defaulting to 'default'.
- Apply: each flavor's base/surface tokens are injected into the shared
theme stylesheet over :root (Latte) or .dark (the three dark flavors);
accent (--primary/--ring) stays separate so it composes. A flavor fixes
light/dark (Latte→light, rest→dark), overriding `mode`, and forces
dark-intensity to soft so the OLED "black" rule can't fight the palette.
sidebar-style + high-contrast still compose (higher specificity).
- Pre-paint bootstrap is palette-aware (no light↔dark flash on reload).
- Settings: swatch-preview picker; mode + dark-intensity controls disable
while a flavor is active. The top-bar ThemeToggle hides then (it would be
inert under a fixed-scheme palette).