Widget & Bindings
Embed the floating button with a script tag, React, Vue, or Svelte.
The widget is the floating Paintless button that starts the capture flow. All the
behavior lives in @paintless/client-core; the widget and the framework
bindings are thin mounting layers, so every option below works identically
everywhere.
Script tag
<script src="https://unpkg.com/@paintless/widget/dist/paintless.iife.js"></script>The IIFE build (≈26KB gzip) auto-mounts on load. Configure with data attributes:
<script
src=".../paintless.iife.js"
data-host="ws://localhost:7327"
data-api="https://paintless.example.com"
data-key="pk_live_..."
></script>Add data-manual to skip auto-mounting and call Paintless.init() yourself.
React
import { Paintless } from '@paintless/react'
<Paintless /> // dev mode
<Paintless api={{ url: 'https://paintless.example.com', key: 'pk_...' }} /> // prod modeVue
<script setup>
import { Paintless } from '@paintless/vue'
</script>
<template>
<Paintless :api="{ url: 'https://paintless.example.com' }" />
</template>Svelte
<script>
import { paintless } from '@paintless/svelte'
</script>
<div use:paintless={{ host: 'ws://localhost:7327' }} />The inspect panel
Picking an element opens the inspect panel rather than a bare comment box. It shows what the request will carry, and lets you correct the selection before sending:
- Source location —
<button> · src/App.tsx:24, or a note that the page has no source mapper. - Breadcrumb — the component hierarchy rather than the raw DOM. Each ancestor is labelled with its framework component name when the runtime exposes one (React fibers, Vue 3 and Vue 2 instances — all development-build only), and with its source file otherwise. Consecutive ancestors sharing a label collapse into one entry. Click any crumb to jump to it. Svelte exposes no runtime component handle, so Svelte projects fall back to source file names.
- Steps —
↑parent,↓first child,←→siblings. Controls disable at the edges of the tree. On a keyboard,Alt+ the arrow keys do the same. - Element details — a devtools-style box model diagram (nested margin,
border and padding rings around the content size, zero and
autoshown as a dash) plus computed layout, text and surface values. Reporters can read what an element actually renders as without opening devtools. This view is a superset of what the request carries: reading styles on the client is free, while theChangeRequestpayload stays curated.
The request is built from the element selected when you press Send, not the one you first clicked.
Touch and small screens
On coarse-pointer devices ((pointer: coarse)) the flow adapts:
- The picker previews while your finger moves and commits on lift — the touch equivalent of hover-then-click — instead of relying on emulated mouse events.
- The panel becomes a bottom sheet: full width, safe-area padded, with 44px touch targets. Selecting an element scrolls it into view so the sheet never covers it.
- The Element details summary stays visible while the sheet peeks, so the details are reachable by tapping it — the handle is a shortcut, not the only way in.
- The sheet's handle responds to both tap and drag: drag up to expand into the element details, drag down to collapse, and drag down again to dismiss. A downward drag will not dismiss a sheet with a typed description — it collapses instead, so a swipe never destroys what you wrote.
- Both the picker and the panel expose a visible Cancel control, since there is
no
Escapekey.
Options
| Option | Attribute | Behavior |
|---|---|---|
host | data-host | Dev host WebSocket URL (default ws://localhost:7327) |
api | data-api / data-key | Paintless server target — switches the widget to prod mode: capture → HTTP submit → toast with the created issue/PR link |
demo | data-demo | Demo mode — runs the picker and inspector but sends nothing, summarizing what would be captured. For landing pages and docs. Ignored when api is set. |
screenshot | — | (el) => Promise<string | null> hook for hosts with a capture API (webview bridges); the extension uses the same hook |
theme | data-brand-color / data-position | Color, corner and size — see below |
copy | — | Text overrides for the trigger and its toasts — see below |
trigger | — | 'auto' (default) mounts the floating button; 'manual' mounts nothing and hands you an open() to call from your own UI |
Without api, the widget connects to the local dev host and shows the live
run panel (streamed agent events, Commit / Discard). When no dev host is
running it logs the captured request and explains how to start one.
Theme
init({
theme: {
brandColor: '#16a34a', // any valid CSS color; default '#6366f1'
position: 'bottom-left', // 'bottom-right' (default) | 'bottom-left' | 'top-right' | 'top-left'
offset: { x: 16, y: 16 }, // px from the two edges of that corner; default 24/24
size: 56, // trigger diameter in px; default 48
},
})brandColor reaches every piece of Paintless UI mounted after it — the
trigger, the picker's selection box, and the primary button in the panel and
run panel — so the whole flow reads as one color, not just the button. The
script-tag embed exposes the two options worth setting without any JS,
data-brand-color and data-position; reach for data-manual plus your own
Paintless.init({ theme }) call for offset or size.
Copy
init({
copy: {
triggerLabel: 'Send feedback', // button title/aria-label
submitted: 'Thanks — we got it.', // toast, no receipt link
submittedWithLink: 'Filed as {link}', // toast, receipt has a link — {link} is the built anchor tag
noDevHost: 'Feedback saved — sync with your team to review it.',
submitFailed: 'That didn’t go through — try again in a moment.',
},
})Every field is optional and falls back to the English default shown in the
source. There is no built-in i18n catalog — copy is the seam for one; a
project supporting several languages picks the right object per locale itself.
Replacing the trigger
trigger: 'manual' mounts no floating button — init() still returns the
same value, now with nothing on screen until you call .open():
import { init } from '@paintless/widget'
const paintless = init({ trigger: 'manual' })
// from your own button, menu item, or keyboard shortcut:
paintless.open()
// still unmounts (a no-op here, since nothing was mounted) if you tear it down:
paintless()@paintless/react's <Paintless trigger="manual" /> and the Vue/Svelte
bindings' trigger prop pass this straight through.
