Skip to main content

Integration contract

CORS, for the DOM stage​

Remote images inside the captured region need crossorigin="anonymous" and an origin that actually sends CORS headers. The renderer re-fetches images through a fresh Image(), so a missing header is a silent hole in the screenshot rather than an error.

Watch for onerror handlers that remove the crossorigin attribute as a retry: once that image is cached without CORS, every later capture on that page is poisoned.

CSP​

If you run a content security policy, the capture needs:

  • script-src for four files under vendor/visual-feedback/ (or your own paths if you serve them yourself):

    • visual-feedback-widget.iife.js — on every page carrying the widget,
    • visual-feedback.iife.js — only where screenshot capture is on,
    • visual-feedback-renderer.iife.js — loaded by the DOM stage at the moment of capture,
    • visual-feedback-editor.iife.js — loaded when a reporter opens a capture to edit it, unless screenshot.editor is off.

    The third one is the one worth naming twice. Leave it out and everything looks right: the integration loads, the widget appears, and the failure arrives only when a reporter has already pressed capture. On iOS there is no second path — the DOM stage is the only one there.

  • img-src blob: for the preview the reporter reviews before attaching, and for the picture in the screenshot editor — the finished PNG is handed to an <img> as an object URL, never uploaded to be displayed,

  • img-src data: for the DOM stage, which redraws every inline <svg> in the captured region through a data:image/svg+xml URL,

  • connect-src for the origins of any SVG images that must be inlined,

  • font-src for the origins of any webfont used inside the captured region,

  • style-src for the widget's own stylesheet and for one inline style attribute — see below.

The package renders no inline <script> that executes, so script-src needs nothing beyond the four bundle paths above. The configuration island the widget reads its settings from is a <script type="application/json"> data block, which is never executed and is not a script source.

A nonce-based policy​

If your policy admits scripts and styles by nonce, as script-src 'nonce-…' 'strict-dynamic' does, the widget's tags carry the nonce you gave Laravel's Vite integration with Vite::useCspNonce(). Livewire puts the same nonce on its own tags, so an application that already nonces Livewire needs nothing more. To pass a nonce explicitly instead:

<x-visual-feedback::scripts :nonce="$nonce" />
@include('visual-feedback::style', ['nonce' => $nonce])

The DOM stage passes the nonce on. The renderer it loads at the moment of capture carries it, and so do the style elements it writes into its copy of the page, which your policy governs as well. A nonce shorter than 16 characters, or one with characters outside base64 and base64url, would make the renderer refuse to run, so the capture leaves it off the renderer's own style elements and your policy refuses those instead. The nonce Laravel generates is 40 letters and digits.

unsafe-eval, and why you do not need it​

You can run this widget under a policy with no unsafe-eval. That is worth stating plainly, because Alpine — which the widget is built on — normally needs it: its standard build evaluates every directive expression by constructing a function at runtime, which is exactly what unsafe-eval permits and a hardened policy withholds.

Alpine ships a second build for that case, and it parses expressions with a small grammar instead of evaluating them. The grammar has no arrow functions, no template literals, no optional chaining, no spread, no new, and it resolves identifiers against the Alpine scope only — a bare document or window does not resolve. An expression outside it is not degraded, it is never evaluated at all: a rejected x-data leaves the element with an empty scope, so every directive on it stops working. The page looks correct; the only trace is an Alpine Expression Error warning in the browser console, plus an uncaught error your error tracker may collect.

Every expression this package renders is a method call on a component registered in the bundle, which parses under both builds. There is no CSP variant of the templates and nothing to configure — it is simply the form the widget ships in, and a guard in the suite parses the rendered markup on every run so it stays that way, checking it against Alpine's own CSP parser wherever that parser is installed.

If you publish the view tree and edit it, that is the one rule to keep: put logic in a component, and let the template call a method. Anything else is a construct that may work in your application and stop working in one with a stricter policy.

One method name in ten thousand is not safe to call

"Call a method" has an exception, and 0.5.1 was released because this package walked into it. A wire: action expression is not exempt from the grammar — Livewire rewrites it to $wire.<expression> and hands it to the same parser — and a method whose name is a JavaScript keyword breaks in one of two ways.

An operator name (delete in instanceof new typeof void) becomes $wire.delete(…), which the grammar rejects because a keyword is not an identifier. Write $wire['delete'](…) instead; it parses under both builds.

A literal name (true false null undefined) is on Livewire's skip list, so it is never rewritten. The expression parses and then calls the literal, which fails at runtime. Index access cures this one too — $wire['true'](…) calls your method rather than the boolean.

Both give you a control that renders correctly and does nothing, with nothing in the console pointing at the cause. Every other name is fine.

style-src, and the one failure here that is silent​

The plain view tree's stylesheet is an inline <style> block — that is what @include('visual-feedback::style') puts in your layout. Under style-src 'self' with no 'unsafe-inline', no nonce and no hash, the block is dropped and the widget renders completely unstyled. Loud, obvious, easy to fix.

The second one is not loud, and it is the reason this section exists.

The honeypot field is concealed by CSS, and it is filled in by real people the moment that fails. A honeypot hit is answered with the success screen deliberately — telling a bot it was caught teaches the bot. So a reporter who fills in an exposed honeypot is thanked for a report that is discarded. Nothing errors, nothing is logged as a failure, and both sides believe it arrived.

Both view trees hide that field two ways, so that losing one of them is not enough to expose it:

  • an inline style attribute, which works on an installation that never included the stylesheet,
  • and the class visual-feedback-honeypot, which works when the stylesheet is admitted by nonce or hash while style attributes are forbidden.

That second case is the one to plan for. style-src-attr 'none' is ordinary hardening, and style-src-attr falls back to style-src, so a policy that never mentions attributes at all still forbids them. So:

  • Plain tree — you are covered as long as the stylesheet loads, because the class carries a rule of its own there. If you strip both, the field is visible.

  • WireKit tree — covered the same way, as long as @include('visual-feedback::style') is in your layout. That include renders a small block for this tree, and the honeypot's concealment rule is in it. It is almost all layout; the colors it does set are read from your design tokens (--color-wk-danger-text, --color-wk-text-muted), never from a palette of ours, so there is nothing here that can fight your theme. The --vf-* palette stays out of this tree entirely.

    It used to render nothing at all, and this section told you to write that rule yourself. You no longer have to. What you do have to keep is the include: strip it from the layout on the grounds that the WireKit tree does not need it, and the class has nothing to fall into again.

    Off-screen rather than display: none on purpose — an undisplayed field is one a bot can cheaply skip, and then the trap catches nothing.

The two img-src values are the ones a strict policy breaks first, and they break differently. Without data: the shot comes back with holes where the icons were, which at least looks wrong. Without blob: the preview box is empty, the buttons under it still say attach, discard and retake, and the capture itself is fine — so the reporter attaches a screenshot they were never shown, and the console violation is the only thing that says why.

Permissions-Policy​

The native capture stage uses one policy-controlled feature, display-capture, for getDisplayMedia(). Its default allowlist is self, so a page that does not name it already allows it. A policy that sets display-capture=() switches the native stage off, and the widget captures through the DOM stage instead, without asking the reporter anything.

The capture does not use the camera, the microphone or the location, so camera=(), microphone=() and geolocation=() are safe to set. The native stage checks the camera feature before it reaches for ImageCapture, a faster way to read a frame that Chrome ties to that feature even on a screen track. Where the policy withholds it, the frame is read through a <video> element instead, which needs no policy. Both paths read one full-size frame of the stream.

What the DOM stage does not reproduce​

The DOM renderer parses a defined set of CSS properties. Everything else is ignored silently — the element appears, only without the effect. Stage one (native capture) has none of these limits.

Each entry says how well it is understood, because we measured this and we have not looked read identically otherwise. An entry marked (not measured here) comes from the renderer's own documented limits or from a single observation, and no test in this package holds it — so treat it as a caution rather than as a proven boundary. Everything else is pinned by a browser test that runs against the renderer this release ships.

  • backdrop-filter and content-visibility — each measured, not assumed. filter is split by function and by engine: grayscale() is reproduced in Blink and not in WebKit — so neither the property name nor the function name alone is a safe answer. clip-path, mix-blend-mode, object-position, box-shadow, mask and filter: blur() used to be on this list and are reproduced now, the blur in Blink and in WebKit
  • cross-origin iframes — replaced by a same-size placeholder naming the host
  • the internal state of custom elements after flattening. The neighboring worry about Shadow DOM content used to sit here and is gone: an open shadow root reproduces, measured against an identical block in the light DOM in the same capture. A closed root is unreachable from script at all, so no renderer can read one
  • WebGL canvases drawn without preserveDrawingBuffer: true — which is the default. The browser is free to discard the drawing buffer after presenting, so there is nothing left for a second reader to copy and the area comes back blank. A 2D <canvas>, a <video> frame and a WebGL canvas created with preserveDrawingBuffer: true all reproduce
  • position: fixed on iOS while the page is scrolled
  • a pinch-zoomed page (not measured here): the capture follows the layout viewport, so a reporter who zoomed in gets the unzoomed band rather than what filled their screen
  • an image that failed to load leaves no trace. On screen the browser draws its broken-image affordance and the alt text; in the capture that area is simply empty, so a report about a missing image shows a gap rather than the evidence
  • separately, and this one is explained: a semi-transparent background stays semi-transparent in the PNG. Capture a single element whose own fill is, say, 8% of your brand color and nothing inside the capture supplies a backdrop, so the viewer decides what shows through — on a dark or checkerboard background the surface reads far more saturated. The pixels are exact; composited over white they match what the browser paints, to the unit. Capturing the page, or any element with an opaque ancestor inside it, has an opaque backdrop and no such ambiguity
  • some inline <svg> icons can be missing from the capture while the control around them draws normally. Observed once, on one control, in one application; the cause is not attributed. Two plausible explanations were measured and ruled out — a transform transition, and styling the icon through a CSS class instead of an inherited currentColor. Both of those render correctly

box-shadow is the newest of those, and it gets a sentence of its own because this page used to say the opposite and told you to design around it. The renderer painted the shadow color across the whole element and ignored the ring geometry, so an inset hairline ring at low alpha — a Tailwind ring-1 ring-*/30, say — came back with its entire fill tinted that color. It is drawn as a ring now, measured to the unit: the ring width, the inset keyword and the alpha compositing all reproduce, and an outset shadow lands outside the box where the browser puts it. The outline or border this page used to recommend for a ring that must survive the capture is no longer necessary.

An inset shadow on an element with a border-radius is drawn as a ring as well, in Blink and WebKit. The renderer before the bundled one painted it as a band as wide as the radius, and the capture left such shadows out; a rounded input with ring-1 ring-inset, or a card with an inner highlight, now comes back with its inner ring.

The one that matters most, and the reason to read this section: a CSS effect is not a redaction, and you cannot tell from the property name whether it survives.

  • filter: blur() is reproduced by the DOM stage in Blink and WebKit with the renderer this release bundles, measured at the edge of a blurred box. It is still not a redaction: blurred text can be read back out of an image, and the renderer before this one dropped the blur and returned the area sharp and in full color.
  • content-visibility: hidden is not reproduced either. The browser paints nothing at all there; the capture shows the content.
  • filter: grayscale() is reproduced in Blink only. In WebKit the capture drops it and the square comes back in its original color — measured in both engines on 2026-09-13. And where it IS reproduced it is still not a redaction: gray text is text, and the words, the layout and every digit are exactly as readable as before.

The point is not which of these currently survives. It is that none of them is a way to keep something out of a screenshot, and the answer changes with the renderer: three properties left that list in one dependency update, and box-shadow left it in the next.

Use the redaction attribute instead. It works in both stages, blacks the region out before anything is captured, and clears input values as it goes. Put it on a wrapper or directly on what you want hidden: an image, a canvas, an inline SVG, a video, an iframe, a checkbox and a custom element are blacked out the same way, and so is a child of a marked region that has a z-index of its own.

It covers what the region paints outside its own box as well: a descendant positioned elsewhere on the page, text that runs over its edge, and the children of a wrapper with display: contents, which has no box of its own. A marker inside an open shadow root counts; one inside a closed shadow root is out of reach for script, so mark its host instead. On the first stage, which photographs the live page, the black boxes are measured when the capture starts and again when the browser hands over the picture, and a region inside an open popover, a modal dialog or a fullscreen element is covered in the top layer it is drawn in.

<div data-visual-feedback-redact>
<x-account-balance />
</div>

The attribute name is screenshot.redact_attribute, so you can rename it if it collides with something in your own markup. Rename it and every occurrence in your templates has to move with it — the capture bundle reads the configured name and knows nothing about the old one.

Other contract notes​

  • Attachments are stored to a private disk, and their temporary uploads are cleaned up on the disk Livewire is configured to use — check that disk's lifecycle if you changed it.

  • The package validates every upload the moment it lands, but the endpoint it lands on is Livewire's global upload endpoint, and its limits are Livewire's settings, not ours. This matters more than it sounds, because the widget is designed to sit on a public page: a file reaches that endpoint and is written to the temporary disk before any of this package's code runs, so the abuse floor — which starts at submit — is not in front of it, and nobody has to press send.

    Set both keys in config/livewire.php:

    'temporary_file_upload' => [
    'rules' => ['required', 'file', 'max:8192'],
    'middleware' => 'throttle:20,1',
    ],

    Without rules, the byte cap on that endpoint is Livewire's default of 12 MB — not the 5 MB this package documents for attachments. The package's own caps are enforced after the file is already on disk, so they bound what is accepted, never what is written.

    Those caps are more than the per-file size, and the rest of them live only in the config file:

    SettingDefaultWhat it bounds
    attachments.max_file_size5 MBone file
    attachments.max_total_size15 MBthe UPLOADS on one report, together — the screenshot has its own cap and is not in this sum
    attachments.max_image_dimension15 000 pxeither edge of an image
    attachments.max_image_pixels100 MPwidth × height

    The last two are the decompression-bomb guards, and they are the reason a cap on bytes alone is not enough: a few hundred kilobytes of PNG can decode to a bitmap that exhausts memory while it is being sniffed. They are checked on the dimensions the file declares, before anything decodes it.

    Two things to get right about the number:

    • Size it to the largest upload in the whole application, not to this package. That key is app-global; every Livewire upload in your app goes through the same endpoint, so too low a value breaks unrelated forms.
    • 8192 KB, not 5120. Screenshots are Livewire uploads too and their own cap is 8 MB, so a 5 MB rule would reject a legitimate capture at the endpoint — before the package's clean rejection path, with no way for the reporter to know what happened.

    A temporary upload disk on S3 takes one file per pick. Livewire refuses a file input with multiple when livewire.temporary_file_upload.disk is an s3 disk, so the widget then drops multiple: each file the reporter picks is uploaded on its own and joins the list under the field, and picking again adds the next one. On any other disk one pick takes several files.

    The throttle is the second half: Livewire's default is throttle:60,1, so writing that value changes nothing. 20,1 is a real narrowing. For comparison, the package's own guest limit is 5 per hour.

  • The widget's Livewire class, Pushery\VisualFeedback\Livewire\ReportWidget, is not an extension point. It is not final, because the package's own browser tests extend it, but nothing in it is held stable for a subclass across releases. To change what the widget shows, publish its views; to act on a report, use the channels and events described in Delivery channels.

  • Mailed reports leave copies in your mail provider, which retention commands cannot reach.

  • An admin console is a deliberate non-goal, and the one exception is opt-in: the report browser is a minimal read-and-delete view that stays unreachable until you route it and open a gate. The reports table and its columns are public API — the package ships no Eloquent model, so the one you build is yours to name and extend — and so is the Report DTO every event carries. Build the screens your team wants; Delivery channels has the query recipe.