npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

dsh-plugin-personalization

v1.5.2

Published

Persistent personalization plugin for the dsh Web GUI: conversation/sidebar backgrounds (solid, image, looping video), true-transparency opacity, liquid-glass effects with adjustable panel border, global font and text color, custom logo, multi custom-font

Readme

dsh-plugin-personalization

A persistent personalization plugin for the dsh (DeepSeek Harness) Web GUI, built on the Cordis plugin system. It adds a Personalization tab to the Settings panel with four sections: conversation background, sidebar, settings panel and fonts (including a custom app logo).

AI-assistance disclosure: This plugin and its documentation were developed with the assistance of AI tools. Review the code before relying on it.

| | | |---|---| | npm | dsh-plugin-personalization | | Version | 1.5.2 | | Requires | dsh Web GUI (React 18 runtime) + a Cordis client-plugin roster row | | Storage | Browser localStorage, IndexedDB and CacheStorage (origin-scoped) | | Build | None — hand-written, zero-build client bundle |


Settings page layout

The Personalization tab is a plain vertical stack of four independent sections, each with a title, a short description and its own Reset section button:

  1. Conversation background — background type, its controls, position & zoom (image / video), opacity, conversation glass, preview.
  2. Sidebar — background type, its controls, position & zoom (image / video), opacity, sidebar glass, preview, Logo settings.
  3. Settings panel — settings-panel glass, border style, border width, border color.
  4. Fonts — font selection, custom fonts, text color, preview.

A global Restore defaults action sits at the bottom of the page.

Layout rules (why nothing overlaps)

The page is laid out with normal flow only — vertical stacks and one grid per field — so elements cannot be painted on top of each other:

  • One block per section: .pp-block is a column with a title row, a description, a reset button and a body; blocks are separated by 1 px rules and 28 px of space.
  • One row rule for every horizontal row: all row containers (.pp-fontRow, .pp-imageRow, .pp-videoRow, .pp-urlRow, .pp-colorRow, .pp-opacityRow, .pp-switchRow, .pp-fontLibHead, .pp-fontItem, .pp-actions) share display:flex; align-items:center; flex-wrap:wrap; min-width:0, so a narrow panel wraps instead of pushing controls out of view.
  • One grid per field: .pp-field is a single-column grid by default and switches to label left / control right only when the container is at least 460 px wide (CSS container query @container pp-page (min-width:460px)). At an unknown panel width the single-column form is always the safe one.
  • Design tokens: radius, control height and gaps come from --pp-radius, --pp-radius-lg, --pp-control-h, --pp-gap-row and --pp-gap-field, declared once on .pp-sec.
  • No negative margins and no absolute positioning inside the settings page. The only position: absolute rules in the whole stylesheet are the two application-layer media layers (.pp-videoLayer, .pp-bgLayer), the sidebar glass pseudo-element and the switch knob.
  • Shrinkable controls: text/HEX inputs are max-width: 100% with min-width bounds, long labels and previews use overflow-wrap: anywhere, and font/logo samples are block-level with ellipsis truncation instead of overflowing.
  • scripts/audit-css.cjs enforces the last three points mechanically.

Features

Backgrounds (conversation area and sidebar, independent)

  • Solid color — color picker plus numeric RGB inputs.
  • Default — follows the current theme wallpaper.
  • Custom image — local JPEG / PNG / WebP file, or an image URL (see Image upload).
  • Video — local MP4 / WebM / OGG, played muted, looped and inline, with no size limit enforced.
  • Independent opacity sliders (0–100 %) per area, implemented as true transparency: the background is painted by its own layer and the slider sets that layer's opacity, so intermediate values really show what is behind instead of fading toward a white/black overlay. 0 % keeps the layer in the DOM (invisible), 100 % is fully opaque. Text, message bubbles and input controls are never affected.
  • Position & zoom (image and video backgrounds, per area) — three sliders under Position & zoom: Zoom 50–500 % (steps of 1 %; up to 1500 % with Extended zoom), Horizontal and Vertical −100–100 %, plus a Reset position & zoom button. They drive transform: scale(var(--pp-…-scale)) translate(var(--pp-…-x), var(--pp-…-y)) with transform-origin: center on the same layer that paints the background, so image and video behave identically, the values survive switching the background type (only the sliders are hidden for solid / default), and zoom/pan never touches the opacity slider or the glass layer above it. Overflow is clipped by the host column's overflow: hidden, so layout, scrolling and hit-testing are unaffected. Each value is persisted in its own key — see Storage keys.
  • Extended zoom — a switch above the sections and a compact copy inside each Position & zoom group (next to the slider you are about to use). Off (default): the zoom sliders stop at 500 %. On: they reach 1500 %, and the badge next to the switch shows the current ceiling. Turning it off clamps any stored zoom above 500 % back to 500 % (in memory and in storage) so state and storage can never disagree.
  • Adjusting without occlusion — while you drag a Position & zoom slider (or a logo position slider) the settings dialog fades to 6 % opacity and its mask fades out, so you can watch the background move on the real conversation area; releasing the pointer restores it. Two layers make this reliable: body.pp-adjusting plus our stylesheet (with a 120 ms transition), and an inline-opacity fallback written straight onto the host's panel/mask elements, so it still works if a host rebuild renames those hashed classes. Only opacity is ever touched (never pointer-events), so the drag is never interrupted, and nothing is written to storage — it is pure UI state, released on pointer-up, pointer-cancel, window blur, or when the slider unmounts. The sliders deliberately carry no onBlur handler: pressing a second slider fires blur on the previously focused one after the new slider's pointerdown, so a blur-based release would cancel the fade the instant you moved from one slider to the next (that is exactly what used to make the X slider appear broken while the first slider you touched worked).

Liquid-glass (frosted) effects

  • Conversation area — frosted panel over the whole conversation view (header, message list and composer band alike), with a blur-strength slider (0–20 px). The glass is a decorative pseudo-element on the conversation column (body.pp-conv-glass-on .pI_x6G_centerCol::after, inset: 0, z-index: -1, pointer-events: none), so it never scrolls with the content and can never leave an uncovered strip at the bottom — see Why the conversation glass covers the whole view.
  • Sidebar — layered frosted glass that keeps the configured background (image or video) visible beneath it.
  • Settings panel — frosted container with a fixed 12 px backdrop blur, plus:
    • Border styleFollow theme, No border or Custom.
    • Border width — slider from 0 px (no border) to 10 px, default 1 px.
    • Border color — color picker + HEX input; empty means follow theme (white border on light, black on dark) and a Follow theme button clears the custom color again.
  • Glass is blur only: none of the three glass layers paints a semi-transparent white or black fill any more, so the background keeps its exact colour and brightness and is simply blurred. Browsers without backdrop-filter show no glass instead of an opaque panel.

Where backgrounds are painted (and why they survive a conversation switch)

Both backgrounds are painted by a CSS pseudo-element on the stable layout column, never by a node inside the conversation tree:

| Area | Painted on | Forced transparent | | --- | --- | --- | | Conversation | body.pp-conv-bg-on .pI_x6G_centerCol::before (fallback: body.pp-conv-bg-on-root .wSkVaW_root::before) | .wSkVaW_root | | Sidebar | body.pp-sidebar-bg-on .pI_x6G_sidebarCol::before | .hHd-Xa_root (the sidebar content root, which otherwise paints the opaque sidebar fill on top of the background) |

  • The layout columns belong to the app frame and are never rebuilt when you switch conversations; only their children are. A background that lives on a pseudo-element of that column therefore cannot disappear, and a background painted by a body-level modifier class does not depend on stylesheet order either (specificity (0,2,1) beats the host's (0,1,0)).
  • The values come from body CSS variables (--pp-conv-solid, --pp-conv-image, --pp-conv-opacity, --pp-sidebar-solid, --pp-sidebar-image, --pp-sidebar-opacity), written by the plugin's theme override layer — so theme switches, resets and restarts are all reflected in the same pseudo-element.
  • Opacity is applied to the pseudo-element itself (real transparency); 0 % keeps it invisible but present, 100 % is fully opaque.
  • Only the video paths still need a real element (<video> cannot be expressed as a pseudo-element); those layers are attached to the same stable columns, and a DOM observer re-attaches them if the host subtree is ever rebuilt.

Stacking contract (the settings panel must always stay on top)

The settings dialog is not rendered in a top-level layer: the host mounts it through the sidebar.settings slot, i.e. .VOzbGW_overlay (position: fixed; inset: 0; z-index: 1000) is a descendant of the sidebar column. Two consequences drive the stylesheet:

  1. The sidebar column must keep a stacking level above the conversation column. body.pp-sidebar-bg-on therefore sets position: relative; z-index: 10 on .pI_x6G_sidebarCol (the same elevation the sidebar subtree already had through .hHd-Xa_root { z-index: 10 }), and the conversation column stays at z-index: 0. If the sidebar column were ever demoted to z-index: 0/auto, the whole sidebar subtree — including the dialog — would sink below the conversation area, and the settings page would be covered, dimmed, unclickable and unable to scroll.

  2. Inside the sidebar subtree the order is fixed and one-directional:

    | Layer | Element | z-index | | --- | --- | --- | | Background | .pI_x6G_sidebarCol::before | -1, pointer-events: none | | Glass | .hHd-Xa_root::before | -1 (inside .hHd-Xa_root), pointer-events: none | | Sidebar content | .hHd-Xa_root | 10 | | Settings dialog | .VOzbGW_overlay / .VOzbGW_panel | 1000 / 1 (host styles, untouched) |

    In the same way the conversation column becomes a stacking context (body.pp-conv-glass-on … { position: relative; z-index: 0 }) whenever the conversation glass is on: its glass layer must be able to blur a backdrop that includes the conversation background layer, while everything inside it stays below the sidebar subtree so the settings dialog is never covered.

  • Both plugin layers are pure pseudo-elements: they have no DOM node, cannot intercept a click, and can never wrap, replace or unmount the sidebar content, the dialog or any of its controls.
  • The plugin's only rules that mention the host's settings classes touch background, backdrop-filter and the border of .VOzbGW_panel — never its z-index, position, display, visibility, opacity or pointer-events. scripts/client-smoke.cjs asserts this rule by rule.

Bottom fade masks (removed only for custom backgrounds)

The host draws two "fade the edge" gradients in theme fill colour (i.e. white in the light theme), and both look wrong on top of a custom background:

| Area | Host element | Host declaration | | --- | --- | --- | | Conversation | .wSkVaW_root[data-phase=active] [data-composer-seat] (aka .wSkVaW_composerSeat) | background: linear-gradient(180deg, transparent 0px, var(--dsw-alias-bg-base) 36px) | | Sidebar | span.qDHVXG_fade at the bottom of the session list | background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)), height: 24px |

The plugin removes them only while that area paints a custom background:

  • The gate is the same body modifier class the background itself uses (pp-conv-bg-on, pp-conv-bg-on-root, pp-sidebar-bg-on), so mask state and background state can never disagree — they are toggled in the same synchronous step, restored from storage on start-up and toggled live when you switch the background type. default (or image without an image) keeps the host's mask exactly as-is.
  • Overrides only delete background-image; display, position, height, z-index and pointer-events are untouched, so layout, scrolling and interaction are unaffected.
  • They win by specificity, not by !important (body.pp-conv-bg-on .wSkVaW_root[data-phase=active] [data-composer-seat] = (0,4,1) beats the host's (0,3,0); body.pp-sidebar-bg-on .pI_x6G_sidebarCol [class$="_fade"] beats .qDHVXG_fade), so they do not depend on stylesheet order. The test suite computes both specificities and asserts the ordering.
  • Each mask has two hooks — the host's hashed class plus a stable semantic one ([data-composer-seat], [class$="_fade"]) — so a rebuild that changes the hashes does not silently bring the white film back.

Why the conversation glass covers the whole view

The host splits the conversation view like this:

.wSkVaW_root                       (header + scroll body)
└─ .wSkVaW_scrollBody              (outer scroller)
   ├─ .Md3f7G_scroll               (message list — its own scroll container and padding)
   └─ .wSkVaW_composerSeat         (composer seat — a SIBLING, sticky, z-index 7)

Because the composer seat is a sibling of the message list, a backdrop-filter on the message list alone (the original implementation) leaves the seat band uncovered — and that band is exactly the "last message is above a strip that is not blurred" seam you see once the content is scrolled to the bottom (the white gradient that used to hide it is also removed for custom backgrounds).

The glass is therefore a pseudo-element of the conversation column, which spans the whole view:

| Layer | Element | Order | | --- | --- | --- | | Conversation background | .pI_x6G_centerCol::before | z-index: -1, painted first | | Glass | .pI_x6G_centerCol::after | z-index: -1, painted second (above the background) | | Content | header / messages / composer | painted above both, so text stays sharp and clickable |

  • inset: 0 on the column means header, message area and the composer band (including the seat's padding) are covered; the column's own overflow: hidden clips the layer to exactly that area.
  • The layer sits on a non-scrolling element, so scrolling can never move it or reveal a gap.
  • It is a pseudo-element, so unlike backdrop-filter on a real container it does not turn anything into the containing block of its position: fixed descendants (menus stay where the host anchors them).
  • Turning the switch on adds body.pp-conv-glass-on; turning it off removes the layer completely (no idle blur(0px) backdrop plane), and the strength slider only rewrites --pp-glass-blur.

Fonts and text color

  • Global font — Original (system default), Cascadia Code, Cascadia Mono, Segoe UI Variable, Aptos, Microsoft YaHei.
  • Multiple custom fonts — batch upload .ttf / .otf / .woff / .woff2 with no size or count limit enforced; newest first, per-font delete and clear-all (both confirmed); each font is injected lazily and only while selected.
  • Text color — color picker + HEX input (#RGB / #RRGGBB), a Follow theme button (highlighted while following, so it is obvious that the color tracks the theme), instant preview and validation with a friendly error for invalid values.
  • Code blocks and icon fonts are intentionally not affected by font or text-color changes.

Custom logo (expanded and collapsed sidebar, independent)

  • The top-left logo can be replaced with text (e.g. My DSH) or an image (png / jpg / svg / webp, ≤ 2 MB, file or URL).
  • Separate settings for the expanded and the collapsed sidebar; the plugin watches the sidebar state and swaps the logo automatically.
  • Each state has its own mode switch, input, preview and Reset logo button.
  • The original wordmark / rail icon is only hidden, never deleted, so resetting restores it exactly.
  • Position — each state also has Horizontal / Vertical sliders (−100–100 %, default 0 %) plus a Reset position button; the offset is applied as transform: translate(X%, Y%) on the injected logo, so the text/image mode, the collapsed detection and the existing click behaviour are untouched. The logo row's own overflow: hidden keeps the logo inside the sidebar, and the per-section reset (and Restore defaults) clears the position too.

Image upload

Every image upload point (conversation background, sidebar background and both logo states) offers exactly two ways to provide an image:

  1. Local file — the Choose file / Replace button opens the native file picker.
  2. From URL — the From URL button reveals a URL input; the image is fetched and embedded as a Base64 data URL. If the host blocks cross-origin reads (CORS) the plugin falls back to using the URL directly as the image source and says so; if the image cannot be loaded at all it reports "Failed to load the image; check the URL".

Not supported: clipboard paste (Ctrl+V) and drag & drop. They were removed on purpose and the plugin registers no paste / dragover / drop listeners, so Ctrl+V and file drops behave exactly as they do without the plugin.

Live, single-source UI state

  • Every control is controlled and reads from one store snapshot; a click updates that snapshot, persists it and notifies subscribers in the same event handler.
  • Because the appearance pipeline and the panel share that store, effect and control state change together, immediately — no need to close and reopen the settings panel.
  • Per-section resets only touch their own section; Restore defaults resets backgrounds, glass, font selection, text color and logos, keeps uploaded fonts, and keeps video data while switching the background type back to Default.

All settings survive dsh and browser restarts.

Update check (non-blocking, silent on failure)

Shortly after start-up the plugin compares its own version with the latest published one and, if the local copy is older, shows a dismissible notice at the top of the Personalization page:

Update available        current v1.4.1 → latest v1.4.2
dsh plugin --profile web update dsh-plugin-personalization
[Copy command] [Ignore this version] [Remind me later]
  • Where the local version comes from: the bundle is a zero-build classic script, so it cannot import package.json; the version lives in one constant (PLUGIN_VERSION, also exported as version) and the test suite fails if it ever drifts from package.json.
  • How the check runs: setTimeout(…, 1500) after activation, then checkForUpdate(); the fetch to https://registry.npmjs.org/dsh-plugin-personalization/latest uses Accept: application/vnd.npm.install-v1+json and an AbortController timeout of 5 s. Nothing is awaited during rendering, so start-up and first paint are never blocked.
  • Cache: the result is cached for 24 h in plugin.personalization.updateCheck ({latestVersion, checkedAt}); within that window the cached value is compared without touching the network.
  • Ignore / later: Ignore this version stores the version in plugin.personalization.ignoredUpdateVersion and stays quiet until a newer version appears; Remind me later hides the notice for the current session only (nothing is persisted).
  • Silent failure: offline, CORS, non-200 and malformed JSON all end in a single console.warn and no notice.
  • Reset-safe: Restore defaults leaves the update cache and the ignored version untouched.
  • The notice is an ordinary block inside the settings page (role="status", aria-live="polite") — it never overlays host UI and cannot block any interaction.

The same helpers are exported for scripts and tests: version, updateCommand, getLocalVersion(), fetchLatestVersion(), compareVersions(local, latest), checkForUpdate(), getCachedUpdateInfo()/setCachedUpdateInfo(), getIgnoredVersion()/setIgnoredVersion().


Installation

dsh plugin --profile web add dsh-plugin-personalization
dsh plugin --profile web add [email protected]   # pin a version
dsh plugin --profile web update dsh-plugin-personalization      # upgrade

Register the browser-roster row in the profile patch layer:

# ~/.dsh/profiles/web/cordis.patch.yml
- insert:
    - id: personalization
      name: 'dsh-plugin-personalization'

Then restart dsh, or hard-refresh the browser (Ctrl+F5 / Cmd+Shift+R) if your dsh hot-reloads cordis.patch.yml. Open Settings → Personalization.


Storage keys

| Key | Content | |---|---| | plugin.personalization.conversation | Conversation background {mode, color, image, opacity} (mode: solid / default / image / video) | | plugin.personalization.sessionBgOpacity | Conversation opacity (0–1 float) | | plugin.personalization.sessionBgScale / sessionBgX / sessionBgY | Conversation background zoom (0.5–5, or 0.5–15 with extended zoom; default 1) and position in % (−100–100, default 0) | | plugin.personalization.extendedZoomEnabled | Extended-zoom switch (default false → ceiling 500 %; true → 1500 %) | | plugin.personalization.sidebar | Sidebar background + opacity (0–100) | | plugin.personalization.sidebarBgScale / sidebarBgX / sidebarBgY | Sidebar background zoom and position (same ranges/defaults) | | plugin.personalization.logoExpandedX / logoExpandedY | Expanded-sidebar logo position in % (−100–100, default 0) | | plugin.personalization.logoCollapsedX / logoCollapsedY | Collapsed-sidebar logo position in % (same range/defaults) | | plugin.personalization.glassEnabled / glassIntensity | Conversation glass: toggle / strength | | plugin.personalization.sidebarGlassEnabled / sidebarGlassIntensity | Sidebar glass: toggle / strength | | plugin.personalization.settingsGlassEnabled | Settings-panel glass toggle | | plugin.personalization.settingsGlassBorder | Border style: theme / none / custom | | plugin.personalization.settingsBorderWidth | Border width in px (0–10) | | plugin.personalization.settingsBorderColor | Border color HEX; absent = follow theme | | plugin.personalization.fontFamily | Selected CSS font-family stack | | plugin.personalization.customFonts | v2 array of custom fonts {id, name, fontFamily, data, uploadTime, format} | | plugin.personalization.customFontData (legacy v1) | Single-font record; auto-migrated and removed | | plugin.personalization.fontColor | Text color HEX; absent = follow theme | | plugin.personalization.logoExpandedMode / …Text / …Image | Expanded-sidebar logo: text / image, its text, its image | | plugin.personalization.logoCollapsedMode / …Text / …Image | Collapsed-sidebar logo (same shape) | | plugin.personalization.videoBackground | Conversation video metadata {fileName, fileSize, mimeType, uploadTime} | | plugin.personalization.sidebarVideoBackground | Sidebar video metadata (same shape) | | plugin.personalization.updateCheck | Update-check cache {latestVersion, checkedAt} (24 h TTL) | | plugin.personalization.ignoredUpdateVersion | Version the user chose to ignore | | IndexedDB plugin.personalization.videos | Video blobs in sessionVideos / sidebarVideos |

Image values are stored either as a data: URL (local files, CORS-friendly links) or as an http(s) URL (links that block cross-origin reads).

About “no limits”: the plugin enforces no file-size or count limit; the browser quota still applies. When localStorage would overflow, font bytes are transparently moved to CacheStorage, and video payloads already live in IndexedDB. Without IndexedDB, small videos (≤ 3 MB) fall back to a localStorage data URL and larger ones are refused with a message; in private browsing or with exhausted quota, writes degrade to session-only with a console warning.


How it works

  1. Registration — an insert row in cordis.patch.yml adds the plugin to the Web profile (host row + browser roster row).
  2. Loadingdsh-client-modules scans the package's dsh.client metadata and injects lib/client.js into the window.__DSH_BOOT__ manifest; the browser fetches /plugins/dsh-plugin-personalization/client.js on demand. No frontend rebuild is needed — refresh the page after changing files.
  3. State — one store (createStore) is the single source of truth. Each mutation replaces the snapshot, bumps a monotonic version, persists, and notifies listeners; the panel subscribes through useStoreSnapshot (version-based useSyncExternalStore), so every change re-renders it. Listener failures are isolated, so one broken listener can never starve the others. Scalar keys are JSON-parsed tolerantly on read, which is what makes the font, text-color and border choices come back after a restart.
  4. Rendering — backgrounds and glass are applied through ctx.theme.overrideTokens() (inline custom properties on <body>); the font uses the --dsw-font-family token and the text color the --dsw-alias-label-primary token, so code blocks and icon fonts stay untouched.
  5. Background layers — solid and image backgrounds are painted by a pseudo-element on the stable layout column (::before, z-index: -1, opacity from the slider) driven by body-level modifier classes and variables; video backgrounds use a real <video> layer attached to the same column. The content root of each area is forced transparent, so the layer paints above the area background and below its content and glass layer, and only the layer's opacity changes with the slider. Nothing is ever injected into the conversation tree, which is what makes a background survive a conversation switch.
  6. Logo layer — the plugin locates the sidebar logo row (.hHd-Xa_logoRow or any *_logoRow class), detects the collapsed state from the *_collapsed ancestor class (falling back to the presence of the expanded-only brand button), hides the original icon via a body modifier class and injects its own element into the same button, so the existing click behaviour (new session / expand sidebar) is preserved.
  7. Self-echo suppressionoverrideTokens() synchronously emits theme/change; the plugin ignores that echo while writing its own layer (otherwise it would recurse), but still rebuilds the appearance on genuine theme switches.
  8. Lazy and cached — video blobs are read from IndexedDB only while that area is in video mode; one URL.createObjectURL per video (cached in a Map), revoked on replace, removal, mode switch and plugin dispose.

Verification checklist

  • Backgrounds: solid / default / image / video apply instantly in each area; the opacity slider fades the background smoothly (50 % shows a genuinely translucent background, 0 % hides it, 100 % is opaque) while text stays fully readable.
  • Position & zoom: with an image or video background, the Position & zoom group appears (and disappears again for solid / default); dragging Zoom resizes the picture live (50 %–500 %, or up to 1500 % with Extended zoom), dragging Horizontal / Vertical pans it, the numbers next to the sliders follow the drag, Reset position & zoom returns to 100 % / 0 % / 0 %, and the values come back after a restart and after switching the background type away and back.
  • Adjusting comfort: while you drag a zoom / pan slider (or a logo position slider) the settings dialog fades almost completely away and its mask clears, so you see the background change unobstructed on the real conversation area; the drag keeps working and everything returns the moment you release the pointer.
  • Extended zoom: switch off → the zoom slider stops at 500 %; switch on → it reaches 1500 % in both areas (the badge shows the active ceiling); switch off again while at 1200 % → the value is clamped to 500 % immediately and stored that way; after a restart both the switch and the value are as you left them.
  • Logo position: each logo state has its own Horizontal / Vertical sliders; the logo moves live inside the top-left area, expanded and collapsed keep independent offsets, Reset position (and Reset Logo) returns them to 0 %, the mode / text / image settings are untouched, and the offsets survive a restart.
  • Nothing is auto-cropped: import an image whose aspect ratio differs from the area (e.g. a tall portrait picture into the wide conversation area) — the whole picture is visible at 100 %, with themed/solid background filling the leftover space instead of the edges being cut off. Zooming in past 100 % lets the picture overflow, and only then does the host's overflow: hidden clip the overflow (that is your zoom, not auto-cropping); pan with Horizontal / Vertical to bring any edge back into view. Videos behave the same way.
  • Conversation switch: set a conversation background (solid, image or video), then switch between conversations — the background stays exactly as it is, without refreshing and without reopening the settings panel.
  • Sidebar background: set a sidebar background (solid, image or video) — it is immediately visible behind the sidebar content (the sidebar's own opaque fill no longer covers it), and the opacity slider fades it to a real translucency.
  • Settings dialog stays usable: with a sidebar background set (and optionally sidebar glass), the settings dialog keeps opening, staying visible, scrollable and clickable, and every control keeps working; switching the sidebar background type between solid / image / video does not close, cover or freeze it.
  • Bottom fade masks: with the conversation or sidebar background left on Default the host's bottom gradient stays exactly as before; as soon as that area uses a solid colour, an image or a video, the corresponding white fade disappears (and comes back when you switch to Default again) — in both the conversation area and the sidebar session list.
  • Glass: toggles and strength react immediately; the blurred area keeps the exact colour and brightness of the background (no white or black film over it); the whole conversation view is covered — scroll a long conversation to the very bottom and there is no unblurred strip above the composer; video keeps playing underneath; the panel border width / color / style respond live.
  • Fonts: the dropdown shows Built-in fonts and My fonts; switching restyles all UI text while code blocks stay unchanged; uploading and deleting custom fonts updates the list at once.
  • Text color: picker and HEX input stay in sync; invalid HEX is rejected with an error; the Follow theme button clears the custom color and turns highlighted.
  • Logo: text and image modes both apply instantly; expanded and collapsed logos are independent; resetting restores the original wordmark.
  • Images: local file and From URL both work for backgrounds and logos; an unreachable URL reports an error and changes nothing; a CORS-blocked but displayable URL falls back to the URL itself.
  • Paste / drag: Ctrl+V and dropping a file do nothing (and never interfere with normal text pasting).
  • Persistence: restart dsh and the browser — every setting is still applied.
  • Real-time UI: every control reflects its new state immediately after a click, without closing and reopening the settings panel.
  • Layout: with the settings panel made very narrow, every row wraps instead of overflowing, labels wrap instead of colliding with their controls, and no control covers another one; widening the panel switches fields to label-left / control-right.

Changelog

1.3.0 and 1.3.1 were never published; 1.3.2 is the first release containing everything below since 1.2.2.

  • 1.5.1Update check. After start-up the plugin compares its own version with the latest one on the npm registry (deferred by setTimeout, 5 s AbortController timeout, 24 h cache in plugin.personalization.updateCheck) and, when the local copy is older, shows a dismissible notice at the top of the Personalization page with the two versions, the update command, Copy command (clipboard) plus Ignore this version (plugin.personalization.ignoredUpdateVersion) and Remind me later (session only). Every failure path — offline, CORS, non-200, malformed JSON, no clipboard API — is silent apart from a single console.warn, start-up is never blocked, and Restore defaults keeps the cache and the ignored version. The local version is a single constant guarded against package.json by the test suite, and the helpers (getLocalVersion, fetchLatestVersion, compareVersions, checkForUpdate, cache/ignore accessors) are exported. (1.4.2 and 1.5.0 were never published; this is the first release since 1.4.1.)

  • 1.5.2 — Drag-to-hide adjusting (and the fix that made it reliable), logo position, the extended-zoom switch, and non-cropping backgrounds. Nothing was published between 1.5.1 and 1.5.2, so everything below landed in this release.

    • Fixed: dragging the horizontal-position slider did not hide the dialog.

    • Root cause. Each adjustment slider had an onBlur handler that released the fade. With a real mouse, pressing a second slider emits pointerdown/mousedown on the new slider and then blur on the previously focused slider — so the panel was hidden and immediately restored again. The first slider you touched worked; every slider you moved to afterwards (typically Horizontal) silently failed. Found by driving the live GUI with headless Chrome over CDP and pressing each slider in sequence.

    • Fix. Removed the onBlur/onFocusOut release path entirely; the fade is now released only by the slider's own pointerup/mouseup/pointercancel, by the window-level pointerup/pointercancel/mouseup (releasing outside the slider), by window blur (the browser window losing focus) and on unmount. Regression tests cover the exact sequence that used to fail, and a real-browser pass now reports 6/6 sliders hiding on press and restoring on release (conversation and sidebar, zoom + X + Y), plus release-outside-the-slider.

    • Drag-to-hide hardening and a nearby extended-zoom switch.

      • The drag-to-hide behaviour now has a DOM-level fallback. body.pp-adjusting plus the stylesheet (with the 120 ms transition) stays the primary path, but the plugin also writes an inline opacity onto the host's _panel / _mask elements inside the settings overlay, so the dialog still fades out even if a host rebuild renames those hashed classes; the inline values are cleared again on release so the stylesheet stays authoritative. Verified in a real browser (CDP): panel 1 → 0.06 → 1, mask 1 → 0 → 1, slider stays usable throughout.
      • The extended-zoom switch is now also rendered inside each Position & zoom group (next to the zoom slider, i.e. where you look for it), sharing the same state as the switch at the top of the page. (Reported as "the switch cannot be found".)
    • Dragging a zoom / pan slider now hides the settings dialog. Pressing a Position & zoom slider (or a logo position slider) adds body.pp-adjusting: the dialog fades to opacity: 0.06 and its mask fades to 0 (both with a 120 ms transition), so the background can be judged unobstructed on the real conversation area; releasing the pointer — or pointercancel, window blur, or unmounting the slider — restores it. Only opacity is touched, never pointer-events, so the drag is never interrupted; the state is pure UI (no store writes) and is depth-counted so overlapping sliders cannot leave the panel stuck.

    • Logo position per state. The expanded and collapsed logos each gained Horizontal / Vertical sliders (−100–100 %, default 0 %, transform: translate(X%, Y%) on the injected element) plus a Reset position button, backed by four new keys (logoExpandedX / logoExpandedY / logoCollapsedX / logoCollapsedY). The offset is part of the logo signature, so it is re-applied whenever the sidebar switches state; changing the text or image keeps the position, the logo row's overflow: hidden keeps it inside the sidebar, and both Reset Logo and Restore defaults clear it.

    • Extended-zoom switch (plugin.personalization.extendedZoomEnabled, default off). Off: the zoom sliders stop at 500 %. On: both areas' sliders reach 1500 % and the switch badge shows the active ceiling. Turning the switch off clamps any stored zoom above 500 % back to 500 % in memory and in storage, and readBgScale applies the same ceiling on start-up, so a value can never be displayed and stored inconsistently. The slider maximum, its displayed value and the transform all follow the switch live.

    • No auto-cropping. Image backgrounds were painted with background-size: cover, which scales the picture until it fills the area and cuts off whatever does not fit — a portrait photo in the wide conversation area lost its top and bottom. Both background pseudo-elements (conversation and sidebar) now use background-size: contain, and the video layers switched from object-fit: cover to object-fit: contain, so an image or video is always shown in full at 100 %, at its original aspect ratio, centred, with the solid colour / theme base filling the leftover space (letterbox / pillarbox). The settings-page preview string and the upload thumbnail (.pp-thumb) use contain too, so what you preview is what you get. Zoom and pan are unaffected: the transform still runs on exactly the same layers, so zooming past 100 % expands the picture and the host column's overflow: hidden then clips the overflow — that is user-driven zoom, not automatic cropping — and panning brings any edge back into view. Nothing about opacity, glass, layout or persistence changed.

    • Zoom +500 % (the base ceiling; 1500 % with Extended zoom). The zoom slider range became 50 %–500 % (MAX_BG_SCALE 25; the slider's max 200500, step still 1 %, default still 100 %) for both the conversation and the sidebar background, since both share one control component and one stored value. Stored values are read through the same clamping reader, so existing settings (≤ 200 %) load unchanged, anything above the ceiling is clamped, and 500 % itself round-trips through storage and survives a restart.

    • Background position & zoom. Both areas gained a Position & zoom group (zoom, horizontal and vertical −100–100 %, and a reset button), shown only for image and video backgrounds. The three values are stored in six dedicated keys (sessionBgScale / sessionBgX / sessionBgY and the sidebarBg* twins), clamped and rounded on write, tolerant of junk in storage on read, kept when the background type changes, reset by the section reset buttons (and by Restore defaults), and applied through transform: scale() translate() on the very layer that paints the background — the conversation background pseudo-element and the sidebar one, plus the two <video> layers — so images and videos behave identically. The transform is gated by body.pp-conv-media-on / body.pp-sidebar-media-on, so a solid colour or the default background is never resized (which would leave gaps), and it leaves opacity, glass and layout untouched.

  • 1.4.1 — Conversation glass coverage, bottom fade masks, three background/glass bug fixes, a settings-dialog stacking fix, plus settings-page layout hardening. (1.3.3 and 1.4.0 were never published; this is the first release since 1.3.2.)

    • The conversation glass now covers the whole conversation view. It used to be backdrop-filter on the message-list scroll container (.Md3f7G_scroll), but the composer seat (.wSkVaW_composerSeat) is a sibling of that container inside .wSkVaW_scrollBody — so the seat band (the strip between the last message and the input card) was never blurred; scrolling to the bottom made that seam obvious, especially after the white gradient there was removed for custom backgrounds. The glass is now a decorative pseudo-element of the conversation column (body.pp-conv-glass-on .pI_x6G_centerCol::after, inset: 0, z-index: -1, pointer-events: none) painted above the background layer and below the content, so header, message area and composer band are all covered, nothing scrolls with the content, and no real container becomes the containing block of position: fixed descendants.
    • The bottom white fade is removed whenever a custom background is active. The host fades the conversation page bottom (.wSkVaW_root[data-phase=active] [data-composer-seat], 36 px to --dsw-alias-bg-base) and the sidebar session list bottom (span.qDHVXG_fade, 24 px to --dsw-specific-sidebar-fill); over a solid colour, an image or a video those white gradients looked like a film over the background. Both are now switched off by the same body modifier class that enables the custom background (pp-conv-bg-on / pp-conv-bg-on-root / pp-sidebar-bg-on), so they follow the background type live and after a restart, and stay untouched on Default. The overrides only clear background-image and win by specificity, and each hashed host class is paired with a stable semantic hook.
    • Settings dialog no longer gets covered by the sidebar background. The host renders the dialog (.VOzbGW_overlay, position: fixed; z-index: 1000) inside the sidebar subtree through the sidebar.settings slot. Giving the sidebar column a z-index: 0 stacking context (needed so the background pseudo-element paints behind the sidebar content) therefore trapped the dialog inside that context, where the conversation column — also z-index: 0 and later in DOM order — painted on top of it: the settings page appeared covered, dimmed and unclickable until it was reopened or the page refreshed. The sidebar column now keeps the elevation the subtree already had (z-index: 10), the conversation column stays at 0, and the layering is asserted rule by rule in the test suite.
    • Conversation background no longer disappears when switching conversations. The background used to be a <div> injected into the conversation root, which belongs to the per-conversation React subtree that is rebuilt on every switch, so the layer was recycled with the old nodes (it reappeared only after the next store/theme event — reopening the settings panel or refreshing). Solid and image backgrounds are now painted by a pseudo-element on the stable app-frame column (.pI_x6G_centerCol::before, with .wSkVaW_root::before as a fallback when the host class changes), driven by a body modifier class and body-level variables, and the conversation root is forced transparent by a body.pp-conv-bg-on … rule whose specificity beats the host's own rule. Video layers moved to the same stable column, and a DOM observer re-attaches a layer if the host subtree is ever rebuilt.
    • Sidebar background now works. It was only ever applied through a same-specificity CSS rule on the sidebar content root, while that same root paints the opaque sidebar fill — with equal specificity it could lose the cascade entirely, and even when it won, the fill was painted above the injected video/background layer. The background is now painted on the sidebar layout column's pseudo-element and the content root is forced transparent, so solid, image and video sidebar backgrounds are visible and fade with real transparency (the old white/black veil is gone here too).
    • Glass is blur-only. All three glass layers no longer paint a semi-transparent fill (rgba(255,255,255,0.5) for the conversation, rgba(255,255,255,0.6) for the sidebar, rgba(255,255,255,0.35) for the settings panel) — that fill was the "pale white film" that made backgrounds look washed out. Only backdrop-filter: blur(<strength>) remains.
    • Settings-page layout hardening. Removed 19 stale duplicate CSS declarations left over from the previous layout (.pp-switch, .pp-switchOn, .pp-danger, .pp-error, .pp-thumb, .pp-preview, .pp-range, .pp-actions, .pp-glass, .pp-modes, .pp-opacityLabel, .pp-opacityValue and the four per-row rules) so every selector has exactly one declaration, merged the two .pp-sec rules into one, gave every field a defined label wrapper, and moved the image-URL picker onto its own line in the logo sections as well — the From URL button and its Apply / Cancel buttons no longer get squeezed into the file-button row.
    • Added scripts/audit-css.cjs (duplicate / dead / missing classes, absolute-positioning audit) and regression assertions for all of the above (202 → 232 checks).
  • 1.3.2

    • Settings page rebuilt as four independent sections (conversation background, sidebar, settings panel, fonts), each with its own Reset section button and a cleaner field layout. An experimental layout switcher was added during development and removed again before release.
    • Custom logo for the expanded and collapsed sidebar, in text or image mode, with previews and per-state reset.
    • Settings-panel border controls: style preset (follow theme / none / custom), width slider (0–10 px) and color picker with a follow-theme default.
    • Text color now has a real Follow theme toggle button (it had been rendered as a text input placeholder and could not be clicked).
    • Conversation background transparency fixed: opacity is applied to a dedicated background layer instead of a white/black veil, so values below 100 % are genuinely translucent rather than covering the background.
    • Image upload unified across all upload points with an image-URL option (with CORS fallback to using the URL directly). Clipboard paste and drag & drop were implemented, then removed in this release: only local file and URL remain.
    • Fixed scalar settings (font family, text color, border style/width/color) not being restored after a restart because JSON-quoted localStorage values were read as raw strings.
  • 1.2.2 — Fixed the settings panel not reflecting state changes until it was closed and reopened. ctx.theme.overrideTokens() emits theme/change synchronously and the plugin's own handler re-entered the appearance pipeline, recursing until the stack overflowed inside the first store listener; that exception aborted the notification loop, so React's subscriber never ran while the styles were already applied. The echo is now suppressed during our own override writes, listener failures are isolated, the panel subscribes via a monotonic version counter, and the appearance pipeline can no longer throw out of the notification loop. Regression tests added: scripts/diagnose-reactivity.cjs and the real-time UI assertions in scripts/client-smoke.cjs.

  • 1.2.1 — Bilingual README, documentation for video backgrounds.

  • 1.1.3 — English README, AI-assistance disclosure.

  • 1.1.1 / 1.1.0 — Multiple custom fonts (v1 → v2 automatic migration).

  • 1.0.0 — Initial release: backgrounds, opacity, liquid-glass effects.


Development and publishing

node --check lib/client.js            # syntax gate
node scripts/audit-css.cjs            # CSS audit: duplicate / dead / missing classes, overlap risks
node scripts/client-smoke.cjs         # headless smoke test (sandboxed DOM / IndexedDB / React)
node scripts/diagnose-reactivity.cjs  # UI reactivity regression (real theme-service semantics)
npm version patch                     # or minor / major
npm pack --dry-run                    # inspect the tarball
npm publish

After changing lib/client.js, reload the page (Ctrl+R). The plugin is a client bundle: the browser keeps the version it loaded in memory, so a page that was already open before your edit keeps running the old code — a missing new button is almost always a stale page tab, not a missing feature. To confirm which build a tab is running, open DevTools → Network and reload: client.js?rev=… must be a fresh request (the server sends cache-control: no-cache).

scripts/ is not published: the files allowlist ships only lib/index.js, lib/client.js and lib/types/index.d.ts (plus README.md and package.json).


Known limitations

  • Class-name coupling: background / video / glass / logo rules target dsh layout class names (.wSkVaW_root, .Md3f7G_scroll, .pI_x6G_sidebarCol, .hHd-Xa_root, .VOzbGW_panel, *_logoRow, *_brand, *_toggle). If a future dsh build renames them, those visual effects stop applying — the settings UI keeps working and dsh itself is unaffected.
  • Service dependencies: the theme, slots and locale services are required (provided by dsh's built-in UI rows).
  • Fonts: remote loading requires network access and happens at most once per font, only when that built-in font is selected and not installed locally. Segoe UI Variable, Aptos and Microsoft YaHei depend on the OS having them installed; otherwise the CSS stack falls back to generic fonts.
  • Images from URLs: cross-origin hosts without permissive CORS headers cannot be embedded as Base64; the plugin then uses the URL directly, which requires that the browser can load it (a restrictive Content-Security-Policy or offline network will show a broken image).
  • Storage: “no limits” means the plugin enforces none; the browser quota still applies, and private browsing degrades writes to session-only.
  • Large video memory: decoding several very large videos at once (conversation + sidebar) can be memory-hungry; the plugin keeps only one layer per area and releases URLs when they are replaced or removed.

To report a bug, please email [email protected]