Skip to content

dsh-plugin-epub-reader

Verified

dsh-plugin-epub-reader Β· v1.0.0 Β· MIT Β· Web UI

EPUB reader with a peek-on-hover panel plus an agent-activity simulator for the DSH Web GUI.

Install

dsh plugin add dsh-plugin-epub-reader

Confirm the layer applied with dsh --profile default --dump-config β€” see the install guide.

Source

Tags

Creators

Readme

dsh-plugin-epub-reader

A DeepSeek Harness Web GUI plugin that reveals one surface made of two halves, laid out inside the chat column:

  • the EPUB reader on the right β€” ←/β†’ or the book button under the pointer turn pages, font size and line spacing are adjustable, every colour comes from the app's theme tokens, and reopening resumes the exact page you stopped on;
  • a simulated conversation filling the rest of the column β€” it keeps emitting DSH-looking rows (a reasoning line, tool calls with their results, prose) and smooth-scrolls as it grows, so the middle of the app reads as an agent at work.

The whole thing is a glance, not a mode. Point at the small book button above the composer and both halves appear; move the pointer away and both vanish immediately. There is no switch to turn and nothing to pin.

   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ chat column ───────────────┬──────────────────┐
   β”‚  thinking Β· tool calls Β· assistant prose  β”‚   EPUB reader    β”‚
   β”‚  (rows styled exactly like the app's own) β”‚   第 3/12 ι‘΅      β”‚
   β”‚                                           β”‚                  β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                [πŸ“–] [β‡ͺ]   ← the dock row above the composer
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ composer ───────────────────────────────┐

Both halves are laid out inside the chat column rather than floating over the window: same background, same message column and row rhythm, no card chrome. The simulator stops where the reader begins, so neither ever covers the other.

Install

From npm:

dsh plugin add dsh-plugin-epub-reader

From a checkout of this repository β€” install.mjs symlinks the working copy into a profile instead, which is what you want while editing the sources:

node build.mjs          # writes client.js from src/
node install.mjs        # symlink + profile patch
node install.mjs status # what is installed where
node install.mjs uninstall

install.mjs does exactly two things, both reversible:

  • symlinks this directory to <profile>/node_modules/dsh-plugin-epub-reader, which is what DSH's package resolution looks for, and
  • appends one insert row to <profile>/cordis.patch.yml, between markers it owns.

It defaults to the profile named by $DSH_PROFILE (or web); pass --profile <name> otherwise. A running dsh watches cordis.patch.yml and hot-loads the change β€” no restart, and often no page reload. If you would rather the package became a real profile dependency (with a lockfile entry and an entry in dsh.profile.bundles), use dsh plugin --profile web add <this directory> instead of node install.mjs.

Use

Gesture Effect
Point at the book button above the composer Reveal the reader and the simulated conversation
Move the pointer out of that surface Hide both at once
← / β†’, PageUp / PageDown Turn one page; crosses chapters at the ends
Left-click the book button Turn to the next page
Right-click the book button Turn to the previous page; the browser's menu is suppressed
Esc Hide the surface
Drag an .epub onto the reader Import it (books are kept in a local library)
β‡ͺ beside the book button Choose an EPUB file

The book button carries no tooltip. The reader appears under the pointer, so a native tip would hang over the page being read; the button still names itself β€” and the open book β€” to assistive technology through its aria-label.

The panel's top bar is the tool row and nothing else: no title, chapter name or page number is repeated above the text. The page number and section count live in the footer, with the two page buttons.

Inside the panel: the first header button opens the book's table of contents (from the EPUB 3 nav document, falling back to the NCX), the second opens the typography popover (font size, line height, panel width), the third opens the local library, the fourth closes.

Reading preferences and the reading position are remembered in localStorage; the book itself is stored in IndexedDB. The panel unmounts whenever the pointer leaves, so the position is restored on every reveal: the chapter is read from storage into the component's initial state, and the page is applied only once that chapter is the one actually laid out. (Restoring through an effect instead would render and measure the wrong chapter first, and a one-page measurement would clamp the remembered page away before the right chapter arrived.)

Who owns the page turn

The composer usually still holds focus when the reader is revealed, and its own arrow handling would swallow every page turn. So the claim is tied to the pointer, not only to focus:

  • pointer on the revealed surface (the book button, the panel, the simulator) β†’ the book turns pages, even while the composer holds focus;
  • pointer anywhere else β†’ the composer keeps its caret movement, so typing and text editing are never hijacked;
  • the panel's own sliders keep their arrow keys either way.

The mouse needs no such test, because the gesture names its target: left-click and right-click on the book button are forward and back. The button lives in the composer dock and the reading position lives in the panel β€” two React trees that cannot share props β€” so the click travels over readerBridge.turnPage, the same hand-off the file picker uses. Two details make that honest rather than approximate:

  • The reader answers "not ready" until a chapter is actually laid out. The first measurement is what turns a column box into a page count; before it the count is 1, and a turn against it would skip a whole chapter instead of one page. So the dock retries on animation frames for about a second instead of dropping the click β€” which is what makes the impatient click, the one that lands while the panel is still being revealed, still turn a page.
  • Putting the surface away abandons a click the reader never answered. The retry stops as soon as the pointer leaves the region, so a stale gesture can never turn a page on the next reveal.

The simulated conversation

It has no controls: it runs exactly while the reader is revealed, and its script is the tail of the current session's own conversation followed by endless invented work. So it starts from where the conversation actually is and then looks like the agent kept going; nothing loops back to the beginning, so the illusion never visibly restarts. A session with no conversation to draw on gets the invented work from the start.

Pacing is deliberate. A row can sit as a bare, sweeping "思考" line for a second or two before a single character appears, typing runs at a readable speed, tool calls spend real time running, and there are pauses between rows and between consecutive tool calls. The feed is meant to look busy, not to flicker.

Matching the app's own rows

The rows are not approximations: every measurement is copied from the shipped CSS modules, so a simulated row is indistinguishable from a real one at a glance.

Row Mirrored from
Chat column, row rhythm, scroller padding ui-chat/src/client/chat/ChatView.module.css (.scroll, .column)
User bubble (radius 22, --dsw-specific-bubble, 0.702 Γ— column cap) ui-chat/src/client/chat/MessageItem.module.css (.userRow, .userStack, .bubble)
Assistant prose (24px + font delta leading) ui-chat/src/client/chat/AssistantMarkdown.module.css (.root, .body)
Reasoning line (24px row, leading glyph, dot, ellipsised summary, running sweep) ui-chat/src/client/chat/ReasoningRow.module.css
Tool line and its result card ui-tool/src/client/tool/components/ToolRow.module.css
The shared 24px disclosure geometry ui-primitives/src/DisclosureRow.module.css

The reasoning line's sweeping 300px gradient, and the shimmer that travels across a running row's title, are the app's own "still thinking" cues, reproduced exactly.

Version note. The installed runtime (0.1.7-rc.2) and the DSH source checkout on this machine (0.1.7-alpha.1) disagree: rc.2 replaced several literals with radius tokens, so the user bubble is var(--dsw-radius-xl) (20px) rather than the source's 22px, and the tool result card is var(--dsw-radius-lg) (16px) rather than 12px. The values here follow the installed bundle, verified by reading its embedded CSS strings. Re-check against dsh-client-ui-chat/lib/client.js and dsh-client-ui-tool/lib/client.js after a DSH upgrade.

Architecture

src/00-util.js       path/string helpers
src/10-store.js      the shared observable store, the peek-region registry,
                     and the file-picker hand-off between the two halves
src/20-zip.js        ZIP central-directory reader; deflate via DecompressionStream
src/30-epub.js       container.xml -> OPF -> spine/nav, chapter sanitisation
src/40-styles.js     the whole stylesheet, themed from --dsw-* / --dsh-* tokens
src/45-dom.js        overlay-layer and conversation-area geometry
src/50-storage.js    IndexedDB book library and localStorage reading positions
src/55-icons.js      inline SVG icons
src/60-reader.js     the reader: geometry, header, pagination, popovers
src/70-dock.js       the book button β€” reveal, and the mouse page turns β€” plus
                     the file input
src/80-activity.js   the simulated conversation: script, pacing, native rows
src/90-plugin.js     apply(): locales, styles, pointer guard, slot registrations

Why client.js is generated by concatenation

The browser plugin format is a single self-contained script that registers one lazy factory:

window.__ModuleLoader__.load({ id, factory(require) { … } })

require() inside a factory resolves only against the platform seed table (react, react-dom, @deepseek-ai/cordis, the client store/slots/primitives packages) and compiler-generated chunks β€” not against relative paths. So build.mjs concatenates the numbered sources into one factory body instead of bundling them, and the sources share a single function scope. Every source is plain JavaScript; there is no build toolchain, no dependency, and no bundler.

Why the two halves talk through a store

The book button lives in conversation.input.dock (session scope) and the two surfaces live in shell.overlay (root scope). Those are different React trees with different props, so they share a plugin-local observable (useStore, built on useSyncExternalStore) plus the element references that define the peek region. peek is the only visibility input β€” there is no pin and no on/off switch, because the two surfaces are one thing that comes and goes with the pointer. The hidden <input type="file"> lives in the dock, which never unmounts, because an unmounted input cannot deliver the file an OS dialog returns. readerBridge is the second half of that hand-off and runs the other way: the panel publishes its page turner there while it is mounted, so the dock button can turn a page without knowing anything about the reader.

Where the surface is, and why it never breaks apart

shell.overlay is root-scoped, so nothing in it knows where the chat column or the dock row are; both surfaces measure them live. The reader takes its top and right edges from the chat column and its bottom edge from the dock row's top, and the simulator fills whatever is left of the column, stopping 8px short of the reader. The reader's width is clamped so the simulator always keeps at least 240px, which is what makes "never covers the other" true by construction rather than by tuning.

That also makes the region connected on any window size. Both surfaces reach down to the dock row, and together they span the whole column, so the row is inside the column's horizontal extent: from the button the pointer can go straight up into whichever surface is above it without ever crossing a gap. The region test allows 6px of slack at every edge, so the 8px gap between the two surfaces is not a hole.

Hiding is driven by one passive pointermove listener on document that tests the pointer against the row's and the two surfaces' rects. That is what makes "leave and it disappears at once" exact: none of the three elements can see another's pointerleave. The same listener records the pointer position, which is what the reader's keyboard claim is tied to.

Pagination

The chapter's sanitised HTML is written into a CSS multi-column box whose column-width is set to the viewport's content width, so the browser fragments the chapter into a horizontal ribbon of viewport-wide columns. The box is translated by page Γ— (columnWidth + columnGap) inside an overflow: hidden viewport. The page count comes from a zero-height sentinel appended after the content: the column it lands in is the last column, which is exact and does not depend on how scrollWidth treats overflowing columns. The measured column pitch then replaces the requested one, so a sub-pixel difference cannot compound across pages.

The book's own layout

A reflowable EPUB ships its own CSS, and that CSS is the book's layout, so the reader keeps it β€” scoped to its own wrapper, so neither side can restyle the other. The settings popover chooses how much of it applies:

Layout What it does
Reflow (default) Keeps the book's typography; drops what was written for a printed page; rejoins text a conversion hard-wrapped
As written Renders the book exactly as its stylesheet says, page-sized widths and fixed line breaks included
Reader Ignores the book's stylesheet and uses the reader's own typography β€” the escape hatch when a book lays itself out badly

Why Reflow uses an allowlist

A book's stylesheet is untrusted input written for a fixed page, and it can say anything: a page-sized width, a full-height box, a paper-white background, a display: flex on the body, a negative indent. Enumerating what to strip is a losing game β€” each round just finds the next property that breaks the reader. So Reflow enumerates what is safe to keep instead:

Kept Dropped
font*, text-*, letter-spacing, word-spacing, direction, list-style*, vertical-align width, height, max-*, position, float, overflow, column-*, flex*, grid*, transform β€” everything that pins a box to the printed page
margin, padding, border*, outline* color, every background* β€” the panel follows the DSH theme, and a book that assumed white paper would otherwise render invisible text in dark mode
display, visibility (a book may hide non-linear content) line-height β€” the reader's line-spacing control owns it
break-inside: avoid on headings and figures white-space β€” it can pin text to the source's line breaks
@media, @supports, @layer, @container blocks @import, @charset, @page
@font-face and @keyframes, hoisted (they are not selector-scoped), with src rewritten to a local blob any url() that leaves the archive

On top of that the reader's own boxes are pinned where the book cannot reach them: the wrapper is forced to a plain, auto-sized, transparent block so it can never stop its content fragmenting across the reader's columns, and its base type size is the reader's control rather than the book's.

Absolute font-size values are rewritten to em against a 16px nominal base, so the font-size control scales the book's own type hierarchy instead of fighting it, and html/body/:root selectors address the wrapper, because inside the reader the wrapper is the document root.

Text a conversion hard-wrapped β€” a .txt source keeps its line breaks, the other way a book ends up wrapping at a fixed column β€” is detected (<br> clearly outnumbering the paragraph structure) and rejoined, with no space inserted between two CJK characters.

A malformed stylesheet degrades to "no book styling" rather than to an unreadable chapter, and a declaration whose url() leaves the archive drops rather than fetching.

Safety

Chapter XHTML is parsed into a detached document and sanitised before it reaches the DOM: script, style, link, iframe, object, form, and friends are removed, every on* attribute is dropped, javascript:/data:text/html URLs are removed, and images are rewritten to blob URLs built from the same archive β€” so no request leaves the page. Archive paths are resolved and collapsed against the OPF's directory, and a malformed escape or missing entry degrades to a missing image rather than an error.

Develop

node build.mjs     # regenerate client.js
node test/smoke.mjs

test/smoke.mjs loads the real client.js into jsdom, calls apply() with a mock client context, mounts the three registrations, and drives them the way a user does: reveal and hide the surface by moving the pointer, drop a synthetic (deflate-compressed) EPUB, page through it with the arrow keys and with left/right clicks on the book button β€” including a click that arrives before the panel has measured itself, and one that is abandoned before it lands β€” follow a table-of-contents entry, change the font size, hide and reopen to confirm the reading position resumes, and watch the simulated feed produce reasoning and tool rows β€” including the bare "thinking" pauses. It also asserts that client.js requires nothing outside the platform seed table, that every t(...) key in the bundle is covered by both dictionaries, that the two surfaces never overlap, that the book button carries no tooltip, that the panel head renders no text, and that the stylesheet uses the installed build's radius tokens rather than the source checkout's older literals.

jsdom has no layout engine, so the test stubs getBoundingClientRect from a table and cannot verify multi-column fragmentation; that part is verified in a browser.

Limits

  • Inflation uses the platform's DecompressionStream, so a browser without it cannot open a book. It reports that as a normal error inside the panel.
  • Encrypted EPUBs (DRM, or the ZIP encryption bit) are refused rather than partially read.
  • The reader is one book at a time; the library keeps the twelve most recently opened.
  • Reflow drops the book's line height, so the reader's line-spacing control is always the one in force. Turn Reflow off to see the book's own value.
  • The simulator covers the chat column, which it finds by the data-conversation-scroll attribute the conversation package publishes. It takes pointer events for that area, so the conversation behind it is not clickable while it is up.
  • The simulated rows mirror the app's measurements rather than importing its components (a plugin must not depend on another plugin's modules), so a future restyle of the chat rows would need the table above re-checked.