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 three 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.

    ⚠️ 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 — 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 two bundle paths. 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.

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 silently stops working. Nothing throws, nothing is logged, and the page looks correct.

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.

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.

  • filter: blur(), backdrop-filter, mask, content-visibility — each measured, not assumed. Note that filter is split: blur() is not reproduced anywhere, and 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 and box-shadow used to be on this list and are reproduced now
  • 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.

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 not reproduced. Blur an area to hide it and the DOM stage captures it sharp and in full color — the live page looks masked and the screenshot is not.
  • 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:

<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.

    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.

  • 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.