Paintless
Latest
/Shells

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 mode

Vue

<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 auto shown 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 the ChangeRequest payload 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 Escape key.

Options

OptionAttributeBehavior
hostdata-hostDev host WebSocket URL (default ws://localhost:7327)
apidata-api / data-keyPaintless server target — switches the widget to prod mode: capture → HTTP submit → toast with the created issue/PR link
demodata-demoDemo 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
themedata-brand-color / data-positionColor, corner and size — see below
copyText 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.

Edit this page

Last updated: