Configuration
Every option is documented inline in config/visual-feedback.php. The blocks:
| Key | What it governs |
|---|---|
enabled | master switch |
categories | the category list offered to the reporter. The first one is the default — the picker shows it and an untouched report carries it, so order the list to choose that default |
fields | how you want each field, and how long it may be. fields.<field>.mode is off, optional or required — one word per field, and the only place that question is answered. A value that is none of the three shows the field as optional, so a typo such as reqired never removes an input, and true and false read as optional and off. An off field is neither shown nor kept: a value a caller of the submission pipeline passes for it anyway is dropped before the report is built. fields.<field>.max_length caps it: subject 150, name 150, email 254, message 50 000, phone 32. The limits are counted in code points, not bytes, so message at 50 000 admits roughly 200 KB of UTF-8; size your own column accordingly if you write reports somewhere other than the shipped table. In the shipped table, reporter_name and reporter_email take your application's default string length, 255 unless Schema::defaultStringLength() lowers it: at 191, keep email at or under 191 or widen the column. Each text field is trimmed before it is judged and stored, Unicode whitespace and zero-width spaces included, so a field of nothing but spaces counts as empty and the limit applies to the trimmed text. message has no mode: a feedback form without a message is not a feedback form. An optional field says so in its label, and a required one carries the star the form's legend explains instead |
guests | nothing any more. Name, email and phone are asked of guests only, and how much of it they have to give is set per field under fields above — require_name and require_email moved there as mode. Their environment variables still work. There is no key that turns guest reporting off: a widget on a public page accepts a guest report, and refusing one is your page's decision about where the widget goes |
attachments | disk, directory, counts, size caps, accepted types, image bomb guards |
screenshot | the capture cascade, scale, caps, redaction attribute |
metadata | which browser metadata is collected |
abuse | the abuse driver, rate limits, the time trap and the challenge view |
privacy | the privacy notice guests acknowledge |
channels | which delivery channels run, and their queue tuning |
mail, webhook, database | per-channel settings |
retention | how long reports and orphaned files are kept — only once you schedule the two commands yourself, see Privacy and retention |
ui | trigger surface, floating button position, asset base URL |
context_providers | your own classes that attach application context to a report — they implement ReportContextProvider, and authorizing what they expose is your duty, see Delivery channels |
Paste this into your .env
Every variable this package reads, commented out at the value it already uses, grouped by the job you are doing rather than alphabetically. Uncomment the lines you want. Nothing here is required — an install works with none of it.
# ── Is it on at all ────────────────────────────────────────────────────────────
# VISUAL_FEEDBACK_ENABLED=true
# VISUAL_FEEDBACK_REQUIRE_AUTHENTICATION=false
# ── Where reports go ───────────────────────────────────────────────────────────
# VISUAL_FEEDBACK_MAIL_TO=
# VISUAL_FEEDBACK_MAIL_MAILER=
# VISUAL_FEEDBACK_MAIL_FROM_ADDRESS=
# VISUAL_FEEDBACK_MAIL_FROM_NAME="${APP_NAME}"
# VISUAL_FEEDBACK_MAIL_LOCALE=
# VISUAL_FEEDBACK_MAIL_SUBJECT_EXCERPT=60
# VISUAL_FEEDBACK_MAIL_REQUIRE_DELIVERABLE_TRANSPORT=true
# VISUAL_FEEDBACK_CHANNEL_MAIL=true
# VISUAL_FEEDBACK_CHANNEL_DATABASE=false
# VISUAL_FEEDBACK_CHANNEL_WEBHOOK=false
# VISUAL_FEEDBACK_WEBHOOK_URL=
# VISUAL_FEEDBACK_WEBHOOK_SECRET=
# VISUAL_FEEDBACK_WEBHOOK_TIMEOUT=5
# VISUAL_FEEDBACK_WEBHOOK_INCLUDE_REPORTER=true
# ── Which queue carries each channel, and how often it tries ───────────────────
# VISUAL_FEEDBACK_MAIL_CONNECTION=
# VISUAL_FEEDBACK_MAIL_QUEUE=
# VISUAL_FEEDBACK_MAIL_TRIES=3
# VISUAL_FEEDBACK_DATABASE_CONNECTION=
# VISUAL_FEEDBACK_DATABASE_QUEUE=
# VISUAL_FEEDBACK_DATABASE_TRIES=3
# VISUAL_FEEDBACK_WEBHOOK_CONNECTION=
# VISUAL_FEEDBACK_WEBHOOK_QUEUE=
# VISUAL_FEEDBACK_WEBHOOK_TRIES=3
# ── What the form asks for: off | optional | required ──────────────────────────
# VISUAL_FEEDBACK_FIELD_SUBJECT_MODE=optional
# VISUAL_FEEDBACK_FIELD_NAME_MODE=optional
# VISUAL_FEEDBACK_FIELD_EMAIL_MODE=optional
# VISUAL_FEEDBACK_FIELD_PHONE_MODE=off
# ── How much text one report may carry ─────────────────────────────────────────
# VISUAL_FEEDBACK_FIELD_SUBJECT_MAX_LENGTH=150
# VISUAL_FEEDBACK_FIELD_MESSAGE_MAX_LENGTH=50000
# VISUAL_FEEDBACK_FIELD_NAME_MAX_LENGTH=150
# VISUAL_FEEDBACK_FIELD_EMAIL_MAX_LENGTH=254
# VISUAL_FEEDBACK_FIELD_PHONE_MAX_LENGTH=32
# VISUAL_FEEDBACK_CONTEXT_MAX_VALUE_LENGTH=2000
# ── How it looks ───────────────────────────────────────────────────────────────
# VISUAL_FEEDBACK_UI_VARIANT=auto
# VISUAL_FEEDBACK_UI_TRIGGER=fab
# VISUAL_FEEDBACK_UI_POSITION=bottom-end
# VISUAL_FEEDBACK_UI_FAB_ICON=message
# VISUAL_FEEDBACK_UI_ASSETS=
# VISUAL_FEEDBACK_UI_ASSETS_INTEGRITY=false
# ── Screenshots ────────────────────────────────────────────────────────────────
# VISUAL_FEEDBACK_SCREENSHOT_STRATEGY=auto
# VISUAL_FEEDBACK_SCREENSHOT_SCALE=2
# VISUAL_FEEDBACK_SCREENSHOT_DARK_FALLBACK=#111827
# VISUAL_FEEDBACK_SCREENSHOT_DEBUG=false
# VISUAL_FEEDBACK_SCREENSHOT_MAX_BYTES=8388608
# VISUAL_FEEDBACK_SCREENSHOT_EDITOR=true
# ── Files ──────────────────────────────────────────────────────────────────────
# VISUAL_FEEDBACK_ATTACHMENTS_DISK=local
# VISUAL_FEEDBACK_ATTACHMENTS_DIR=visual-feedback
# VISUAL_FEEDBACK_ATTACHMENTS_MAX_FILES=5
# VISUAL_FEEDBACK_ATTACHMENTS_MAX_FILE_SIZE=5242880
# VISUAL_FEEDBACK_ATTACHMENTS_MAX_TOTAL_SIZE=15728640
# VISUAL_FEEDBACK_ATTACHMENTS_MAX_IMAGE_DIMENSION=15000
# VISUAL_FEEDBACK_ATTACHMENTS_MAX_IMAGE_PIXELS=100000000
# ── Abuse, privacy, retention ──────────────────────────────────────────────────
# VISUAL_FEEDBACK_ABUSE_DRIVER=builtin
# VISUAL_FEEDBACK_ABUSE_GLOBAL_RATE_LIMIT=1000
# VISUAL_FEEDBACK_ABUSE_RATE_LIMIT=30
# VISUAL_FEEDBACK_ABUSE_GUEST_RATE_LIMIT=5
# VISUAL_FEEDBACK_ABUSE_MIN_FILL_SECONDS=3
# VISUAL_FEEDBACK_ABUSE_ON_ERROR=open
# VISUAL_FEEDBACK_ABUSE_DRIVER_ON_ERROR=open
# VISUAL_FEEDBACK_PRIVACY_SOURCE=
# VISUAL_FEEDBACK_PRIVACY_URL=
# VISUAL_FEEDBACK_PRIVACY_DOCUMENT_KEY=privacy
# VISUAL_FEEDBACK_RETENTION_DAYS=
# VISUAL_FEEDBACK_RETENTION_ORPHAN_ATTACHMENTS_MIN_AGE=1440
# VISUAL_FEEDBACK_RETENTION_PRUNE_DELIVERED_ONLY=true
Four older variables are not in that block and still work, because they are published and sit in
existing .env files. Prefer the _MODE form above in anything new:
| Retired | Say this instead |
|---|---|
VISUAL_FEEDBACK_FIELD_SUBJECT=false | VISUAL_FEEDBACK_FIELD_SUBJECT_MODE=off |
VISUAL_FEEDBACK_FIELD_PHONE=true | VISUAL_FEEDBACK_FIELD_PHONE_MODE=optional |
VISUAL_FEEDBACK_GUEST_REQUIRE_NAME=true | VISUAL_FEEDBACK_FIELD_NAME_MODE=required |
VISUAL_FEEDBACK_GUEST_REQUIRE_EMAIL=true | VISUAL_FEEDBACK_FIELD_EMAIL_MODE=required |
Recipes
The table further down answers what exists. These answer what do I write to get X.
Send reports somewhere other than the default mailbox.
A single page can override that on the widget itself — <livewire:visual-feedback.report-widget recipient="[email protected]" /> — so a docs page and a billing page can reach different teams.
The address has to be declared first. A recipient decides where a report is delivered, and the widget's state travels through the browser, so the permitted set is configuration rather than something the page asserts. It is a list, not an environment variable, so it lives in the published config file:
// config/visual-feedback.php
'mail' => [
],
mail.to itself is always permitted, so an application that never uses the prop needs no list.
Mounting the widget with an address that is not permitted fails immediately, with a message naming
this key — that is a wiring mistake a developer can fix. An address that arrives from the browser
instead is answered with mail.to, silently and by the same rule.
Make the mail come from a name other than your application's.
VISUAL_FEEDBACK_MAIL_FROM_NAME="Acme Feedback"
Without the first one the sender is APP_NAME, the way Laravel's own mail config does it.
Insist on an email address, so you can reply.
VISUAL_FEEDBACK_FIELD_EMAIL_MODE=required
The box is then marked required in the markup and a submission without it is refused, in this package's own wording and in all seven shipped locales.
Ask for nothing but the report itself.
VISUAL_FEEDBACK_FIELD_NAME_MODE=off
VISUAL_FEEDBACK_FIELD_EMAIL_MODE=off
VISUAL_FEEDBACK_FIELD_SUBJECT_MODE=off
The form is then a category and a message. A field that is off is not merely hidden: a value for it is dropped on the way in, so a crafted request cannot smuggle one back.
Read rendered reports in laravel.log while developing.
MAIL_MAILER=log
VISUAL_FEEDBACK_MAIL_REQUIRE_DELIVERABLE_TRANSPORT=false
Since 0.8.0 the mail channel refuses a transport that accepts a message and drops it, because that produced a delivered receipt for a mail that never left. The second line says you mean it.
Keep feedback arriving while the application's own mail is silent.
MAIL_MAILER=array
VISUAL_FEEDBACK_MAIL_MAILER=smtp
A demo or staging tenant can keep its mail on array, so nobody it invents gets a message, and
still receive reports: the channel sends through the mailer named here and checks that one.
Turn screenshots off entirely.
VISUAL_FEEDBACK_SCREENSHOT_STRATEGY=off
No capture button, and the larger of the two bundles stops being shipped to the page.
Switch the widget off in one environment.
VISUAL_FEEDBACK_ENABLED=false
The widget renders nothing and the pipeline refuses every submission before it touches a rate limiter, a cache or a disk.
Environment variables
Everything below has a working default, so an install needs none of them. They exist for the settings that differ per environment — where reports go, which disk holds attachments, whether the capture asks for permission.
| Variable | Default | Effect |
|---|---|---|
VISUAL_FEEDBACK_ENABLED | true | master switch — see Turning it off |
VISUAL_FEEDBACK_REQUIRE_AUTHENTICATION | false | sign-in only: no trigger, no form and no accepted submission from a guest session — see Sign-in only |
VISUAL_FEEDBACK_UI_VARIANT | auto | which view tree renders: auto (WireKit when it is installed, plain otherwise), plain or wirekit |
VISUAL_FEEDBACK_UI_TRIGGER | fab | fab, inline or none (you place the trigger) |
VISUAL_FEEDBACK_UI_POSITION | bottom-end | which corner the floating button sits in, read in the writing direction |
VISUAL_FEEDBACK_UI_FAB_ICON | message | the glyph on the floating button in the WireKit tree: any icon alias WireKit declares, plus for the old one. A name WireKit does not know, or a WireKit older than 2.27, keeps the plus |
VISUAL_FEEDBACK_UI_ASSETS | published path | base URL of all four files, if you serve them yourself — see the note below |
VISUAL_FEEDBACK_UI_ASSETS_INTEGRITY | false | add a Subresource Integrity digest to both script tags — only meaningful with VISUAL_FEEDBACK_UI_ASSETS, and it can refuse the page: see the note below |
VISUAL_FEEDBACK_FIELD_SUBJECT_MODE | optional, or what the retired variable says | off, optional or required for the subject field |
VISUAL_FEEDBACK_FIELD_NAME_MODE | optional, or what the retired variable says | same three values for a guest's name |
VISUAL_FEEDBACK_FIELD_EMAIL_MODE | optional, or what the retired variable says | same three values for a guest's email |
VISUAL_FEEDBACK_FIELD_PHONE_MODE | off, or what the retired variable says | same three values for a guest's phone |
VISUAL_FEEDBACK_FIELD_SUBJECT_MAX_LENGTH | 150 | ceiling for the subject, in code points. Anything unreadable keeps the shipped value rather than dropping to zero |
VISUAL_FEEDBACK_FIELD_MESSAGE_MAX_LENGTH | 50000 | ceiling for the message body, in code points |
VISUAL_FEEDBACK_FIELD_NAME_MAX_LENGTH | 150 | ceiling for a guest's name |
VISUAL_FEEDBACK_FIELD_EMAIL_MAX_LENGTH | 254 | ceiling for a guest's address — the longest an address may be |
VISUAL_FEEDBACK_FIELD_PHONE_MAX_LENGTH | 32 | ceiling for a guest's phone number |
VISUAL_FEEDBACK_CONTEXT_MAX_VALUE_LENGTH | 2000 | ceiling for one collected metadata value; longer ones are truncated |
VISUAL_FEEDBACK_FIELD_SUBJECT | true | retired — say VISUAL_FEEDBACK_FIELD_SUBJECT_MODE=off instead. Still read |
VISUAL_FEEDBACK_FIELD_PHONE | false | retired — say VISUAL_FEEDBACK_FIELD_PHONE_MODE=optional instead. Still read |
VISUAL_FEEDBACK_GUEST_REQUIRE_NAME | false | retired — say VISUAL_FEEDBACK_FIELD_NAME_MODE=required instead. Still read |
VISUAL_FEEDBACK_GUEST_REQUIRE_EMAIL | false | retired — say VISUAL_FEEDBACK_FIELD_EMAIL_MODE=required instead. Still read |
VISUAL_FEEDBACK_ATTACHMENTS_DISK | local | the disk attachments are stored on — keep it private. An empty or unreadable value keeps local, never your application's default disk |
VISUAL_FEEDBACK_ATTACHMENTS_DIR | visual-feedback | directory on that disk. The orphan sweep owns everything in it, so a value that names the root of the disk, such as . or x/.., or climbs above it, is read as visual-feedback |
VISUAL_FEEDBACK_ATTACHMENTS_MAX_FILES | 5 | how many files one report may carry. 0 refuses attachments entirely |
VISUAL_FEEDBACK_ATTACHMENTS_MAX_FILE_SIZE | 5242880 | bytes per file — 5 MB |
VISUAL_FEEDBACK_ATTACHMENTS_MAX_TOTAL_SIZE | 15728640 | bytes for all uploads of one report — 15 MB. The screenshot is bounded separately and does not count towards this |
VISUAL_FEEDBACK_ATTACHMENTS_MAX_IMAGE_DIMENSION | 15000 | px on either edge, a decompression-bomb guard |
VISUAL_FEEDBACK_ATTACHMENTS_MAX_IMAGE_PIXELS | 100000000 | total pixels, the second half of that guard — 100 MP |
VISUAL_FEEDBACK_SCREENSHOT_STRATEGY | auto | auto, native, dom or off — see the capture cascade |
VISUAL_FEEDBACK_SCREENSHOT_SCALE | 2 | render scale, or device for the device pixel ratio |
VISUAL_FEEDBACK_SCREENSHOT_DARK_FALLBACK | #111827 | background for a page that declares none and is dark |
VISUAL_FEEDBACK_SCREENSHOT_DEBUG | false | the renderer's own logging — off in production |
VISUAL_FEEDBACK_SCREENSHOT_MAX_BYTES | 8388608 | bytes for the capture — 8 MB. A SERVER-side cap: the browser is not told, so a larger capture is uploaded and then refused. Lower it and a reporter can loop |
VISUAL_FEEDBACK_SCREENSHOT_EDITOR | true | the editor a reporter opens on a capture to crop it before attaching it. false takes the Edit button away, and the page never fetches the editor's file |
VISUAL_FEEDBACK_ABUSE_DRIVER | builtin | which abuse gate runs |
VISUAL_FEEDBACK_ABUSE_GLOBAL_RATE_LIMIT | 1000 | the ceiling for the whole application, per hour. 0 switches it off; anything unreadable keeps the shipped cap rather than removing it |
VISUAL_FEEDBACK_ABUSE_RATE_LIMIT | 30 | per authenticated user, per hour. A per-sender limit cannot be switched off: anything below 1 keeps the shipped 30 |
VISUAL_FEEDBACK_ABUSE_GUEST_RATE_LIMIT | 5 | per guest IP, per hour — IPv6 counted per /64, an IPv4 address in IPv6 notation as that IPv4 address. Anything below 1 keeps the shipped 5 |
VISUAL_FEEDBACK_ABUSE_MIN_FILL_SECONDS | 3 | how fast a form may be submitted before it reads as a bot. 0 switches the trap off |
VISUAL_FEEDBACK_ABUSE_ON_ERROR | open | what the builtin floor does when its own limiter errors — open lets the submission through, anything else refuses |
VISUAL_FEEDBACK_ABUSE_DRIVER_ON_ERROR | open | the same question for the additional driver, when abuse.drivers.<name>.on_error says nothing about it. That map is keyed by a name you choose and no environment variable can express a map, so this is how a host that does not publish the configuration hardens the one driver it runs |
VISUAL_FEEDBACK_PRIVACY_SOURCE | none | url, legal-consent, or a class name |
VISUAL_FEEDBACK_PRIVACY_URL | none | your policy page — required for url and for legal-consent; a class-name source supplies its own |
VISUAL_FEEDBACK_PRIVACY_DOCUMENT_KEY | privacy | which legal-consent document the wording is read from |
VISUAL_FEEDBACK_RETENTION_DAYS | none | how long a stored report is kept before visual-feedback:prune removes it. The package schedules nothing, so this deletes nothing until you schedule that command — see Privacy and retention |
VISUAL_FEEDBACK_RETENTION_ORPHAN_ATTACHMENTS_MIN_AGE | 1440 | minutes an unattached upload survives before pruning removes it — 24 h. Keep it above your queue's retry horizon, or a retry will look for a file that is gone |
VISUAL_FEEDBACK_RETENTION_PRUNE_DELIVERED_ONLY | true | holds a report back from pruning while one of its channels has not delivered it. false makes the retention window a hard ceiling — see Privacy and retention |
VISUAL_FEEDBACK_CHANNEL_MAIL | true | enable the mail channel |
VISUAL_FEEDBACK_CHANNEL_DATABASE | false | enable the database channel |
VISUAL_FEEDBACK_CHANNEL_WEBHOOK | false | enable the webhook channel |
VISUAL_FEEDBACK_MAIL_TO | none | where reports are mailed — without it the mail channel stays idle |
VISUAL_FEEDBACK_MAIL_MAILER | default mailer | the mailer from config/mail.php reports go through, when it should not be the application's default, mail.default. The deliverability check below reads this one |
VISUAL_FEEDBACK_MAIL_SUBJECT_EXCERPT | 60 | how much of the message goes into the subject line when the reporter left the subject empty. 0 keeps the category alone |
VISUAL_FEEDBACK_MAIL_REQUIRE_DELIVERABLE_TRANSPORT | true | skip the mail channel when its mailer resolves to a transport that accepts a message and drops it (log, array, or a failover chain that can fall through to one). Never applied while your application runs its tests |
VISUAL_FEEDBACK_MAIL_FROM_ADDRESS | app default | sender address |
VISUAL_FEEDBACK_MAIL_FROM_NAME | APP_NAME | sender name — without it the From header would be a bare address, which reads like an unattended relay rather than like your product |
VISUAL_FEEDBACK_MAIL_LOCALE | app locale | a fixed locale, or reporter for theirs |
VISUAL_FEEDBACK_MAIL_CONNECTION | default connection | queue connection for the mail job — the worker that carries it, not a database connection |
VISUAL_FEEDBACK_MAIL_QUEUE | default queue | queue for the mail job |
VISUAL_FEEDBACK_MAIL_TRIES | 3 | how often the mail job is tried before the delivery counts as failed. A value below 1 or one that is not a whole number falls back to 3, because Laravel reads 0 as no limit at all |
VISUAL_FEEDBACK_DATABASE_CONNECTION | default connection | queue connection for the database job |
VISUAL_FEEDBACK_DATABASE_QUEUE | default queue | queue for the database job |
VISUAL_FEEDBACK_DATABASE_TRIES | 3 | how often the database job is tried before the delivery counts as failed. A value below 1 or one that is not a whole number falls back to 3, because Laravel reads 0 as no limit at all |
VISUAL_FEEDBACK_WEBHOOK_URL | none | endpoint for the webhook channel |
VISUAL_FEEDBACK_WEBHOOK_SECRET | none | HMAC key the payload is signed with — required, or the channel is skipped |
VISUAL_FEEDBACK_WEBHOOK_INCLUDE_REPORTER | true | include reporter details in the payload |
VISUAL_FEEDBACK_WEBHOOK_CONNECTION | default connection | queue connection for the webhook job |
VISUAL_FEEDBACK_WEBHOOK_QUEUE | default queue | queue for the webhook job |
VISUAL_FEEDBACK_WEBHOOK_TRIES | 3 | how often the webhook job is tried before the delivery counts as failed. A value below 1 or one that is not a whole number falls back to 3, because Laravel reads 0 as no limit at all |
VISUAL_FEEDBACK_WEBHOOK_TIMEOUT | 5 | seconds before the delivery attempt is abandoned. Never 0 — most clients read that as wait forever |
Settings that live only in the config file
Most of what you will change has an environment variable. A handful does not, either because it is a value rather than a switch or because it is fine-tuning nobody reaches for twice. They are listed here so that "not in the table above" never has to mean "does not exist".
| Setting | Default | What it does |
|---|---|---|
screenshot.dark_fallback_color | #111827 | the color the DOM stage paints behind a page whose background is transparent while color-scheme is dark. Any background your page actually sets wins through the normal cascade; this is only the last resort |
screenshot.flatten_custom_elements | true | flatten custom elements before capture |
screenshot.iframe_placeholder | true | draw a same-size placeholder naming the host for a cross-origin iframe |
metadata.max_value_length | 2000 | the cap the metadata sanitizer applies to one collected value |
metadata.user_agent_max | 512 | the cap for the user-agent string specifically, which is the one that gets long |
metadata.url_query | false | keep the query of the reported url. Its fragment is never kept |
metadata.url_token_paths | MetadataSanitizer::TOKEN_PATHS | routes whose path is a credential; each * segment of the reported url is stored as {token}. See Privacy and retention |
Both screenshot booleans are real switches and do exactly what the table says: the
configuration is handed to the capture bundle, which skips the corresponding pass whenever one
of them is false. Open <dialog> elements are hidden unconditionally and need no key.
An <img src="*.svg"> is not fetched and inlined. It goes through the renderer's ordinary
image path, so a cross-origin SVG served without CORS headers is missing from the capture like
any other cross-origin image, and one served from your own origin is captured like any other
image. That is worth stating because an inlining pass is the thing people assume, and assuming
it hides a CORS problem behind a file format.
The attachments caps and the mail and webhook switches also have no environment variable,
and they are documented where the decision gets made rather than here — see
Integration contract and
Delivery channels.
Serving the bundles yourself
There are four files, and the base URL has to serve all of them.
visual-feedback-widget.iife.js is the small one that registers the Alpine components the
templates bind to, and it is requested on every page the widget renders on.
visual-feedback.iife.js is the capture state machine, requested only while
screenshot.strategy is not off. visual-feedback-renderer.iife.js is the DOM renderer —
about 276 KB, and fetched by the second file at capture time, so a visitor who never takes a
screenshot never downloads it. visual-feedback-editor.iife.js is the screenshot editor, fetched
by the second file the first time a reporter opens a capture to edit it.
The renderer's and the editor's URLs are derived from the script that loads them, so both have to
sit beside visual-feedback.iife.js under the same base. Upload all four. Without the renderer the
capture button reaches the point of taking the picture and reports a failure; without the editor
the Edit button says that the editor could not be loaded, and the capture can still be attached as
it is.
Upload only the capture bundle and the widget's own file 404s — which does not look like a
missing file from the outside. The components are simply never registered, so every control in
the widget does nothing. The capture bundle says so in the browser console, but nothing on
the server reports it: setting ui.assets tells the package the bundles are served
externally, so the staleness check that php artisan about runs
against the published copy is skipped by design. Verify all four URLs yourself after uploading; the
package cannot reach them.
VISUAL_FEEDBACK_UI_ASSETS takes a base URL, without a query string. Each rendered <script>
tag appends a cache-busting ?id= token of its own, derived from the installed package version,
and the capture bundle requests the renderer and the editor with tokens of their own as well, so the URLs in your
page source and in the browser's network panel are not byte-identical to the one you configured. That is expected
and nothing to correct — a Content-Security-Policy script-src source expression matches on the
path and ignores the query, so nothing needs changing there either.
The token solves one of the two ways a bundle goes wrong, and it is worth knowing which. It
handles republished but cached: after vendor:publish --tag=visual-feedback-assets --force
replaces the bytes on disk, the URL changes, so a browser or CDN holding an old file under a long
max-age fetches the new one.
It does not handle forgot to republish. If the copy under public/vendor/visual-feedback/
is older than the one in vendor/, the token busts the cache and loads the same stale file again.
Republishing after every upgrade of this package is still the only thing that prevents it — see
Installation.
Verifying what the foreign origin sends
VISUAL_FEEDBACK_UI_ASSETS_INTEGRITY=true adds a Subresource Integrity digest and
crossorigin="anonymous" to both script tags. The digest is sha384 over the bundles inside
the installed package, which is what makes it worth anything: it is the value a divergence is
measured against, so taking it from the copy under suspicion would prove nothing.
All four files are covered, including the two no tag loads. The renderer and the editor are
appended by the capture bundle rather than rendered as tags, so they cannot carry an attribute of
their own; their digests travel to them on the capture tag instead and are applied when each one is
fetched. That means the foreign origin has to allow CORS for both of them as well, because a digest
is only checked on a request made with crossorigin.
It does nothing while the bundles come from your own public/ — those are same-origin already,
and the digest would be checking a file against itself. It applies only where ui.assets points
at an origin you do not serve.
It is off by default because it can refuse the page, and that trade is yours to make. Turning it on is a statement that the origin mirrors this release's bundles byte for byte. A CDN that re-minifies, or that is still carrying the previous version, fails the check — the browser drops the script, and a widget whose bundle never loaded renders perfectly and does nothing. That failure is silent in exactly the way this package works hardest to prevent everywhere else, so it is not something to acquire by upgrading.
Turn it on after an upload, then reload the page once and check the console. If the bundle was refused the browser says so there, by name.