# dsh-plugin-palette-board > A Raycast / Launchpad-style 2D palette board: a fully keyboard-driven floating app grid summoned by ⌘K, for the DeepSeek Harness (dsh) web console. [![dsh Version](https://img.shields.io/badge/dsh-%E2%89%A50.1.2--alpha.1-4D6BFE)](https://www.npmjs.com/package/@deepseek-ai/cordis) [![Node Version](https://img.shields.io/badge/Node.js-%E2%89%A520-339933?logo=node.js)](https://nodejs.org/) [![Zero Dependency](https://img.shields.io/badge/runtime_deps-0-brightgreen.svg)]() [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [简体中文](README.md) | **English** --- ## 📖 Introduction `dsh-plugin-palette-board` brings a **2D palette board** to the DSH (DeepSeek Harness) web console: summon a glassmorphic floating panel with `⌘K` / `Ctrl+K` / `Alt+Space`, with instant search, category filters, and full keyboard navigation — gathering the plugin pages scattered across your sidebar and browser into one customizable card grid. The card catalog is persisted as a local JSON file (`~/.dsh/storages/palette-board.json`), with no seeding and no built-in assumptions about any plugin — hand-write cards via the in-panel **Register** dialog, one-click adopt sidebar entries via **Scan**, or let third-party plugins check in through the `ctx.paletteHub` service. The palette service is open to every client plugin: soft-dependency integration, one-line registration. --- ## 📸 Screenshots The plugin registers an entry button in the sidebar footer via the host slot system (`lib/client.js` is a lightweight zero-dependency browser bundle); the panel itself mounts directly onto `document.body` as a full-screen overlay. ### Main panel (fresh paper theme) ![Palette Board (fresh theme)](assets/board-fresh.png) Instant search + dynamic category filters + card grid: hover the "⋯" overflow menu to pin / reorder / edit / delete; header buttons cycle three panel sizes and switch between both themes; the status bar reports catalog sync health in real time. ### Scan — turn sidebar entries into cards ![Scan modal](assets/scan-modal.png) The scan button reads the sidebar footer action slot registry, lists entries not yet adopted, and converts any of them into a custom card that triggers it on click. ### Register — adopt any page with zero code ![Register modal](assets/register-modal.png) The runtime register / edit dialog: fill in a "page link" to adopt any plugin's web page into the palette, or hand-write a `footerTrigger` card that fires a sidebar button. ### Dark glass theme ![Palette Board (glass theme)](assets/board-glass.png) Two themes — `fresh` (paper, default) and `glass` (dark glassmorphism) — switched by a header button, remembered in localStorage, driven by CSS variables with zero re-render cost. ### Mount points | Mount point | Location | Content | | :--- | :--- | :--- | | `sidebar.footer.action` | Sidebar footer action area | Persistent palette summon button (circular icon when the rail is collapsed) | | `document.body` portal | Full-screen overlay | The palette panel (search / categories / grid / register / scan) | | Floating orb (bottom-right) | Persistent meta entry | Draggable, position remembered, auto-hides while the panel is open | | Host static route `/palette-board/guide` | New tab | Built-in integration guide (bilingual) | --- ## ✨ Key Features - ⌨️ **Full keyboard interaction**: `⌘K` / `Ctrl+K` / `Alt+Space` to summon, `Esc` to close, `↓` to enter the grid, arrows / `Home` / `End` to move between cards (roving tabindex), `Enter` to launch; never steals keys during IME composition. - 🗂️ **Catalog persisted as local JSON**: `~/.dsh/storages/palette-board.json` with a two-section structure (`entries` for hand-written cards + `layout` for plugin-card layout records); edits / deletions / additions / pins / reorders write back in real time, survive refresh and restart, and the file can be hand-edited or backed up directly. - 🛡️ **Three-way failure alerts**: PUT write-back failure, load failure at startup (file left untouched — a file that was never read is never overwritten), and limit rejection (200 cards / 256 KB, adjudicated by the host storage layer and mapped to HTTP 413) — reported separately in the status bar; sync resumes automatically after trimming cards. - 🔌 **paletteHub as a pluggable service**: the client half publishes `ctx.paletteHub` as a cordis service; other plugins integrate via soft dependency and register cards in one line — everyone stays healthy when palette-board is disabled. - 🔍 **Scan & adopt**: reads the `sidebar.footer.action` slot registry and adopts existing sidebar entries into custom cards in one click; shares the same source of truth as the footerTrigger action area. - 🧹 **Sieve mode**: a funnel toggle hides dead cards (trigger button gone from the sidebar, link 404/410) — display only, never touches the catalog. - 📏 **Three sizes**: compact / regular / large cycling, panel and grid scale with the tier, remembered in localStorage. - 🌗 **Two themes**: `fresh` paper / `glass` dark, one-click switch; UI is bilingual (zh / en) following the dsh language preference. - ♿ **Accessible modals**: `role="dialog"` + `aria-modal` + Tab focus trap + Esc to close + focus restored to the summoning element. - ⚡ **Clone-and-run**: `lib/` build artifacts are committed to the repo with zero runtime dependencies — mount right after cloning, no install / build needed. --- ## 🚀 Quick Start ### 1. Prerequisites - **Node.js** >= `20` - **dsh web** >= `0.1.2-alpha.1` (see "Architecture Notes · Version baseline") ### 2. Clone and mount ```bash # 1. Clone. lib/ build artifacts are committed with zero runtime dependencies — # mount right after cloning; no install / build needed unless you change src/: git clone https://github.com/zhm20001/dsh-plugin-palette-board.git cd dsh-plugin-palette-board # 2. Mount into your dsh web profile: add # "dsh-plugin-palette-board": "link:/path/to/dsh-plugin-palette-board" # to the profile package.json dependencies, add "dsh-plugin-palette-board" # to the dsh.profile.bundles array, then: cd ~/.dsh/profiles/web && pnpm install # 3. Restart dsh web (host-half changes need a restart; client-half changes # hot-reload — just hard-refresh the browser) ``` The bundle channel automatically merges this package's `cordis.patch.yml` insert line; you don't need to touch the profile's own patch file. The manual channel (writing the insert line into the profile's `cordis.patch.yml`) also works, but **never use both channels at once**. ### 3. First use Summon the palette in the browser with `⌘K` / `Ctrl+K` / `Alt+Space` (the palette entry above the sidebar settings button, or the bottom-right floating orb, work too). The catalog starts empty — hand-write cards via **Register**, adopt sidebar entries via **Scan**, or let third-party plugins check in through `ctx.paletteHub` (see "Third-party plugin integration"). --- ## ⚙️ Catalog file & storage | Kind | Location | Notes | | :--- | :--- | :--- | | Card catalog (single source of truth) | `~/.dsh/storages/palette-board.json` | Two sections `{ entries, layout }`: entries are hand-written cards, layout holds plugin-card layout records | | Theme / size / orb position | Browser localStorage | Pure UI preferences, never in the catalog file | > 💡 The catalog can be hand-edited or backed up directly; when corrupted, the host backs it up as `.bak` and resets to an empty catalog. Legacy v1.6–v1.7 plain-array files migrate automatically. Writes go only through the loopback same-origin entries API with a Host fence. --- ## 🔌 Third-party plugin integration (paletteHub, soft dependency) The palette's entire registration surface lives on the **browser side**. **Always use a soft dependency** — putting `'paletteHub'` into a module-level `inject` is a hard contract: if palette-board is disabled, your plugin pends forever and takes the whole web GUI down with it: ```tsx // my-plugin/src/client/index.tsx import type {} from 'dsh-plugin-palette-board/client' // type-only import: triggers ctx.paletteHub type merge import type { Context } from '@deepseek-ai/cordis' export function apply(ctx: Context): void { // Soft dependency: this plugin activates normally when palette-board is absent ctx.inject(['paletteHub'], (sctx) => { const unregister = sctx.paletteHub.register({ id: 'my-plugin:telemetry', title: 'Container Live Telemetry', description: 'Real-time Docker & GPU kernel metrics inspector.', category: 'Developer Tools', color: 'emerald', badge: 'v1.4', actionLabel: 'Inspect', onClick: () => { /* open your UI / call your service */ }, }) sctx.effect(() => unregister, 'my-plugin: palette entry') // auto-unregister on dispose / HMR }) } ``` See [`examples/demo-plugin.tsx`](examples/demo-plugin.tsx) for a complete example. **Key points**: - The sub-scope created by `ctx.inject(['paletteHub'], …)` runs its callback when the service appears and tears down entirely when palette-board stops — safe in both states; **do not** replace it with a module-level `inject = ['paletteHub']`. - Wrap the `register` disposer in `sctx.effect`, otherwise the registration leaks after the fiber unmounts and duplicates on re-activation. - `import type {}` is erased at compile time: no runtime dependency, no purity-gate violation in your client bundle (cross-plugin collaboration goes through cordis services only — value imports are forbidden). - `icon` accepts any `ComponentType<{ className?: string }>` (24x24 line style works best); a built-in Sparkles is used when omitted. - **Zero-code channel**: don't want to write code? Fill in a "page link" in the Register dialog to adopt any plugin's web page, or use the scan button to adopt a sidebar entry in one click. - **Plugin cards are session-scoped**: re-registered by the plugin on activation after refresh (they come back as long as the plugin is enabled); your pins / reorders persist as layout records and take effect again when the plugin returns. For permanent entries, use the Register dialog, scan adoption, or hand-edit the catalog file. --- ## ⌨️ Keyboard Shortcuts | Key | Action | | :--- | :--- | | `⌘K` / `Ctrl+K` | Summon / dismiss the palette | | `Alt+Space` | Summon / dismiss the palette | | `Esc` | Close (never stolen during IME composition or inside editable elements; modals listen on their own) | | `↓` (in search box) | Enter the card grid | | `↑` `↓` `←` `→` / `Home` / `End` | Move focus in the grid (`↑` on the first row returns to the search box) | | `Enter` | Launch the focused card | --- ## 🛠️ Local Development ```bash pnpm install # development only (zero runtime deps, clone-and-run) pnpm build # tsc declarations → lib/types, tsdown → lib/index.js + lib/client.js pnpm typecheck # tsc --noEmit pnpm test # node:test unit tests (host storage layer / scan pure functions, no external deps) pnpm watch # watch build (pairs with the dsh host client HMR) ``` > ⚠️ After changing `src/`, remember to `pnpm build` and commit the `lib/` artifacts together — the repo distributes via the committed lib/ output. ```text dsh-plugin-palette-board/ ├── package.json # dsh.client declaration + exports["./client"] + dsh.bundle.patch ├── cordis.patch.yml # bundle patch: insert mount line (id: palette-board) ├── src/ │ ├── index.ts # host half: cordis plugin + guide / entries routes │ ├── entries-store.ts # catalog JSON file storage (validate/migrate/atomic-write/backup) │ ├── guide.ts # integration guide static page (bilingual, served by host half) │ ├── types.ts # PaletteItem / PaletteHubService shared type surface │ └── client/ # client half: panel / register / scan / themes / i18n / paletteHub service │ ├── index.tsx # entry: provide service + locale dicts + shortcuts + portal + sidebar entry │ ├── PaletteBoard.tsx # main panel (search/categories/grid/keyboard nav/sieve/status bar) │ └── … # full listing in CONTEXT.md (domain glossary) ├── test/ # node:test unit tests └── lib/ # build artifacts (committed to the repo) ``` --- ## 🧩 Architecture Notes - **Dual-half structure**: follows the real dsh web plugin convention — a Node-side cordis plugin (`inject = ['webServer', 'webRuntime']`) plus a browser-side `__ModuleLoader__` client bundle, mounted via the `dsh.client` declaration + `exports["./client"]` + `dsh.bundle.patch`. - **Artifact contract**: `lib/index.js` (host half, Node ESM); `lib/client.js` (client half, browser CJS closure factory) — `require` allows platform module-table names only (this plugin uses just `react` / `react/jsx-runtime` / `react-dom/client`); everything else is inlined. - **`paletteHub` lives only in the client half**: the host half carries the plugin identity, the `/palette-board/guide` static route, and the entries API. The host half must not have a default export (the loader's `unwrapExports` would drop the module-level `name` / `inject` declarations). - **Store discipline**: `createPaletteHubStore()` creates one instance per activation (module-level singletons forbidden); snapshots are immutable and reference-stable, consumed via `useSyncExternalStore`. - **Portal mounting**: the palette is a full-screen overlay outside the shell slot layout — it mounts a root directly onto `document.body` (the `data-dsh-palette-board` anchor) with a body-reorganization guard and a root-level error boundary. - **Version baseline**: implemented against the dsh `0.1.2-alpha.1` module table and scan conventions; **new dsh versions only, no legacy compatibility** — scan adoption depends on the public enumeration surface of the host slots service; on older hosts the guard simply yields an empty scan result (everything else keeps working). If scans always come back empty, update dsh first. --- ## 📄 License This project is open-sourced under the [MIT License](LICENSE). The same license covers both source halves (host / client), build artifacts (lib/), the integration guide page, and all documentation.