---
title: Integration Guide
description: Required setup, optional dependencies, asset publishing, and verification checklist for embedding WireKit in your app
visibility: guest
draft: false
---

# Integration Guide

The complete setup reference: required dependencies, browser baseline, asset publishing, optional features, and a verification checklist. The [Getting Started](/getting-started) recipe gets you to a working install in the common case — come here when you need to understand the moving parts in detail or debug a specific step.

## Browser Support

WireKit's supported-browser baseline is **pinned to [Tailwind CSS v4's official requirements](https://tailwindcss.com/docs/compatibility)** — whenever Tailwind raises its baseline, WireKit follows in the same release. There is no separate "WireKit browser matrix" to track.

| Browser | Minimum version | Released |
|---------|-----------------|----------|
| **Chrome** | 111 | March 2023 |
| **Edge** | 111 | March 2023 (Chromium-based) |
| **Safari** | 16.4 | March 2023 |
| **Firefox** | 128 | July 2024 |

**What this means for your app:**

- If your application already runs on Tailwind CSS v4 (which is a required dependency), you are automatically within WireKit's supported range. No separate compatibility check is needed.
- WireKit ships **no polyfills**, **no vendor-prefix fallbacks**, and **no shims** for browsers that Tailwind has dropped. Components may visually degrade or break in older browsers.
- WireKit freely uses modern CSS features that are covered by the Tailwind baseline — including `@property`, `color-mix()`, `contain: inline-size`, and `min()`/`max()`. You can rely on these in your own code inside WireKit components.
- Not every modern feature is covered. `@starting-style`, `field-sizing: content`, `text-wrap: balance`, native CSS nesting and `round()` each first shipped above one of the minimum versions in the table — they are progressive enhancement, not floor. Where WireKit uses one, it sits behind an `@supports` test and adds polish that nothing depends on, so a browser at the minimum version renders the component without it. Detect them the same way in your own code instead of assuming them.
- If your audience still includes users on older browsers (e.g. corporate intranets pinned to Chrome 108), WireKit is not the right fit. Consider staying on Tailwind v3 + a compatible UI kit.

::: tip
The only officially supported configuration is **Tailwind v4 + WireKit on a browser from the table above**. Any other combination is unsupported and untested.
:::

## Required Setup

### 1. Composer

```bash
# 1. Pull WireKit + Livewire v4 (a hard dependency — Alpine.js is bundled
#    with Livewire, so no separate npm install for Alpine is needed). The
#    two icon packages ensure <x-wirekit::icon> renders the heroicons set
#    used internally by buttons / dropdowns / overlays.
composer require pushery/wirekit blade-ui-kit/blade-icons blade-ui-kit/blade-heroicons
```

### 2. Blade Directives

Add both directives to your app layout, `@wirekitScripts` before `@livewireScripts`:

```blade
<head>
    @wirekitStyles   {{-- Design tokens, CSS variables, keyframe animations --}}
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
    {{ $slot }}
    @wirekitScripts  {{-- WireKit Alpine component registrations (BEFORE @livewireScripts) --}}
    @livewireScripts {{-- Boots Alpine + Livewire's runtime --}}
</body>
```

**The invariant is about EXECUTION, not tag order.** WireKit's Alpine factories have to be registered before `Alpine.start()` runs, and `@wirekitScripts` emits its tag with `defer` — a deferred script runs after parsing and before `DOMContentLoaded`, wherever its tag sits. In a Livewire application the requirement is therefore met either way, and swapping the two directives changes nothing measurable.

The order above is still the one to write: it says what depends on what, and it is the arrangement every other page here assumes.

**Where it genuinely breaks is anything that runs the bundle LATE** — an `async` attribute instead of `defer`, a file injected at runtime, a bundler that drops the `defer`, or a non-Livewire setup that starts Alpine earlier. If every `wirekit*` factory is missing at once, look there before you look at the order of two lines.

**`@wirekitStyles`** loads `wirekit.css` — contains:

- ~70 CSS custom properties (colors, spacing, radius, shadows, fonts)
- Dark mode token switching via `.dark` class on `<html>`
- Keyframe animations (`wk-skeleton`, `wk-progress-indeterminate`)
- `prefers-reduced-motion` guard for all animations
- `[x-cloak] { display: none !important }` — see below

::: info `x-cloak` is covered — the rule ships with WireKit
Alpine hides nothing for `x-cloak`; it removes the attribute once it has initialized, and the hiding has to come from CSS. Many WireKit components rely on it to stay invisible until Alpine boots, so `wirekit.css` ships the rule and `@wirekitStyles` is what delivers it.

Worth knowing if you go looking for it: `@wirekitStyles` emits its own `<link>` to `wirekit.css`. The rule therefore lives in that file — **not** in your Vite bundle, and grepping `public/build/assets/app-*.css` for it finds nothing whether everything is wired up correctly or not. Check the served `wirekit.css` instead, or the published copy at `public/vendor/wirekit/wirekit.css`.

You only need to write the rule yourself if you deliberately load neither — no `@wirekitStyles` and no import of `wirekit.css`. In that case you have no design tokens either, and cloaked elements flashing on load is the smaller of the two symptoms.
:::

**`@wirekitScripts`** loads `wirekit.js` — contains:

- All Alpine.js component registrations (`wirekitModal`, `wirekitDropdown`, etc.)
- Floating UI positioning (tooltips, popovers, dropdowns)
- Focus trap utility (modals, drawers, alert dialogs)
- Overlay scroll lock (reference-counted)

#### The overlay container

Every teleported panel — modal, drawer, dropdown, tooltip — lands inside one labeled region, `<div id="wk-overlay-root" role="region" aria-label="…">`. Without it those panels sit outside every landmark, which an accessibility audit flags and a screen-reader user experiences as content that belongs nowhere.

**You do not have to do anything.** `@wirekitScripts` creates the container, names it in the page's language, and re-creates it after a `wire:navigate`.

**Writing it yourself is supported, and it is the sturdier of the two routes.** Put it in your layout and it sits in the markup Livewire morphs, so it is never briefly absent and no navigation timing has to be right:

```blade
{{-- 1. Optional. WireKit adopts this container instead of creating its own, and
        fills in the role and the localized name if you left them out. --}}
<div id="wk-overlay-root"></div>

{{-- 2. The scripts still come after it. --}}
@wirekitScripts
```

A `role` or an `aria-label` you write yourself is left alone — including `aria-labelledby`, so a container pointed at a heading keeps its name. Only what is missing gets filled in, because a label you wrote is one you translated.

**To supply the name yourself** — for a locale WireKit does not ship, or wording your product prefers — put it on any `<script>` tag in the document:

```blade
{{-- 3. Read once when the container is built. Any script tag works; this one
        exists only to carry the attribute. --}}
<script data-wk-overlay-label="@lang('Overlays')"></script>
```

### The routes that serve WireKit's own files

`@wirekitStyles` and `@wirekitScripts` fall back to package routes when the assets are not published. Those routes **run no middleware**, and that is deliberate rather than an oversight.

They serve a static file from the package directory: no session is read, no user is looked at, and the reply declares `public, max-age=31536000, immutable`. Under Laravel's `web` group every one of those replies also carried a session cookie — and a shared cache invited to keep a response for a year may hand it to the next visitor with the cookie still attached. Removing the group is what makes the year-long directive honest and the files genuinely cacheable by a CDN.

**If you need middleware on them, name it** in `wirekit.assets.middleware`:

```php
// 1. config/wirekit.php — an empty array (the default) means no middleware.
'assets' => [
    'middleware' => ['throttle:asset-serving'],
],
```

A bare string works too and is cast for you, so `'middleware' => 'web'` does not silently produce a route with none.

**Before you add `web` back:** it is the one value that reintroduces the cookie. If what you actually want is a rate limit, a geo-block or a log line, name that middleware instead — the session is almost never the thing you were after.

Publishing the assets (`vendor:publish --tag=wirekit-assets`) makes the question moot for production: the files are then served by your web server and never reach Laravel at all.

### 3. Tailwind CSS v4 Source Scanning

**CRITICAL:** WireKit components use Tailwind utility classes internally. Your app's Tailwind build MUST scan WireKit's Blade templates, or the component styles will be missing.

In your `resources/css/app.css`:

```css
/* 1. Pull in Tailwind v4 — provides `@source`, `@theme`, and the
      utility-class compiler. */
@import "tailwindcss";

/* 2. Register WireKit's published Blade templates as a Tailwind source
      directory. Without this line, the utility classes WireKit components
      emit at runtime never reach the compiled CSS — buttons render
      without backgrounds, inputs without padding. */
@source "../../vendor/pushery/wirekit/resources/views/**/*.blade.php";
```

**Without this line**, components render as unstyled HTML — tables have no borders, inputs have no padding, buttons have no backgrounds.

> **Tip:** Both setup paths work in v1.3.0+:
>
> 1. **`@wirekitStyles` Blade directive** (the path documented above) — emits a `<link>` tag straight to the package's compiled CSS. Fastest path, no Tailwind compile step required.
> 2. **`@import '../../vendor/pushery/wirekit/dist/wirekit.css'`** in `resources/css/app.css` — Tailwind v4 picks up the variables on its next compile. Useful when you want a single bundled CSS file from Vite.
>
> Either path resolves; pick whichever fits your build pipeline. Pre-v1.3.0 versions used Tailwind's `@theme {}` compiler directive, which browsers skipped as an unknown at-rule when loaded via `<link>` — that's now fixed (the file ships with `:root {}` / `.dark {}` blocks directly).

### 4. Alpine.js — comes via Livewire v4

WireKit declares `livewire/livewire ^4.0` as a hard composer dependency. Livewire v4 bundles Alpine.js and starts it for you when `@livewireScripts` runs — so a separate Alpine install is **not needed and not recommended**:

- ❌ Do NOT `npm install alpinejs`
- ❌ Do NOT `import Alpine from 'alpinejs'` in your `app.js`
- ❌ Do NOT call `Alpine.start()` manually
- ✅ Just emit `@wirekitScripts` THEN `@livewireScripts` in your layout (per Step 2). Livewire's bundle starts Alpine; WireKit's components register on `alpine:init` before that happens because the directives execute in document order.

The full reasoning + a complete copy-pasteable layout file lives on the [Getting Started](/getting-started#layout-setup) page.

### Dark-mode `dark:` utilities in your own markup

WireKit's components switch their colors automatically via the `.dark` class — the `--color-wk-*` tokens flip in the `.dark {}` block of `wirekit.css`, so you never write `dark:` utilities on a WireKit component.

But if **your own** markup uses Tailwind `dark:` utilities (e.g. `<div class="dark:bg-slate-800">`), add one line to `resources/css/app.css` so they follow the `.dark` **class** your theme toggle sets on `<html>` — not the OS `prefers-color-scheme`:

```css
/* 1. Pull in Tailwind v4. */
@import "tailwindcss";

/* 2. Make Tailwind's `dark:` variant key off a `.dark` CLASS on a parent
      (the same class WireKit + your toggle set on <html>), not the OS-level
      prefers-color-scheme. Without this, your own `dark:` utilities react
      only to the OS setting, so a manual light/dark switch appears to do
      nothing for them. */
@custom-variant dark (&:where(.dark, .dark *));
```

WireKit ships its own `@custom-variant dark` inside `wirekit.css`, but that file arrives as a pre-compiled `<link>` (via `@wirekitStyles`) and never reaches **your** Tailwind build — so your build still defaults `dark:` to `prefers-color-scheme`. This only affects `dark:` utilities you author yourself; WireKit components are unaffected (they don't use `dark:`).

### Styles not applying?

If a component renders correct HTML but looks unstyled, the cause is your app's asset loading, not WireKit. Check, in order:

1. **`@source` line present** (Step 3) — Tailwind must scan WireKit's Blade templates, or the utility classes the components use are never generated. Verify with `grep @source resources/css/app.css`.
2. **`@wirekitStyles` in your layout** (Step 2) — it emits the `<link>` to `wirekit.css`, which defines the `--*-wk-*` tokens. Without it, components render against undefined tokens.
3. **Fresh build** — Tailwind only generates classes it has scanned. After adding the `@source` line (or any new arbitrary-value token class), restart `npm run dev` or run `npm run build`; a dev server started before the change serves stale cached CSS.
4. **Stale published assets** — if you ran `php artisan vendor:publish --tag=wirekit-assets` once, `@wirekitStyles` keeps serving that published copy as long as its file timestamp is newer than the package `dist/`. Re-publish after a WireKit upgrade, or delete `public/vendor/wirekit/` to fall back to the always-current package copy.

`php artisan wirekit:doctor` checks the first three automatically.

## Dark Mode (Optional)

WireKit's tokens switch on a `.dark` class on `<html>`. If your app offers dark
mode, add one directive and one component rather than writing the plumbing:

```blade
<head>
    {{-- 1. BEFORE your stylesheet: applies the reader's stored theme before the
           first paint. Without it the page paints light and then turns dark in
           front of them. It is inline and synchronous for exactly that reason —
           moving it into a bundle brings the flash straight back. --}}
    @wirekitThemeScript

    {{-- 2. Your WireKit styles, as usual. --}}
    @wirekitStyles
</head>

<body>
    {{-- 3. The control, wherever it belongs in your chrome. --}}
    <x-wirekit::theme-controller />
</body>
```

Running a Content Security Policy? Pass your nonce: `@wirekitThemeScript($nonce)`.

Full options, variants and the storage key: [theme controller](/components/theme-controller).

## Optional Dependencies

WireKit's core has no front-end dependencies, but four components light up when you add a peer dependency. Install each **only if you use the component it powers** — every one degrades gracefully when its dependency is absent (the chart and map show an in-place advisory, the editor falls back to a plain textarea, the QR code is simply skipped).

| Component | Peer dependency | Install |
|-----------|-----------------|---------|
| [`<x-wirekit::chart>`](/components/chart) | Chart.js *or* ApexCharts | `npm install chart.js` |
| [`<x-wirekit::qr-code>`](/components/qr-code) | bacon/bacon-qr-code | `composer require bacon/bacon-qr-code` |
| [`<x-wirekit::editor>`](/components/editor) | Tiptap | `npm install @tiptap/core @tiptap/starter-kit` |
| [`<x-wirekit::map>`](/components/map) | MapLibre GL *or* Leaflet | `npm install maplibre-gl` |

`php artisan wirekit:doctor` lists these too — Chart.js by config + bacon-qr-code by package detection, and the editor/map engines as a reminder (they're browser globals a PHP command can't probe).

### Chart.js (for `<x-wirekit::chart>`)

```bash
npm install chart.js
```

```javascript
// resources/js/app.js
//
// 1. Pull Chart.js + the registerables bundle (the controllers, scales,
//    plugins, and elements that ship in the chart.js package).
import { Chart, registerables } from 'chart.js';

// 2. Register everything globally so any chart type (bar, line, doughnut,
//    radar, …) works without per-call imports.
Chart.register(...registerables);

// 3. Expose Chart on window so WireKit's `wirekitChart` Alpine factory
//    can find the constructor at mount-time. The factory looks at
//    `window.Chart`; if it's not there, charts render the "Chart.js is
//    not loaded" placeholder.
window.Chart = Chart;
```

Also set in `config/wirekit.php`:

```php
'charts' => [
    'library' => 'chartjs',
],
```

Chart.js dark mode works automatically — WireKit's Alpine component uses a `MutationObserver` to re-read CSS variables when the `.dark` class toggles.

### QR Code (for `<x-wirekit::qr-code>`)

```bash
composer require bacon/bacon-qr-code
```

No configuration needed — the component auto-detects the package.

### Rich-Text Editor (for `<x-wirekit::editor>`)

```bash
npm install @tiptap/core @tiptap/starter-kit
```

[`<x-wirekit::editor>`](/components/editor) is a thin Alpine adapter around a ProseMirror editor ([Tiptap](https://tiptap.dev) recommended). Expose a `window.wirekitEditor(config)` factory (the legacy `window.tiptapEditor` name still works as a deprecated alias) and the editor mounts the rich-text surface; without it, the editor degrades to a plain `<textarea>` (still `wire:model`-bound and submittable) and logs a one-time console hint. The factory contract, toolbar presets, and `wire:model` wiring live on the dedicated [Editor component page](/components/editor).

### Map (for `<x-wirekit::map>`)

```bash
npm install maplibre-gl   # or: npm install leaflet
```

[`<x-wirekit::map>`](/components/map) renders through whichever engine you load on `window` — [MapLibre GL](https://maplibre.org) (`window.maplibregl`) or [Leaflet](https://leafletjs.com) (`window.L`). Without an engine it degrades to an accessible marker list. Load the engine **only on routes that show a map** — MapLibre GL is large, so a global import inflates every page's bundle (the [Map component page](/components/map) shows the lazy-load pattern).

### Stackable Heroicons Extensions

WireKit ships two stackable extension presets — `heroicons-app` (app-state aliases like `arrow-up`, `bell`, `lock`) and `heroicons-marketing` (landing-page aliases like `sparkles`, `rocket-launch`, `chart-bar`) — that add aliases on top of any base preset without an extra package install.

Activation is one config change. The full setup walk-through, alias menu, resolution-order rules, and stacking patterns live on the dedicated [Icon component page](/components/icon#stackable-presets-marketing-aliases).

## Asset Publishing

Publishing assets is **optional but recommended for production** — it lets your web server (nginx, Apache) serve the CSS and JS files directly instead of routing every request through PHP.

```bash
php artisan vendor:publish --tag=wirekit-assets
```

This copies `wirekit.css`, `wirekit.js`, `wirekit.core.js`, `wirekit-apex.js` (the optional ApexCharts adapter glue — non-MIT, see [`apexcharts.com/license`](https://apexcharts.com/license/)), `wirekit-tiptap.js` (the optional rich-text editor adapter glue — see "Choosing a JS bundle" below), `wirekit-optimistic.js` (the optional Optimistic UI factory — see "Choosing a JS bundle" below), `wirekit-alpine.js` (the optional self-contained bundle — see "Choosing a JS bundle" below), and `wirekit-alpine.csp.js` (its Content-Security-Policy twin) from the package into `public/vendor/wirekit/`. The `@wirekitStyles` and `@wirekitScripts` directives detect published assets automatically and prefer them over the route-based fallback.

### Choosing a JS bundle

WireKit ships several JS bundles, each with a specific developer story. Pick exactly one **primary** bundle (the first three — they're mutually exclusive); the **adapter** bundles below load ALONGSIDE your chosen primary when you opt into the feature they cover.

| Bundle | Size (raw) | When to load |
|---|---|---|
| `wirekit.js` | ~219 KB | Your app already runs Alpine.js and registers WireKit's components yourself. The default for Laravel + Livewire setups. |
| `wirekit.core.js` | ~13 KB | You only need the chart component, no overlays. Smallest footprint. |
| `wirekit-alpine.js` | ~264 KB | You want a self-contained drop-in: bundles Alpine.js core + every WireKit component, registers all of them, calls `Alpine.start()` automatically. Ideal for isolated preview iframes, sample landing pages, or any context where you don't want a separate Alpine pipeline. |
| `wirekit-apex.js` | ~21 KB | Optional ApexCharts adapter glue. Loaded ALONGSIDE one of the above when you've set `wirekit.charts.library => 'apexcharts'`. |
| `wirekit-optimistic.js` | ~8 KB | Optional Optimistic UI factory (`wirekitOptimistic`). Loaded ALONGSIDE whichever bundle you picked, when you use a component's `optimistic` prop. Deliberately in **no** other bundle: loading this file is how you opt into its announcement behavior, so apps that don't use it pay nothing. |
| `wirekit-alpine.csp.js` | ~279 KB | The same self-contained drop-in, built against Alpine's Content-Security-Policy distribution — for a policy without `script-src 'unsafe-eval'`. Set `wirekit.scripts.bundle` to `csp` and do NOT also load your own Alpine; this bundle brings one. See [Content Security Policy](#content-security-policy) below. |
| `wirekit-tiptap.js` | ~7 KB | Optional rich-text editor adapter glue (`wirekitEditor` factory). Loaded ALONGSIDE `wirekit.core.js` when you want `<x-wirekit::editor>` without the full overlay bundle. Redundant with `wirekit.js` / `wirekit-alpine.js` — those already include the editor. |

`wirekit-alpine.js` and `wirekit.js` are mutually compatible — loading both produces a console warning and the second one skips its own Alpine registration. But you should pick exactly one for clarity.

> **Tip:** Add `public/vendor/wirekit/` to your `.gitignore` — these files are rebuilt on deploy via `vendor:publish` and should not be committed to version control.

### Cache Busting

Every URL the directives generate ends in `?v={filemtime}` so browsers automatically pick up new content on every deploy. You never need to hard-reload or clear the browser cache manually after a WireKit update.

Example output:

```html
<link rel="stylesheet" href="/vendor/wirekit/wirekit.css?v=1776089069">
<script src="/vendor/wirekit/wirekit.js?v=1776089069" defer></script>
```

Because every URL is fingerprinted this way, the package's built-in asset route (`/wirekit/wirekit.css`) responds with `Cache-Control: public, max-age=31536000, immutable` — standard fingerprinted-asset caching. Content changes always produce a new URL, so a year-long browser cache is safe.

### The asset routes run no middleware

The routes serving WireKit's own CSS, JS and fonts are registered with **no middleware group**. Their handlers read a file from the package directory — they use no session, no CSRF token, no authentication and no route-model binding.

This matters for the header above. Inside a group like `web`, `StartSession` runs for every stylesheet hit and the session cookie leaves on the response — so a reply that says `public, max-age=31536000, immutable` would also say `Set-Cookie`. A shared cache is invited to keep that reply for a year and may hand it to the next visitor with the cookie attached. Most shared caches refuse a response carrying `Set-Cookie`, but that is a per-vendor default you do not control rather than a property of the response.

The practical effect is the one the year-long directive was written for: without the cookie, a CDN can actually cache these files. With it, most CDNs decline them — the header would promise what the cookie prevents.

::: tip
Nothing is required of you here — this is the default. An application that serves the assets from `public/vendor/wirekit/` after `vendor:publish` bypasses these routes entirely, so the change is invisible to it.
:::

If your application genuinely needs middleware on them — a security-header layer or HTTPS enforcement you apply everywhere is the realistic case — name it explicitly:

```php
// config/wirekit.php

// 1. Middleware for the package's own asset routes. Empty by default.
// 2. Both a string and a list are accepted.
// 3. Anything that starts a session brings the cookie back with it, so the
//    responses stop being safely cacheable by a shared cache.
'assets' => [
    'middleware' => ['secure-headers'],
],
```

### Automatic Staleness Detection

Laravel's `vendor:publish` is **non-overwriting by default** — running it a second time without `--force` silently skips existing files. This means that after a `composer update pushery/wirekit`, your `public/vendor/wirekit/wirekit.css` would normally stay frozen at whatever version you first published.

WireKit handles this automatically. The Blade directives compare the published copy's `filemtime()` against the package's `dist/wirekit.css` and transparently fall back to route-based serving whenever the published copy is older than the package source. Your users always see the latest version, even if you forget to re-publish.

| State | Directive behavior |
| --- | --- |
| No published copy exists | Routes through `/wirekit/wirekit.css` (served by PHP from `vendor/pushery/wirekit/dist/`) |
| Published copy ≥ package source | Fast path: `<link href="/vendor/wirekit/wirekit.css?v=…">` (served by web server) |
| Published copy < package source (stale) | Auto-fallback to the route so users see fresh content |

After a `composer update pushery/wirekit`, you can optionally re-publish to restore the fast path:

```bash
php artisan vendor:publish --tag=wirekit-assets --force
```

This is a **performance optimization**, not a correctness requirement — if you forget, WireKit still serves the latest version via the route fallback. The `--force` flag is required because `vendor:publish` does not overwrite existing files without it.

### Deploy Checklist — wire `vendor:publish --force` into your deploy hook

::: warning Required for production deploys
WireKit's default `wirekit:install` adds `/public/vendor/wirekit` to your `.gitignore` (this is the Filament-style pattern — published assets are build artifacts, not source). That means after every deploy the directory is empty until your deploy pipeline runs `vendor:publish` again. **Add `php artisan vendor:publish --tag=wirekit-assets --force` to your post-deploy hook.** Without it, the route-based fallback still serves the assets correctly — but every request goes through PHP instead of being served directly by your web server, so the page-load cost goes up.
:::

A WireKit deploy needs **two** asset steps: `npm run build` (compiles your app CSS —
including the WireKit utility classes its `@source` line pulls in) and
`vendor:publish` (copies WireKit's own CSS/JS bundles). The exact wiring depends on
your hosting provider; the most common shapes:

```bash
# Laravel Forge — Site → Deploy Script
cd $FORGE_SITE_PATH
git pull origin $FORGE_SITE_BRANCH
$FORGE_COMPOSER install --no-interaction --prefer-dist --optimize-autoloader

# 1. Build the frontend. wirekit:install added a Tailwind `@source` for WireKit's
#    templates, so this is what compiles WireKit's utility classes into your app
#    CSS — skip it and the deploy renders unstyled.
npm ci && npm run build

# 2. Publish WireKit's own asset bundles to public/vendor/wirekit/.
#    --force is required because vendor:publish is non-overwriting by default.
$FORGE_PHP artisan vendor:publish --tag=wirekit-assets --force

$FORGE_PHP artisan migrate --force
```

```bash
# Envoyer / Deployer / generic post-deploy script
# 1. Compile app CSS (incl. WireKit's @source utility classes).
npm ci && npm run build
# 2. Publish WireKit's own bundles (--force overwrites stale copies).
php artisan vendor:publish --tag=wirekit-assets --force
```

```yaml
# GitHub Actions deploy step
- name: Build assets + publish WireKit
  run: |
    npm ci && npm run build                                 # 1. app CSS incl. WireKit @source classes
    php artisan vendor:publish --tag=wirekit-assets --force # 2. WireKit's own bundles
```

If you committed your published assets (you used `wirekit:install --no-gitignore` at install time), you don't need this step — your `git pull` already restored the files. The deploy hook is only required when `public/vendor/wirekit/` is gitignored, which is the default.

::: info How to check if you forgot
`php artisan wirekit:doctor` ships a check that fails when `public/vendor/wirekit/` is empty AND `wirekit-assets` is published-by-config. The failure message includes the exact `vendor:publish --force` command to run. Wire the doctor into a post-deploy smoke test (e.g. `php artisan wirekit:doctor || exit 1` after the publish step) to catch a missed deploy hook on the next deploy instead of waiting for a user to report unstyled output.
:::

## Verification Checklist

WireKit ships an artisan command that checks all integration requirements:

```bash
php artisan wirekit:doctor
```

The aliased `php artisan wirekit:verify` resolves to the same command — pick whichever reads better in your scripts.

Output:

```text
WireKit Integration Check

  ✓ wirekit.css published
  ✓ wirekit.js published
  ✓ wirekit.css is up to date
  ✓ wirekit.js is up to date
  ✓ Tailwind @source includes WireKit templates
  ✓ config/wirekit.php published
  ✓ @wirekitStyles directive in layout
  ✓ @wirekitScripts directive in layout
  ✓ @wirekitScripts is before @livewireScripts
  ✓ Alpine.js detected
  ✓ JS bundle configured: full
  ✓ Font assets published
  ✓ Chart.js adapter configured
  ✓ bacon/bacon-qr-code installed

  14 passed, 0 warnings, 0 failed

All checks passed.
```

The command checks 14 areas:

1. **wirekit.css published** — CSS design tokens in `public/vendor/wirekit/`
2. **wirekit.js published** — Alpine.js component registrations
3. **CSS freshness** — published asset matches package source (MD5)
4. **JS freshness** — published asset matches package source (MD5)
5. **Tailwind @source** — `resources/css/app.css` scans WireKit Blade templates
6. **config/wirekit.php** — config published for customization
7. **@wirekitStyles** — Blade directive present in layout `<head>`
8. **@wirekitScripts** — Blade directive present in layout `<body>`
9. **Directive order** — `@wirekitScripts` before `@livewireScripts`
10. **Alpine.js** — detected in JS entry files or layout (CDN)
11. **Bundle config** — `wirekit.scripts.bundle` is valid (`full` or `core`)
12. **Published views** — warns if overridden views may be outdated
13. **Font assets** — published if custom fonts are configured
14. **Optional deps** — Chart.js adapter, QR Code package (info only)

The command returns exit code `1` on failure — use it in CI or as a Claude Code session-start hook:

```jsonc
// .claude/settings.json (in the developer app)
{
  "hooks": {
    "SessionStart": [
      {
        "command": "php artisan wirekit:verify --no-ansi 2>&1 || true",
        "description": "Check WireKit integration on session start"
      }
    ]
  }
}
```

## Responsibility Boundaries

| Concern | Responsible |
|---------|-------------|
| Component Blade templates | `pushery/wirekit` |
| CSS design tokens (`wirekit.css`) | `pushery/wirekit` |
| Alpine.js components (`wirekit.js`) | `pushery/wirekit` |
| Documentation content (`docs/`) | `pushery/wirekit` |
| Loading wirekit.css / wirekit.js | **Developer app** |
| Tailwind `@source` scanning config | **Developer app** |
| Alpine.js installation | **Developer app** |
| Chart.js / QR Code installation | **Developer app** |
| App layout, routing, CSS overrides | **Developer app** |
| Preview container styling | **Developer app** |

**Rule of thumb:** If a component renders correct HTML but looks unstyled or non-interactive, the issue is in your app's asset loading — not in WireKit.

## Motion

Motion follows the operating system by default: when a visitor has asked for reduced motion, transitions collapse and components that animate on their own — a carousel advancing, a chart drawing in, a streamed response appearing token by token — switch to instant.

If your application holds its own motion setting, say so on `<html>` and it wins:

```blade
<html lang="en" data-reduce-motion="{{ auth()->user()?->reduceMotion ? 'reduce' : 'no-preference' }}">
```

| Value | Meaning |
| --- | --- |
| `reduce` | Reduce motion whatever the operating system says |
| `no-preference` | Do **not** reduce, even though the operating system asks |
| attribute absent | Follow the operating system — the default |

The middle value is the reason the attribute exists. A media query can express two states, and someone who enabled the OS setting for an unrelated reason has no way to say "not here" — previously they would have needed to out-specify the library's own rules and add `!important` to do it.

Both halves of the library read this one attribute: the stylesheet for transitions and animations, and the components for the decisions CSS cannot make. They cannot disagree on the same page.

### Increased contrast

The same three-state shape, for the preference that had no answer here at all. `prefers-contrast` is an OS setting, and an application that offers its own — an account preference, a toggle in a dialog — needs to be able to override it in both directions:

```html
<!-- 1. The reader asked for more contrast. Wins over the OS setting. -->
<html data-contrast="more">

<!-- 2. The reader declined. Also wins over the OS setting — this is the state a
        media query cannot express, and the reason an attribute exists. -->
<html data-contrast="no-preference">

<!-- 3. Neither attribute: follow `prefers-contrast`. -->
<html>
```

It affects the muted end of the palette — helper text, placeholders, borders — which is what a reader with low vision loses first. Set it on `<html>` from your own preference UI, exactly as you set `.dark`.

::: info
**Both attribute names are fixed.** `a11y.motion_attribute` and `a11y.contrast_attribute` in
`config/wirekit.php` record `data-reduce-motion` and `data-contrast` so you can read them without
grepping a bundle, rather than to set them. The stylesheet names both literally in its selectors,
and the JavaScript carries the motion one as its own constant. Setting either key to something else
changes neither.

That is worth knowing in one situation: if you write your own reduced-motion or contrast rules,
match these attributes and your rules and WireKit's stay on the same page state.
:::

### Type size

The third preference, and the one that is a number rather than a state. A reader who
needs larger type has the browser zoom — but zoom scales the whole page, and on a dense
interface that reflows the layout into something harder to use, not easier. An
application that offers its own type-size setting needs to move the type and leave the
layout alone.

One custom property does the whole ramp:

```html
<!-- 1. Everything WireKit renders gets 25% larger type. Nothing else moves. -->
<html style="--font-scale-wk: 1.25">
```

```css
/* 2. Or drive it from your own preference UI, the same way you set `.dark`. */
:root { --font-scale-wk: 1.15; }
```

Set nothing and the default is `1`, which renders byte-identically to before — the
factor multiplies each size, and multiplying by one changes nothing.

It reaches every size because the ramp is `rem` throughout. The single exception is
deliberate: form controls carry a 16px floor that stops Safari zooming the page when a
field takes focus, and scaling that away would restore the problem it prevents. A build
guard fails if a second absolute size appears, because the way a scale like this breaks
is by omission — one component ships a fixed size, that one surface ignores the reader's
setting, and nothing anywhere goes red.

::: info
**Three preferences, three mechanisms, one reason each.** Motion and contrast are
attributes because each has three named positions, including the "not here" a media
query cannot express. Type size is a custom property because it carries a value, and an
application asking for `1.25` should not have to pick from a list somebody guessed at.
`a11y.font_scale_property` in `config/wirekit.php` records the name, exactly like its
two siblings — it records it, it does not change it.
:::

### Arbitrary values ride in an inline `style`

A prop that takes any CSS value — `grid`'s `min` and `template`, and the computed sizes on `avatar`, `image`, `stats`, `reading-progress`, `color-picker`, `status-matrix`, `slider`, `editor`, `lightbox`, `image-compare`, `reading-minimap`, `indicator` and `shimmer` — is delivered as an inline `style` attribute. It has to be: Tailwind extracts class names from source text, so a value computed at runtime leaves its scanner nothing to find.

A `style-src` policy without `'unsafe-inline'` drops those attributes. Level 3 gives you `style-src-attr` if you want to allow inline attributes while still forbidding inline `<style>` blocks.

::: warning
**For `grid` the loss is total, not cosmetic — and nothing reports it.** Everywhere else a dropped inline style costs a shade or a width and the component still reads correctly. `grid`'s `min` and `template` carry the entire column definition, so without them the grid silently stacks into one column: no console error, no failed request, just a layout that is wrong. Do not ship a strict `style-src` without checking one grid by hand. If you must, pass `cols` alongside the track prop to declare what it falls back to — see [the grid page](/components/grid).
:::

## Touch targets

On a coarse pointer, form controls and buttons are lifted to a 44×44 target, and text fields render at 16px so mobile Safari does not zoom the page when one takes focus. Your desktop rendering is untouched — both rules live behind `@media (pointer: coarse)`.

Icon buttons that a component renders internally — the theme control, the code-block copy button, the notification bell — get there a different way. They carry `wk-touch-target`, which centers a transparent 44×44 hit area inside the control without changing its size. A size floor would have moved layout: an earlier attempt at exactly that widened a toolbar of small icon buttons past its container and had to be withdrawn.

You can use the class on your own icon buttons:

```blade
<button type="button" class="wk-touch-target" aria-label="Dismiss">
    <x-wirekit::icon name="close" />
</button>
```

::: warning
One case where it is the wrong tool, and one that used to be. Since v2.24.0 it gives its host a positioned ancestor only when the host does not already declare one, so a `fixed` or `absolute` element is safe — it used to be **moved** rather than enlarged, and on an older build it still is. What remains: in a dense grid, 44px hit areas **overlap**, so the element painted last takes the taps meant for its neighbor: a small target becomes an unreachable one. WCAG 2.5.8 allows an undersized target that is spaced away from its siblings, which is the case those grids fall under.
:::

## Content Security Policy

If your app serves pages under a Content-Security-Policy, there are three things to know before you ship. All are easy; the first two are easy to miss because nothing reports them.

### Interactive components need `script-src 'unsafe-eval'`

The interactive components are Alpine components — `x-data`, `x-on`, `x-bind`. Alpine's standard build evaluates those expressions with `new Function(...)`, so a policy that does not allow `'unsafe-eval'` stops them from being evaluated. This is Alpine's requirement rather than something in WireKit's own code, and it applies however Alpine reaches the page — bundled by Livewire, or through the self-contained `wirekit-alpine.js`.

::: warning
**Without `'unsafe-eval'` the failure is silent.** Nothing throws and nothing looks broken. A one-time-code field still accepts typing, it just stops advancing to the next box; a dropdown still renders, it just never opens. There is no console error to search for, so this is normally found by a user report rather than by testing. If your policy omits `'unsafe-eval'`, assume the interactive components do not work and verify one of them by hand.
:::

A minimal policy that works:

```text
script-src 'self' 'unsafe-eval';
style-src  'self';
```

`'unsafe-eval'` is the concession a strict policy gives up most reluctantly, and that is a fair objection rather than a technicality. If you cannot grant it, use the CSP bundle instead.

### A Livewire app: keep the default bundle, switch Livewire

If your app is Laravel + Livewire — the setup `wirekit.js` is the default for —
this is the shortest route to a policy without `'unsafe-eval'`, and it changes
nothing about how you load WireKit:

```php
// config/livewire.php

// 1. Livewire serves its own CSP distribution, which brings a CSP-safe Alpine.
'csp_safe' => true,
```

```php
// config/wirekit.php

// 2. Unchanged. The default bundle is already the right one here.
'scripts' => [
    'bundle' => 'full',
],
```

That is enough because **`wirekit.js` carries no evaluator of its own.** It does
not bundle Alpine — it registers plugins on the Alpine your app already has. So
in a Livewire app, the `'unsafe-eval'` requirement comes entirely from the Alpine
Livewire ships, and switching Livewire to its CSP distribution removes it.

Reaching for `wirekit-alpine.csp.js` here is the wrong turn: it brings a second
Alpine, which is precisely what the warning below tells you not to do. That
bundle is for an app with **no** Alpine of its own.

Verify rather than assume — the failure is silent, so a policy that is not
actually working looks exactly like one that is:

```bash
# 3. Every Alpine expression in YOUR views, checked against Alpine's own CSP grammar.
php artisan wirekit:csp-audit
```

::: info
**A method named after a JavaScript operator or literal needs index access.**
`$wire.delete(...)` does not parse under the CSP grammar, while `$wire['delete'](...)`
does. The affected names are these, and this is all of them:

`delete` · `false` · `in` · `instanceof` · `new` · `null` · `true` · `typeof` · `undefined` · `void`

The list is shorter than "a JavaScript keyword" suggests, and the difference is worth
knowing before you rename anything. Alpine's CSP tokenizer promotes exactly the words
above to operator and literal tokens; every other reserved word — `class`, `for`,
`function`, `import`, `return`, `this`, `var` and `yield` among them — is read as an
ordinary identifier and parses after a dot. A method called `for` or `class` needs no
change, so renaming one is a cost with nothing bought.

The audit above finds the real cases before a user does.
:::

### The CSP bundle: no `'unsafe-eval'` at all

WireKit ships a second self-contained bundle built against Alpine's Content-Security-Policy distribution, which interprets directive expressions rather than compiling them with `new Function(...)`.

```php
// config/wirekit.php

# 1. Serve wirekit-alpine.csp.js instead of the standard bundle.
'scripts' => [
    'bundle' => 'csp',
],
```

```text
script-src 'self';
style-src  'self';
```

::: warning
**Do not load your own Alpine alongside it.** This bundle brings its own, and two copies of Alpine on one page fight over the same elements. If your app already runs Alpine — a Livewire app does — then either drop that pipeline or stay on the standard bundle with `'unsafe-eval'`.
:::

Every expression WireKit itself writes resolves under this bundle. That is a checked claim rather than an aspiration: an audit runs Alpine's own tokenizer, parser and evaluator over every directive in the library on each build, and the budget for expressions that fail is zero — one that stopped resolving would fail the build rather than reach you.

::: info
**Structured props and non-ASCII copy are covered too.** A component that takes a list, a map, or text outside ASCII renders that value into the markup, and the CSP build reads a narrower grammar than the standard one — so how the value is encoded decides whether it arrives. WireKit writes each one as a plain JavaScript literal rather than a `JSON.parse(…)` call the CSP evaluator cannot resolve, and leaves non-ASCII characters as themselves rather than escaping them into text the tokenizer would mangle.

Both are checked on every build, so a component that regressed would fail rather than reach you quietly.
:::

::: warning
**A Livewire app requires Livewire's CSP build as well.** WireKit's bundle covers WireKit's directives. A Livewire app also runs Livewire's own, and its standard build compiles those at runtime exactly as Alpine's does — so loading only WireKit's CSP bundle is not enough. Livewire ships a CSP distribution for this; point your bundler at it, or the page still requires `'unsafe-eval'` no matter which WireKit bundle you load.
:::

The remaining trade-off is bundle size — the CSP distribution carries an interpreter the standard build does not need — and the [bundle table](#choosing-a-js-bundle) above lists both.

### Every asset directive takes a nonce

Under a `'strict-dynamic'` policy the nonce is the only thing that grants a resource, so all three directives accept one and omit the attribute entirely when you pass nothing:

```blade
<head>
    @wirekitStyles($cspNonce)
    @wirekitThemeScript($cspNonce)
</head>
<body>
    {{-- … --}}
    @wirekitScripts($cspNonce)
    @livewireScripts
</body>
```

Under a `'self'`-based policy you need none of this — the assets are same-origin and already allowed.

If you render markup of your own that needs the same nonce — an inline `<style>`
in a custom component, a script tag beside a WireKit one — ask for the value
rather than threading a variable through every view:

```php
use Pushery\WireKit\WireKit;

// The nonce this request runs under, or null when it runs under none.
$nonce = WireKit::cspNonce();
```

It resolves the same way the directives do: a `csp-nonce` container binding
first, then Vite's own nonce — which is the value Livewire reads — so an
application that already has one is covered without configuring anything twice.
`null` is a real answer and means the request has no policy, not that lookup
failed.

## Troubleshooting common console errors

Field reports point at three failure modes that produce broken interactive components (Modal / Drawer / Popover / Dropdown / Context-Menu / Clipboard-Button). Each shows up as a different console error; the fix surface is the same in every case.

| Console error | Likely root cause | Fix |
| --- | --- | --- |
| `Uncaught ReferenceError: wirekitModal is not defined` (or any other `wirekit*` factory name) | `dist/wirekit.js` not loaded, or `@wirekitScripts` missing from the layout. The Alpine factory registration that powers `x-data="wirekitModal(...)"` lives in that bundle. | Add `@wirekitScripts` to the layout (typically right before `@livewireScripts` at the end of `<body>`). Run `php artisan wirekit:doctor` to verify. |
| `Alpine Expression Error: ... is not a function` for an overlay component (Modal / Drawer / Popover) | The `core` bundle is configured (`'scripts' => ['bundle' => 'core']` in `config/wirekit.php`) but the page renders an overlay that depends on Floating UI / focus-trap. The core bundle ships only the chart Alpine factory; overlays require the `full` bundle. | Switch to `'bundle' => 'full'` in `config/wirekit.php`, or remove the override (full is the default). |
| `wirekitDropdown is not defined` AND every other `wirekit*` factory is also missing | The bundle RAN after `Alpine.start()`, so the `Alpine.data(...)` registrations arrived once the components had already mounted. Note that this is about execution, not tag position: `@wirekitScripts` emits `defer`, so in a plain Livewire layout the order of the two directives does not produce this. | Look for what runs it late — an `async` attribute in place of `defer`, a bundle injected at runtime, a bundler that dropped the `defer`, or a setup that starts Alpine itself before Livewire does. Run `php artisan wirekit:doctor`. |
| `chartjs-plugin-annotation` warning when using `<x-wirekit-chart annotations="...">` | Annotations are passed to a Chart.js chart but the optional `chartjs-plugin-annotation` package isn't installed. The chart still renders without annotations. | Either install `npm install chartjs-plugin-annotation` and register it in your `app.js`, or switch the page to ApexCharts (`'library' => 'apexcharts'`) which has annotations built-in. |
| `apexcharts is not defined` when using `<x-wirekit-chart library="apexcharts">` | ApexCharts adapter is selected but `apexcharts` npm package isn't installed AND/OR `wirekit-apex.js` isn't loaded. | `npm install apexcharts`, expose globally (`window.ApexCharts = ApexCharts;` in `app.js`), and add `<script src="{{ asset('vendor/wirekit/wirekit-apex.js') }}"></script>` BELOW `wirekit.js` in your layout. See [Chart docs → ApexCharts setup](/components/chart#apexcharts-setup). |

::: tip Run the doctor first
`php artisan wirekit:doctor` checks the four most common root causes (assets published, directive presence, directive order, bundle configuration) and prints a literal fix snippet for whichever one fails. Always run it before opening a bug report.
:::

## Performance — Conditional Asset Loading

WireKit ships syntax-highlighted code blocks (via `<x-wirekit::code-block>`) and chart canvases (via `<x-wirekit::chart>`). Both pull in non-trivial client-side bundles:

| Bundle | Size (gzip) | Used for |
|--------|-------------|----------|
| `highlight.js` engine + 2 themes | ~25 KB | Syntax highlighting in `<pre><code>` |
| `Chart.js` UMD | ~70 KB | `<x-wirekit::chart>` Alpine component |

Most documentation pages — landing, about, theming guide, recipes that don't ship code — never need either. Loading both globally costs ~95 KB gzip on every page-view. Doc-sites at scale are noticeably faster on lightweight routes when these bundles are gated to "page actually needs them."

### Pattern: server-side flag detection

Detect which bundles the rendered page actually uses by scanning the output HTML once, after rendering, and emit only the relevant tags.

**Step 1 — `App\Support\PageAssetHints`:**

```php
<?php

declare(strict_types=1);

namespace App\Support;

final class PageAssetHints
{
    /**
     * @return array{hasCode: bool, hasChart: bool}
     */
    public static function detect(string $html): array
    {
        return [
            'hasCode' => str_contains($html, 'class="hljs')      // pre-highlighted source-panes
                || str_contains($html, 'data-language='),         // your code-block marker
            'hasChart' => str_contains($html, 'x-data="wirekitChart')
                || str_contains($html, 'x-data=&quot;wirekitChart'),
        ];
    }
}
```

The detection is conservative: false positives (loading hljs on a page that doesn't actually need it) waste payload but stay correct; false negatives (skipping hljs on a page that does need it) break syntax highlighting visibly. Pick patterns that err on the side of inclusion, then tighten as you observe production traffic.

**Step 2 — wire it into the layout:**

Compute the hints from the `$slot` of your layout x-component (Blade renders the slot first, then the layout, so `$slot` is already a string when the layout body executes):

```blade
{{-- resources/views/layouts/app.blade.php --}}
@php
    use App\Support\PageAssetHints;
    $hints = PageAssetHints::detect((string) $slot);
    $hasCode = $hints['hasCode'];
    $hasChart = $hints['hasChart'];
@endphp
<!DOCTYPE html>
<html>
<head>
    {{-- … other tags … --}}
    @if($hasCode ?? true)
    <link rel="stylesheet" href="{{ asset('vendor/highlight.js/styles/github.min.css') }}" />
    @endif
</head>
<body>
    {{ $slot }}

    {{-- … other scripts … --}}
    @if($hasChart ?? true)
    <script src="{{ asset('vendor/chart.js/chart.umd.min.js') }}" defer></script>
    @endif
    @wirekitScripts
    @livewireScripts
    @if($hasCode ?? true)
    <script src="{{ asset('vendor/highlight.js/highlight.min.js') }}" defer></script>
    {{-- + your hljs-setup, copy-code, code-expand scripts --}}
    @endif
</body>
</html>
```

The `?? true` fallback preserves the old "always load" behavior for any future render-path that bypasses the slot entirely (e.g. a non-x-component error page).

### Why server-side flag (not client-side IntersectionObserver)?

| Approach | Latency | Cache-friendly | Complexity |
|----------|---------|----------------|------------|
| **Server-side flag** (this) | First paint correct | ✓ | LOW (1 helper, 2 `@if` blocks) |
| Client-side IntersectionObserver | Bundle loads on intersect | ✓ but needs careful media handling | MEDIUM (custom JS loader) |
| Always-on (default) | First paint correct | ✓ | NONE — but +95 KB always |

Server-side flag wins on UX (no FOUC on first scroll into a code block because hljs hadn't loaded yet) and cache-key invariance: the page HTML is the same; only the rendered tags differ — so existing per-page HTML cache stays valid.

### Why the cache-key doesn't change

Critical detail: the hint computation runs **after** the cached HTML is fetched, **not** before. The hint is a function of the already-cached output, not part of the cache key. This means existing per-page HTML caches stay valid through this refactor, and pre-warmer commands need no changes — hints recompute on every render at ~50µs per page. If you do want to skip the hint computation on cache hits, memoize `PageAssetHints::detect($html)` by `md5($html)` — typically not worth the 50µs you save per request.

### Verified results

A reference implementation on `docs.wirekit.app` (with 8 representative routes including `/`, `/getting-started`, `/components/button`, `/components/chart`, `/blocks`, `/search/results`) cleanly partitions the asset surface: pages without code skip the hljs payload, pages without charts skip Chart.js, pages with both load both. Tested with 7 Pest cases covering every detection pattern + edge cases (entity-encoded attrs, false-positive guards on inline `<code>` mentions in prose).

WireKit does not ship a built-in helper for this pattern — the 1–2 LOC `str_contains()` checks are simpler than a coupled API surface, and developer doc-sites typically need to adapt the detection regex to their own DocsParser / class-naming conventions. Re-evaluate as a shared helper if multiple developers converge on the same patterns.

## CI / Deploy script discipline

`wirekit:install` is strict-by-default in v2.1.0+: pre-flight validation aborts with exit code 2 (INVALID) when any flag is malformed, a token-clobber is detected, or the project is still on Tailwind CSS v3 (WireKit's CSS is built on the Tailwind v4 engine and cannot run on v3 — the abort message points you at the upgrade), all BEFORE touching the filesystem. Wire it into your CI / deploy script with the canonical fail-fast shape:

```bash
#!/usr/bin/env bash

# 1. Fail fast on any error, undefined variable, or pipe failure so a
#    silent install regression never reaches production.
set -euo pipefail

# 2. Run WireKit's strict-default install. Pre-flight errors abort
#    with exit 2 BEFORE the filesystem is touched; runtime failures
#    abort with exit 1. The deploy step exits non-zero either way.
if ! php artisan wirekit:install \
    --preset=cupertino \
    --font=inter \
    --no-interaction; then
    echo "::error::WireKit install failed — aborting deploy"
    exit 1
fi

# 3. Re-publish vendor assets if your deploy pipeline strips
#    public/vendor/wirekit/ between runs. `--force` overwrites
#    existing files so the bundles always match the installed
#    package version.
php artisan vendor:publish --tag=wirekit-assets --force --quiet
```

### Distinguishing exit codes

| Exit | Action |
|------|--------|
| `0` | Install + verify completed cleanly. Continue your pipeline. |
| `1` | Runtime failure mid-install — investigate the install output; the install-log may have rolled back partial state. |
| `2` | Pre-flight validation rejected the input. The error message names every problem in one pass. Fix the input + retry. |

### GitHub Actions example

```yaml
# 1. The job step itself fails on any non-zero exit. Exit 1 (runtime
#    failure) and exit 2 (pre-flight validation rejected the input)
#    BOTH abort the workflow — no need to wrap in if-conditionals.
- name: WireKit install (strict)
  run: php artisan wirekit:install --preset=cupertino --no-interaction
```

### Opt-out for legacy CI scripts

CI scripts that were written against the pre-v2.1.0 "warnings as success" behavior can preserve their semantic by combining the two opt-out flags:

```bash
# 1. --no-strict downgrades pre-flight warnings from aborts to
#    advisories (the v2.0.0 default).
# 2. --ignore-failed-flags lets per-flag failures (font validation,
#    preset application) report-and-continue rather than abort.
# 3. Together they preserve the exact v2.0.0 "best-effort install"
#    semantic — useful for legacy CI scripts that depended on the
#    pre-strict behavior.
php artisan wirekit:install --font=inter --no-strict --ignore-failed-flags --no-interaction
```

Use sparingly — the strict-default exists because the legacy mode silently masked install failures that developers were surprised to discover later.

### Dry-run preview before committing

`wirekit:install --diff` reports what WOULD change without writing any files. Useful when reviewing an install change in a code-review setting:

```bash
# 1. --diff is read-only: pre-flight validation still runs (so
#    invalid input is caught), but no filesystem mutations happen.
# 2. The exit code reflects the would-be state — 0 if the install
#    would succeed, 2 if pre-flight would reject. Drop --diff and
#    re-run to actually apply the changes.
php artisan wirekit:install --preset=cupertino --font=inter --diff
```

### Rollback the most recent install

`wirekit:install --rollback` reverses the most-recent install session by replaying its recorded before-snapshots from `.wirekit-install.log` at your project root. Per-file restore — failed restores reported but non-fatal.

```bash
# 1. Replays the before-snapshots recorded during the most recent
#    install session. Restores app.css, the layout file, .gitignore,
#    .wirekit-schema.json, config/wirekit.php, and every file under
#    public/vendor/wirekit/ to its pre-install byte-for-byte state.
# 2. --rollback is mutually exclusive with every other install flag;
#    run it on its own. Files that didn't exist before the install
#    are removed; files that existed before are restored from their
#    captured snapshot.
php artisan wirekit:install --rollback
```

The install-log is committed to your repository by default (along with `.wirekit-schema.json`); it's an audit trail for `wirekit:install` invocations. Add to `.gitignore` if you prefer to keep installs fresh per environment.
