lilcandi/dsh-any-background-plus

dsh-any-background-plus

dsh-any-background 的增强分支。v0.4.0 起的改动为本 fork 自作,新增左右双图轮播:同一轮播在窗口两侧各推进一步,以绝对中心为分界,壁纸不再被会话区完全挡住。两条车道还可各读一个文件夹(左右各一),共用同一轮播频率与模式,左右边缘淡化效果完全一致。

待分类deepseek-harnessdsh-plugin-marketdsh-plugins

dsh-any-background-plus

English | 中文

This is a fork, and this half of it is mine. Modified from Tkingxiao/dsh-any-background (MIT), which built the colour wheel, the per-surface opacity/blur system, the generated backgrounds and the rotation engine. Everything from v0.4.0 onward is this fork's own work — see What this fork adds below. Upstream: github.com/Tkingxiao/dsh-any-background.

A DeepSeek Harness appearance plugin: custom theme colour, background wallpaper (image / algorithmically generated), and fine-grained per-surface opacity & blur controls — plus this fork's dual-lane wallpaper, two-folder rotation and a floating rotate-now orb with a live countdown ring. Compatible with DSH 0.1.5-rc.2 ~ 0.2.1-alpha.2.


What this fork adds

These are the features I built on top of upstream. They are the reason this fork exists.

  • Dual-lane wallpaper — two pictures, left and right. A single dual toggle paints two different pictures of the same rotation at once, one down each side of the viewport, clear of the centre column. The motivation is geometric rather than decorative: the wallpaper is painted behind the whole app while the host's conversation column sits on top of the middle, so a picture whose subject is dead centre has that subject exactly where it cannot be seen. Two lanes hug the edges instead, and the picture that used to be half-hidden becomes the one you actually look at. Off by default.
  • One rotation advanced twice — never a second rotation. Both lanes come from one step of one rotation: the left index is drawn with the existing pickRotationIndex() (same shuffle/order semantics, same "never repeat the current picture"), and the right index is taken from nextLaneIndex(), which steps forward from the left one. Stepping rather than drawing again is deliberate — the shuffle RNG is unseeded, so two independent draws would occasionally collide and paint the same picture twice, which is precisely what dual mode exists to avoid. In order mode the pair is simply adjacent pictures. The pair is persisted as laneItems, so it survives a reload and cannot drift apart.
  • A third rotation source: one folder per side. The rotation can read a different directory for each lane, each listed and advanced in place. The cadence and the mode stay shared — the point is two sources, not two rotations — so the left and right cursors are kept separately: the left one is the stored current, the right one is recovered by looking up the name the right lane is already showing in its own listing. That matters as soon as the two directories differ in length, because a single shared index would walk the shorter listing off its end. Only once both directories exist does the wall change; picking just one stores it without previewing, so the wall is never left half split with a stale lane on the other side. The single-folder mode is still there — the pair is a third source, not a replacement.
  • The split is the window's absolute centre, and the lanes are equal by construction. wpLeftEl and wpRightEl are peer position:fixed; width:50%; z-index:-1 elements, one pinned to each edge, and both are given the same pixel width — Math.floor(innerWidth * 0.5) — so an odd viewport width cannot leave one lane a pixel wider than the other. The overhang that lets a picture reach the seam is applied only in center mode: in fit the picture is contained inside its lane by construction, so an overhang there would make the two lanes overlap at the seam and whichever painted second would win the strip — one picture visibly encroaching on the other, which is the asymmetry this mode exists to prevent.
  • Each picture's intrinsic size is measured separately. The lane geometry needs both pictures' real dimensions before either can be laid out, and a single-slot measurement cache cannot supply that — whichever lane ran second would claim the cache and push the first into the proportional fallback, painting one picture to its box and the other to its ratio. That is the exact "one picture bigger than the other" split dual mode exists to avoid, so the sizes are held in a small per-URL map with a synchronous lookup.
  • Edge feathering applies to both lanes, identically. The slider is read as a share of each picture, the ramp is computed with the same px-stop recipe against the same box, and it is applied unconditionally — a lane is never skipped for being wider than its box. Both lanes are therefore feathered symmetrically and neither can end up faded while the other stays hard-edged.
  • The settings preview mirrors the split. The hero image on the theme panel is drawn as the same 50/50 grid, with the right lane's picture beside the left one, so the preview shows what the wall will actually look like rather than a single centred picture. The right URL travels through the theme store rather than being read from the module-level image state, because a React surface reading that state directly would miss rotation updates.
  • Floating rotate-now orb with a countdown ring. A round button is pinned to the bottom-right of the viewport, reachable without opening any panel, with a live ring drawn around it showing how long until the next swap. The ring only appears for the minutes cadence — that is the only interval whose remaining time is knowable in the browser (reload is due on every read, daily/weekly are settled by the node half as the page loads, and a disabled rotation never falls due at all), so for those the button is drawn alone rather than showing a ring that would always read "full" and be a lie. Clicking advances the rotation, and because the advance stamps lastRotate the ring refills by itself. It is mounted as its own React root that portals to <body>, so neither the settings dialog's scroll column nor a host transform can clip it. Drag it and it stays where you put it (the position is remembered per window, re-clamped into the viewport on every resize); right-click it to send it back to the corner. It is drawn with the host's own floating-button recipe rather than a lookalike — --dsw-alias-button-floating-fill / -hover, --dsw-elevation-panel, --dsw-elevation-stroke-color: var(--dsw-alias-border-l3) and --dsw-alias-label-primary, the same tokens the host's scroll-to-bottom control resolves against, so it follows the theme colour and the light/dark switch by construction. Dropping it onto <html> instead — a sibling of <body>, so on the far side of every design token — would have left it a transparent outline, which is measured, not assumed.
  • The video feature is gone, as code. Every video endpoint, route, upload handler, mime table, filename whitelist, folder scanner, per-element playback layer and its settings were deleted, not hidden — BackgroundType no longer carries 'video', the rotation no longer carries media / videoFolder / videoName / videoVolume / videoAlign / ended, and the whole src/client/utils/video.ts module is removed. A stored config that still names a video rotation falls back to images on normalize.
  • A compatibility range that survives a host upgrade. The host gates a plugin on its peerDependencies before importing a single module, so an enumeration of release strings is a hard expiry date: publish a host release it does not name and the plugin is refused at load, then dropped from dsh.profile.bundles by the next plugin-manager write. The gate is a semver range now (>=0.1.5-rc.2 <0.3.0-0), and dsh.compatibility.dshReleases keeps the explicit record of the ten releases that were actually checked, through 0.2.1-alpha.2. Diffing the 0.2.0-rc.1 and 0.2.1-alpha.2 packages showed ThemeRuntime and SidebarRightTabRegistry unchanged, so this build needs no new adapter folder — it joins the folder whose facts held.

Screenshots

Dual-lane wallpaper: two pictures, left and right
Dual-lane wallpaper · two different pictures of one rotation, split at the window's absolute centre, clear of the conversation column

Theme settings: the background page
The background page of the theme settings · source, folder picker, layout mode and wallpaper rotation

Theme colour and per-surface opacity
Theme colour and per-surface opacity · PS-style wheel, smart extraction, per-surface sliders

Floating rotate-now orb with countdown ring
Rotate-now orb · the host's own floating-button styling, draggable (right-click to reset), with a countdown ring on the minutes cadence

Features

  • PS-style Color Wheel — Pick hue on the ring, adjust saturation & lightness in the inscribed square. Generates 30+ CSS design tokens in real time.
  • Precise HSL / RGB Input — Enter exact color values numerically with instant bidirectional sync to the wheel.
  • Smart Color Extraction — One click derives a theme color from your wallpaper by sampling the visible region, quantizing, and filtering out gray / near-black / near-white pixels. Fully client-side.
  • Eyedropper — Hover the wallpaper to preview a color and click to pick it as the theme color.
  • Background Wallpaper — Upload any image as your wallpaper. Drag to pan and scroll to zoom inside a viewport-proportional editor (one finger to pan, two to pinch-zoom on touchscreens).
  • Position Editor — Drag to pan, scroll or pinch to zoom, one-click reset. Image placements are stored per slot and never overwrite each other.
  • Layout Modes — Fit / Fill / Stretch / Tile / Center; in Fit mode the editor-committed framing stays consistent across window resizes and cross-monitor moves.
  • Dual-Lane Rotation — Paint two different pictures of the same rotation at once, one down each side of the viewport, clear of the centre column. The wallpaper sits behind the whole app while the host's conversation column sits on top of the middle, so a picture whose subject is dead centre has that subject exactly where it cannot be seen; two lanes hug the edges instead. Both lanes come from one step of one rotation — the right name steps forward from the left, so the pair can never collide — and the pair is persisted, so it survives a reload. Off by default.
  • Generated Dynamic Backgrounds — Choose mesh gradient, Shader, or geometric patterns with adjustable spread, intensity, and seed locking.
  • Per-surface Interface Opacity — Independent sliders for the main background, sidebar, cards & panels (including the dropdowns and menus around the dialog), the input & controls (composer box, Cordis panel), the settings panel, the conversation text frame, the trajectory view, the right sidebar (or bettersidebar), produced / highlighted content, and the header popovers (Agent Team panel, background-job list and the session-header dropdowns).
  • Per-surface Interface Blur — Frosted-glass backdrop-filter blur (0–60 px) per surface, including a real backdrop on the composer, the Cordis panel and popover surfaces via stable host selectors.
  • Produced / Highlights — Code blocks in conversation content (with their language banner), inline code highlight chips and produced chips share one opacity + blur slider. The opacity is the alpha of each surface's own background color (no second color stacked on top of the original), and the blur frosts that same layer so the wallpaper shows through the content.
  • Right sidebar / bettersidebar surface — One slider pair (panelOpacity / blurs.panel), two identities: without dsh-better-sidebar it reads "右方侧边栏" (Right sidebar) and drives the official right Sidebar's surface tokens and frosted blur (works on 0.1.5-rc.2 through 0.1.7); with dsh-better-sidebar installed it reads "bettersidebar" and takes over that plugin's bottom workbench panel (the official sidebar keeps responding too). The row is always visible.
  • Header popovers — The session-header dropdowns get their own opacity + blur pair: the Agent Team panel, the background-job list, the open-in-app / session-log menus and the subagent lineage tree. On 0.1.7 the open-in-app picker moved to a portal and the session-row menu became a dynamic slot, so the plugin observes the stable conversation.session.header* slot anchors and tags the open popover at runtime instead of relying on class shapes.
  • Sidebar "Theme" page (dual mode) — The same five pages (Color / Interface / Font / Background / Profiles) register into two surfaces: without dsh-better-sidebar, a "Theme" card is contributed to the official right Sidebar's guide page through its public extension points (sidebarRightTabs + the sidebar.right.pane.tab keyed seat); with dsh-better-sidebar installed, the page registers in that plugin's sidebar instead and the official guide card withdraws itself, so the two never duplicate. Settings panel, official sidebar and better-sidebar all share one page implementation and one state store — a change in any of them shows up everywhere. The shell adapts to the panel width, and a narrow panel tightens padding and falls back to a single column. On a host without the right Sidebar the registration silently never happens.
  • Conversation View Cards — The message list is wrapped in a translucent card automatically, and the trajectory page gets whole-page opacity & blur controls, letting the wallpaper shine through the content.
  • Theme Export / Import — One-click export to a self-contained dsh-any-theme.json (config + wallpaper, embedded as data URLs) and import to restore it anywhere.
  • Appearance Presets & Profiles — Six one-click presets (Default / Frosted glass / Minimal / Midnight / Cyber / Warm daylight) plus named profiles: save the current look and re-apply it anytime. A two-step confirm guards deletion.
  • Wallpaper Rotation — Add images to a rotation pool (thumbnail picker included), or point it at an image folder; the wallpaper then changes by shuffle or order on every refresh, every N minutes (1–1440), daily or weekly. Advancing copies the chosen image into the active wallpaper slot, so export/import and color extraction keep working unchanged. With Dual-Lane Rotation on, one advance paints two different pictures of that same rotation, left and right.
  • Day/Night Auto Switch — Assign a day profile and a night profile; the plugin switches automatically at fixed clock times or by following the OS dark mode.
  • Custom Font — Upload a ttf / otf / woff / woff2 file (up to 100 MB) and apply it to the whole interface through @font-face; toggle it off or remove it at any time. Fonts stream as raw bytes and persist in the plugin data dir; code blocks keep their monospace stack.
  • Per-part Text Outline — The same surface groups as the interface page (now ten, including the header popovers), each with its own -webkit-text-stroke: width 0–4 px (0 = off) and a color of auto-contrast / gray / black / white / accent / custom. Code blocks, inline code, icons and the host's background-clip: text shimmer chrome (the "深度求索中" turn-status line and the turn-process rows) are exempted automatically, so multi-color syntax never smears and gradient text is never flattened into a stroke-coloured blob.
  • Forced Interface Scheme — Force light or dark token palettes regardless of the accent color's lightness; in Auto both the surface and font directions follow the accent's lightness (dark pick → light fonts, light pick → dark fonts), falling back to the wallpaper's perceived brightness when no color is picked.
  • File-based Persistence — All settings are stored on the filesystem under ~/.dsh/.dsh-any-background-data/, not localStorage.
  • Bilingual — Full Chinese / English UI with automatic locale detection.
  • Theme Watchdog — Re-asserts the custom theme if the host resets it.

Changelog (newest first)

v0.4.5 (The compatibility range stops being a hand-typed list)

  • The supported range is a range now, not nine hand-typed version strings. engines.dsh, all seven gated @deepseek-ai/dsh-* peerDependencies and dsh.compatibility.dsh were 0.1.5-rc.2 || 0.1.5-rc.3 || … || 0.2.0-rc.1 — an enumeration that had to be edited, rebuilt and re-released before the plugin could run on any host release that came after the last entry. They are >=0.1.5-rc.2 <0.3.0-0 now. The upper bound is deliberate: <0.3.0-0 admits every 0.2.x prerelease — the -0 suffix is what makes a prerelease boundary inclusive, so 0.3.0-alpha.1 is excluded while 0.2.1-alpha.9 is not — and it still stops before 0.3.0, where the host is expected to change shape again.
  • Why this was a real failure and not housekeeping: on DSH 0.2.1-alpha.2 the enumeration did not cover the running release, so evaluatePluginCompatibility returned an issue and loadProfileDirectory threw it into skippedBundles — silently, without printing and without touching the manifest. A later plugin-manager write ran reconcileProfilePlugins, whose retention rule keeps a dependency only when it is still classified as a bundle, and dsh-any-background was dropped from dsh.profile.bundles entirely. The symptom the user saw was the plugin simply not being there. Widening the range is what stops the first half from happening again; the second half is a host-side behaviour this plugin cannot influence, so the entry had to be restored in the profile manifest as well.
  • dsh.compatibility.dshReleases keeps its explicit list, and gained 0.2.1-alpha.2. That map is the human-readable record of which releases were actually checked, so it stays an enumeration — a range there would assert coverage nobody verified. The semver range is the machine gate; this map is the claim about what has been looked at.
  • Verified against the host's own semver, not against intuition: semver.satisfies from the host's bundled semver@7.8.5 was run with {includePrerelease: true} over the range — 0.1.5-rc.2 through 0.2.1-alpha.2 all satisfy it, 0.3.0-alpha.1 and 1.0.0 do not.
  • SUPPORTED_RELEASES gained a 0.2.1-alpha.2 row, so the adapter registry now reports exact for the host that is actually running instead of clamping to nearest and logging a clamp warning on every start. The row joins the 0.1.7-alpha folder because the 0.2.1-alpha.2 packages still ship ThemeRuntime and SidebarRightTabRegistry in the shape that folder records — a new row, not a new adapter folder. This is the adapter table, not the load gate: classifyRelease returns match: 'nearest' for anything it does not know, which is a log line, never a refusal.

v0.4.4 (The orb moves, and the orb is the host's own button)

  • The orb is draggable, and the position survives a reload: press and drag it anywhere; a pointer-capture drag past a 4 px slop threshold hands the movement to the button instead of the page, and releasing writes the resulting {x, y} to localStorage under dab-orb-pos — not to the plugin config. That split is deliberate: the position belongs to this window on this screen, not to the theme, and putting it in the config would drag the Node half into a purely presentational concern and make every drag need a host restart to take effect. The corner is the default, and it stays the default in the CSS — the stored position is only consulted when one exists, and right-clicking the orb deletes it and drops the button back to the corner rather than to a pinned approximation of it. A stored position is re-clamped after every resize (the button keeps its last known size in a ref, so the clamp has something to measure even on the first frame), which is what stops a position saved on a wide monitor from parking the orb off-screen on a narrow one.
  • The orb now is the host's floating button, rather than a lookalike: the style is transcribed from ChatView.module.css's own EvIC1a_toBottom scroll-to-bottom control — --dsw-alias-button-floating-fill with -hover behind it, --dsw-elevation-panel as the shadow with --dsw-elevation-stroke-color: var(--dsw-alias-border-l3) supplying its hairline, --dsw-alias-label-primary for the glyph, border-radius: 100px with corner-shape: round, 34px square. The old orb was a hand-picked dark rounded square; the new one is drawn from the same tokens the host's own floating controls resolve against, so it follows the theme colour, the light/dark switch and the elevation recipe without any of them being restated. Measured on the live page rather than assumed: computed border-radius: 100px, corner-shape: superellipse(1), cursor: grab, and a glyph colour that matches --dsw-alias-label-primary exactly.
  • A latent defect in the mount animation, found while restyling: @keyframes dab-orb-in was applied with animation-fill-mode: both, which keeps the last keyframe applied after the animation ends — so the final transform: none went on pinning the button's transform forever and neither :hover nor :active could scale it. The fill mode is backwards now, which is what you want from an entrance animation: it holds the starting frame before the animation begins and then releases the property completely.
  • The reduced-motion block could not reach the orb at all: it was scoped inside .dab-root, and the orb is portalled out of that subtree onto <body>, so the one element that animates on every rotation and repaints once a second was exempt from the preference. Its own selector is spelled out in that media block now.
  • Portal mounts to <body>, and this was a real bug rather than a hygiene fix: every --dsw-alias-* token is declared only on body / body[data-ds-dark-theme] (209 declaration sites, no :root, no html), so a portal host appended to document.documentElement — <body>'s sibling, not its ancestor — inherited none of them and fell back to nothing: the toast's background: var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-layer-2)) resolved to no value at all and the toast rendered fully transparent, its border black, its z-index: 10001 intact. Measured by putting the real .dab-toast class in a replica portal host under each parent: under <body> the same class computes background: rgba(92, 95, 69, 0.5) and border: 1px solid rgba(117, 119, 95, 0.22); under <html> it computes background: rgba(0, 0, 0, 0) with both token lookups empty. The orb was portalled the same way and was equally token-blind — the only reason it looked styled is that the default styling was rebuilt with hard-coded values in v0.4.3.
  • The tooltip teaches the gesture: the hover text is now `立即切换壁纸 · 还剩 1: