dsh-plugin-epub-reader
Verifieddsh-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
insertrow 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's22px, and the tool result card isvar(--dsw-radius-lg)(16px) rather than12px. The values here follow the installed bundle, verified by reading its embedded CSS strings. Re-check againstdsh-client-ui-chat/lib/client.jsanddsh-client-ui-tool/lib/client.jsafter 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-scrollattribute 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.