Skip to main content

Styling the UI

Required for every screen this package renders — the dashboard, the self-service portal and both publishable stubs.

WireKit ≥ 2.53, and this one is enforced. The shipped screens depend on behavior WireKit gained in named releases. All but the last of them fail quietly below their version — the page renders, it just tells the reader something untrue.

  • 2.12 — the dashboard and self-service tables sort their columns through WireKit's native keyboard-operable sort-action (WCAG 2.1.1). Below it the sort headers still render, but only as mouse targets.

  • 2.25 — the self-service payload-version select marks the stored version selected in the server render. Below it WireKit's select ignores value, so the field shows the first option — the empty "inherit" entry — for an endpoint that is pinned to a version, on the first paint and for every reader without JavaScript.

  • 2.27 — WireKit ships all seven locales this package ships, and wraps its own alert-dialog cancel label in __(). Below it, five of those seven fall back to English on a button that still renders — in the wrong language, with nothing turning red.

  • 2.37 — the geometry that makes an overlay an overlay (fixed inset-0 and its z-index) ships in WireKit's own stylesheet instead of existing only as Tailwind utilities your build has to compile. Below it, the shipped destructive confirmations — delete endpoint in the self-service portal, rotate secret in the management stub — render correctly styled, visible and enabled, while sitting in normal document flow: on a page taller than the viewport the confirm button is off-screen and the click never lands. No error, no log line, nothing to tell an operator why. Whether it happened depended on your Tailwind build, which is what the two registrations below are for — so a misconfiguration one paragraph away turned a delete button into a dead one.

  • 2.38 — an icon whose alias resolves onto a set nobody registered degrades to the inert placeholder instead of throwing. Below it that case took the page down: blade-icons installed without blade-heroicons is enough, and the shipped screens ask for heroicon-* names, so the dashboard and the portal answered 500 on every screen that draws an icon — which, through buttons and dropdowns, is every screen. The unknown-alias path already degraded; it was the resolved-alias-missing-set path that did not.

  • 2.44 — a code block becomes a landmark only when the caller gives it a name. Below it every code block was one, named after its language, so the self-service transform editor — which shows the sample payload and the transformed result side by side, both JSON — rendered two regions both called "json code". Two landmarks of one name is landmark-unique, a WCAG-mapped failure that an automated audit of your own application reports against your pages, not ours. The screen looks right either way: a duplicate region name is something a screen-reader rotor shows and a sighted reader never sees.

  • 2.51 — WireKit's pagination can turn a page inside a Livewire component, and it ships the two views (wirekit::pagination.livewire, …livewire-simple) that Livewire's paginationView() and paginationSimpleView() point at. The shipped screens use them, so this is the one entry on this list that does not fail quietly: below it the view name does not resolve and every paginating screen throws rather than misinforms. It earns its place anyway, because of what it replaced — before it, WireKit's pager could only page by navigating, which on a filtered table meant page two came back unfiltered and any component state not held in the URL was gone.

  • 2.53 — that same pager stopped dimming its inert boundary control with an opacity on top of an already-muted pair. Opacity applies to the ELEMENT, so text and background composite against the page together and the distance between them collapses: a 4.5:1 pair read at about 1.6:1 in the light theme. This package had removed exactly that line from its own control after measuring it, so adopting WireKit's pager at 2.51 would have quietly handed the property back.

2.53 is therefore the floor, and composer.json carries "conflict": {"pushery/wirekit": "<2.53"} so it is a fact rather than a claim. That refuses an older WireKit; it never installs one, so a headless host stays headless. Every occurrence of the number is held together by WirekitFloorContractTest — the constraint and the prose cannot drift apart.

The package ships no compiled stylesheet. Its views are Tailwind utilities over WireKit's design tokens: @wirekitStyles brings the tokens, and your Tailwind build compiles the utilities that consume them. That build has to be told where to look — for WireKit's components and for this package's views. Both source registrations are required; with either one missing the screens render unstyled.

That is the visible half. The half that cost a release: until WireKit 2.37 the geometry of every overlay came from those same compiled utilities, so a missing registration also left the confirmation dialogs in normal document flow — styled, visible, and unreachable below the fold. The floor above closes that case whatever your build scans; the registrations are still required for everything else on the screen.

/* resources/css/app.css */
@import 'tailwindcss';

/* This package's views. */
@import '../../vendor/pushery/webhooks-for-laravel/resources/css/webhooks.css';

/* WireKit's components (required by every WireKit consumer — see its install notes). */
@source '../../vendor/pushery/wirekit/resources/views/**/*.blade.php';

The icon set

Install the icon set the screens draw their empty states and primary actions against. Without it WireKit renders an inert placeholder where each icon would be — the pages still work, they simply lose their iconography:

composer require blade-ui-kit/blade-icons blade-ui-kit/blade-heroicons
They are a pair — half of it costs you the iconography

blade-icons is the renderer and blade-heroicons is the set the shipped screens ask for. With either one missing the screens draw WireKit's inert placeholder where an icon belongs: they render, they are simply without iconography, and both halves cost the same.

Installing the renderer alone is the easy accident — a host that already uses blade-icons for its own icons reads the second package as optional. Until WireKit 2.38 that combination took the page down rather than degrading. The enforced floor sits above that release, so what remains is missing icons.

php artisan webhooks:preflight names the missing half.

Dark mode

WireKit's dark tokens live behind a .dark class on the document root. Because the dashboard and the portal are the package's layouts, the package puts it there: ui.theme is auto by default, which mirrors the reader's system preference (and keeps mirroring it if they change it).

Pin it with WEBHOOKS_UI_THEME=light or =dark — which is also how you switch off the small inline head script under a strict Content-Security-Policy.

Two package-level custom properties retune the plot heights on the dashboard without forking a view: --wh-chart-height and --wh-sparkline-height.

On another UI kit entirely

Publish the views (--tag=webhooks-views, --tag=webhooks-dashboard-views, --tag=webhooks-self-service-views) and restyle them; the pagination control (webhooks::pagination) publishes with them. See the publishable tags reference.

Embedding in an app with its own asset pipeline and a strict CSP

The shipped layouts emit only WireKit's tokens (@wirekitStyles), so an app with its own Vite build has nowhere to load its compiled CSS — and its @source glob (above) has to reach vendor/ for the utilities to build at all. Rather than publishing and forking the layout, point ui.assets at a Blade partial and the layouts @include it in <head>:

// config/webhooks.php
'ui' => [
'assets' => 'webhooks-assets', // resources/views/webhooks-assets.blade.php: @vite(['resources/css/app.css'])
],

Under a strict Content-Security-Policy the package emits exactly two inline scripts in its own layouts, and one of them can be switched off:

ScriptEmitted whenCan it be dropped?
The theme = 'auto' dark-mode mirrorui.theme is autoYes — pin the theme with WEBHOOKS_UI_THEME=light/dark
The wire:submit guardalwaysNo — it is a constant, so allow it by its hash

The guard holds a Livewire form still until Livewire has bound it. A <form wire:submit> has no method, so before the scripts at the end of the body arrive, Enter sends the page to its own address as a GET, and every named field lands in the query string, the browser history and your access log. Its hash is the same on every response, so it goes into your policy once:

use Pushery\Webhooks\Support\LivewireSubmitGuard;

// e.g. with spatie/laravel-csp, or wherever your policy is built:
$policy->add(Directive::SCRIPT, LivewireSubmitGuard::cspHash()); // 'sha256-…'

The layouts also put the theme nonce on it when you configure one, but prefer the hash: a nonce stops matching the document's policy after wire:navigate and on a cached page.

Everything else the package runs in the browser is a file served from your own origin. The three Alpine components — the delivery drawer's focus trap, the secret panel's countdown and the endpoint form's focus handover — live in one script the package serves through its own route, and the views load it with @assets <script src="…" defer>. Under script-src 'self' it needs no nonce and no vendor:publish.

They are files rather than inline blocks on purpose: registering the behavior is what keeps it working under a policy without unsafe-eval, where Alpine parses directive attributes against a restricted grammar instead of evaluating them — an inline x-data object simply never runs there, with nothing in any server log to say so. It would take out the drawer's focus trap and leave the revealed secret on screen past its window.

So there are two ways through, and either one is enough on its own:

  • Pin the theme (WEBHOOKS_UI_THEME=light or =dark) and the only inline script left is the submit guard, which its hash admits.
  • Keep auto — the reader's system preference keeps being mirrored — and give the theme script a nonce, next to the guard's hash.

style-src — the one thing that is not solved

The scripts are handled. Styles are not, and the package says so rather than letting you find out from an empty chart. The two dashboard plots — the hourly activity chart and the p95 sparkline — set each bar's height with a style attribute, because the height is a number that comes from your data and a utility class cannot carry one.

A nonce does not help here, and that is not a package limitation: a nonce applies to <style> elements, never to style attributes. Under style-src 'self' without 'unsafe-inline' a browser drops them, and the bars collapse — an operator reads an empty plot as "nothing happened in this window", with nothing in any server log to say otherwise.

So on a policy that strict, either allow 'unsafe-inline' for style-src (or hash the attributes with style-src-attr 'unsafe-hashes'), or leave the dashboard out of the routes that policy covers. Every constant color has been moved into a class, so what is left in an attribute is only the geometry.

Note that WireKit itself emits one inline <style> block for its font custom properties, which the same policy discards — so a strict style-src affects more than these two panels.

Because a per-request nonce is a closure and a closure in config breaks php artisan config:cache, register it from a service provider instead of the config file:

// A service provider's boot():
use Pushery\Webhooks\Support\UiTheme;

UiTheme::resolveNonceUsing(fn () => \Illuminate\Support\Facades\Vite::cspNonce());

(config('webhooks.ui.csp_nonce') still accepts a static string for the rare case a fixed nonce is enough.)