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 vendor/visual-feedback/visual-feedback-widget.iife.js and vendor/visual-feedback/visual-feedback.iife.js (or your own paths if you serve them yourself) — two files, and the first one is on every page carrying the widget while the second is only there when screenshot capture is on,
  • 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.

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 — it publishes no stylesheet, so the class has no rule to fall into and the attribute is the only mechanism the package can supply. If your policy forbids style attributes, write the rule yourself; the class is there as the hook:

    .visual-feedback-honeypot {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    left: -9999px;
    }

    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: grayscale() is reproduced and blur() is not, so the property name alone is not 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; Shadow DOM content (not measured here) — the flattening half is pinned, the shadow-root half is not
  • 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 — and that is still not a redaction. Gray text is text: 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 UI is a deliberate non-goal. 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.