When a mod that works in the terminal is opened in the desktop app, these five problems come up most: a pane opened at startup does not appear (opens without a user action need 144 columns), hex colors are unreadable in the light theme, Raster is not drawn, Svg shrinks to 300x150, and an isInteractive Svg flashes black every second. All of them can be fixed by branching on e.surface.
Tested with Claude Code 2.1.286 to 2.1.288 (October 2026, Windows, 150% scaling).
1. A pane opened at startup does not appear
Even if you call $.ui.open(...) in session.start, the pane may not appear on the desktop. The official interface page has rules for this:
- An open backed by a user action (a command, a pressed button, a sent prompt) is placed regardless of width
- An open without one (from
session.start, a timer, orturn.start) needs 144 columns. A pane the user has opened themselves at least once needs 110 columns - When there is not enough room,
$.ui.openreturns{ isPlaced: false, reason }and waits
The desktop side panel is narrow, so assume an open at startup may not be placed. I chose to reopen on the first prompt submission.
let opened = false
on('prompt.submit', async ($, e, next) => {
const kind = e.origin?.kind
if (!opened && (kind === 'composer' || kind === 'bridge')) {
opened = true
await $.ui.open({ id: PANE, title: 'My Mod' })
}
return next(e)
})
The values of e.origin.kind (composer, bridge) are what I saw in the type definitions for my version. On other versions, check the type definitions (.claude-plugin/types/ from Part 2).
Other options are to open the pane from a command or a band button, or to show a toast saying it can be opened instead of opening it on your own (Part 4).
Also, the desktop app I tested (2.19675) accepted rows and columns in $.ui.open but did not use them for the pane size. Draw on the assumption that the size is whatever width the user dragged it to.
2. Hex colors are unreadable in the light theme
In the terminal you can paint with color: '#7FD1A6'. On the desktop, hard-coded colors can stand out oddly or become unreadable in the light theme. Pass theme color names instead and they render correctly in both light and dark.
| Meaning | Name |
|---|---|
| Up, success | success |
| Down, removal | diffRemovedWord |
| Warning | warning |
| Dim text | dimColor: true |
The official reference for Text also says color is a theme key or a color like 'red'. If you keep hex values in your feature code and put a single wrapper that swaps them only on the desktop, you do not have to rewrite anything.
const THEMED = { '#7fd1a6': 'success', '#f2837f': 'diffRemovedWord', '#d9b26a': 'warning' }
function themeProps(props) {
const out = { ...props }
const v = typeof out.color === 'string' ? out.color.toLowerCase() : null
if (v && THEMED[v]) out.color = THEMED[v]
return out
}
3. Raster is not drawn, or the surface is detected wrong
A mod that draws a grid of colored cells with Raster in the terminal cannot draw it on the desktop. Raster and Image are terminal-only, and Svg is desktop-only.
If you try to detect the surface by checking whether a component exists, like if (t.Raster), you will get it wrong, because the table from $.ui.resolve(e) contains every component name. Always branch on e.surface ('terminal' or 'desktop').
if (e.surface === 'terminal') {
return Raster({ key: 'grid', columns, rows, cells })
}
return Svg({ source: svgOf(grid), alt: 'Heatmap', width: w, height: h })
4. Svg shrinks to a small box
If you do not pass width and height to Svg, the frame stays at the default 300x150 (CSS pixels), and the picture shrinks to fit inside it. This happens even if the SVG itself has a viewBox.
t.Svg({ source: svg, alt: 'Chart', width: w, height: h })
The catch is that the pane size reaches the mod only in columns and rows (bodyColumns, bodyRows), not in CSS pixels. On my screen, one pane cell measured 7.6 wide and 18.6 tall (CSS pixels).
const CELL = { w: 7.6, h: 18.6 }
const w = Math.floor(cols * CELL.w)
const h = Math.floor(rows * CELL.h)
These values may differ on other setups, so make the drawing robust to a wrong estimate:
- Give the root
<svg>width="100%" height="100%"and aviewBox, so it keeps its aspect ratio and fits even if the frame differs from your estimate - Estimate a little small and fill the leftover space with the background color of the outer
Box
source is limited to 131,072 characters (official reference). 200 candlesticks already take tens of thousands of characters. Round coordinates to one decimal place and group identical fills into classes.
5. An isInteractive Svg flashes black every second
An Svg with isInteractive: true is drawn inside a frame (an iframe), so :hover and CSS animations work. However, every time the content changes, the whole frame reloads and goes black while it does. A view redrawn every second flashes every second. I also tried stacking two frames and loading in the back one, but that did not fix it.
The conclusion: do not use isInteractive; use a single image Svg.
- An image Svg keeps showing the old picture until the new one is ready, so it does not flash
- CSS
@keyframeswritten inside the SVG still run in an image Svg
What you lose is roughly :hover and <title> tooltips.
Animations restart from the beginning on every redraw
Even with an image Svg, changed content becomes a new image, and CSS animations start over. If you write the animation delay as "event time minus the time the SVG was built," the animation continues from the middle even after a rebuild, because a negative delay means "already partway through."
const madeAt = Date.now()
const delay = (eventAt) => `${((eventAt - madeAt) / 1000).toFixed(2)}s`
svg += `<text class="type" style="animation-delay:${delay(caption.at)}">${esc(caption.text)}</text>`
Things that change every second (elapsed time and so on) should stay out of the SVG and go in a Text row above it. That reduces how often the SVG itself is rebuilt.
Colors go wrong when two are side by side
If you reference <linearGradient id="g"> with url(#g), the second of two side-by-side images points at the first one's gradient. Give each one a unique prefix (id="s3-g").
Two more things you will hit
You cannot style buttons, and clicks on top of the picture do not register
The desktop Button has a fixed look; you cannot set its color, shape, or size. HTML cannot go into a pane either. I tried drawing the whole screen as an Svg and layering a Client on top to catch where the user clicked, but clicks on a Client layered over the picture did not work (2.1.286 to 2.1.288). The layout I settled on is: visuals in an Svg picture, and standard Buttons placed above or below the picture for anything clickable. Writing plain: false makes the button undrawable on the desktop, so only use true or omit it.
Layouts break in the narrow pane
The usual way a pane opens on the desktop is the narrow side pane next to the conversation (about 58 columns, 440px). Layouts tuned on a wide screen broke here: charts collapsed into thin columns, axis labels overlapped, and legends were cut off.
- If placing items side by side would leave a chart under 60 columns, switch to stacking them vertically
- Drop legend entries that do not fit, starting from the end
Open it once in the narrow pane before you polish anything. That is the fastest route.
Checking an Svg without opening the app
Opening the desktop app every time is slow, so I put the SVG in an HTML file and screenshot it with headless Edge. The trick is to shoot at the usual panel width (about 440px).
& "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --headless=new --screenshot=out.png --window-size=440,600 file:///C:/work/stage.html
Summary
| Problem | Fix |
|---|---|
| Startup pane does not appear | Open from a user action (automatic opens need 144 columns) |
| Colors are unreadable | Pass theme color names |
Raster is not drawn |
Branch on e.surface, use Svg on the desktop |
Svg shrinks |
Always pass width and height (1 cell is about 7.6x18.6) |
Svg flashes |
Do not use isInteractive
|
| Buttons cannot be styled | Visuals in Svg, clicks on standard Buttons |
| Layout breaks | Make the 58-column narrow pane your pass line |
You can tell whether a mod written for the terminal will draw on the desktop by checking its code for an e.surface branch before installing. The modscode gallery lists only mods that explicitly target the desktop app, with reproductions of how they look in the narrow desktop panel.
This article was written with AI assistance (Claude) and checked against the official Claude Code docs; anything marked "not verified" could not be confirmed there.
Top comments (0)