**Minor release.** Form-input upgrades (range-slider Livewire integration, input HTML5 props, number-input grid-snap), entrance-animation polish (zero-flash + delay-prop fix universal), AI-tooling schema fields (`sub_components`, `slots` required flag, `@example`, ClassPropsExtractor for class-based components), two new artisan commands (`wirekit:fonts`, `wirekit:icons`), and three new docs pages (`prop-naming-conventions`, `overlays/events`, `recipes/on-page-toc`).

### Added

- **`<x-wirekit::range-slider>` first-class `wire:model` integration.** Pre-fix `wire:model="priceRange"` on the component tag was silently dropped into the outer `<div>`'s attribute bag — Livewire only watches input/select/textarea elements, not divs. Dragging the slider didn't update the Livewire property. Now: the component detects any `wire:model*` directive on the tag, strips it from the outer bag (no double-render), and re-emits it on the two hidden inputs as `wire:model="propName.min"` / `wire:model="propName.max"`. Developer declares `public array $priceRange = ['min' => 20, 'max' => 80];`; dragging either handle updates `$priceRange['min']` / `$priceRange['max']` live. All modifiers (`.live`, `.lazy`, `.debounce.500ms`, `.blur`) flow through unchanged. `docs/components/range-slider.md` "Livewire Integration" section rewritten to show the array-property pattern.
- **`docs/extending/prop-naming-conventions.md` — reference page for the three semantic-modifier prop families (`intent` / `variant` / `tone`).** Documents the contract behind each name, the canonical 7-value `intent` enum (`primary` / `accent` / `neutral` / `danger` / `success` / `warning` / `info`), and a per-family decision tree for component authors. Cross-component example table covers `<x-wirekit::button>` (`intent`), `<x-wirekit::alert>` / `<x-wirekit::card>` (`variant`), and `<x-wirekit::feature>` (`tone`). Single source of truth for "which prop name should my new component use?" — closes the historical gap where the three families were inferred from reading the source. Registered under the "Extending" group in the docs navigation.
- **`docs.wirekit.app/recipes/on-page-toc` — canonical recipe for the right-edge sticky sidebar Table-of-Contents pattern.** Pairs `<x-wirekit::reading-spine>` and `<x-wirekit::reading-progress>` to document the in-page TOC shape common to modern documentation sites. Covers the sidebar shape end-to-end (canonical wiring, CSS-variable knobs for expanded width and sticky offset, integration with a sticky `<x-wirekit::brand-bar>`, breadcrumb composition); cross-links to the `marketing-landing-toc` recipe for the horizontal strip variant. Targets the `<main>` selector by default so `H2` / `H3` headings auto-populate the spine without manual wiring.
- **`docs/overlays/events.md` — canonical event-vocabulary reference page.** Complete matrix of every overlay component's event names + payload shapes (modal / drawer / alert-dialog / command-palette / tour / toast-region — 6 overlays). Three sections: the matrix table, Alpine `$dispatch` + Livewire `$this->dispatch()` examples per event family, and "Conventions + footguns" enumerating the 4 historical irregularities (hyphen-not-colon, tour's name-in-event-name shape, toast's variant-vs-intent divergence, fire-and-forget show events). Single source of truth for AI / IDE tooling that previously had to discover this by reading `resources/js/components/*.js`. Registered under a new "Overlays" group in the docs navigation.
- **`<x-wirekit::alert-dialog.cancel>` sub-component — pre-wired close button for alert-dialogs.** Analogous to `<x-wirekit::modal.close>`, but specific to the destructive-action confirmation dialog. The wrapping `<div>` carries `x-on:click="close()"` (the parent's `wirekitAlertDialog` Alpine x-data exposes the method); slot defaults to a `<x-wirekit::button intent="neutral" surface="filled">Cancel</x-wirekit::button>` so the typical case is a one-tag affair. Override the label by passing slot text (`<x-wirekit::alert-dialog.cancel>Back</x-wirekit::alert-dialog.cancel>`) or wrap your own `<x-wirekit::button>` for full control (custom variant / size / icon). Replaces the manual-wiring pattern (`x-on:click="$dispatch('wirekit-alert-dialog-close', { name: '...' })"`) — that pattern still works but is no longer required.
- **`wirekit:make recipe:<name>` scaffolds for all 9 documented recipes.** Each `recipe:` template generates a Livewire class + Blade view from a shipped stub mirroring the corresponding `docs.wirekit.app/recipes/<name>` page's structural composition. Class names derive PascalCase from the kebab-case recipe slug (`marketing-landing-page` → `MarketingLandingPage`). Available recipes: `documentation-reader`, `feature-numbered-marker`, `hero-with-code-aside`, `live-kpi-strip`, `long-form-article`, `marketing-landing-page`, `marketing-landing-toc`, `stat-with-sparkline`, `toolbar-filter-bar`. Each generated view ends with a comment cross-linking to `docs.wirekit.app/recipes/<name>` for the full reference. Unknown recipes fail with a Levenshtein-ranked Did-you-mean hint.
- **`slots` schema field now flags required slots.** Slot entries in `wirekit:export-json` / `.wirekit-schema.json` / `wirekit:show` were previously a flat list of names — they didn't tell developers (or AI tooling) which slots were OPTIONAL (guarded by `@isset`) and which were REQUIRED (referenced bare via `{{ $name }}`). The shape now reads `[{name: 'trigger', required: true}, {name: 'header', required: false}, ...]` per slot. Detection heuristic: an `@isset($name)` / `isset($name)` guard marks the slot as optional; a bare `{{ $name }}` / `{!! $name !!}` / `$name->method()` reference without an enclosing guard marks it as required. The fix closes the popover / hover-card / context-menu gap — all three reference `{{ $trigger }}` directly and 500 with `Undefined variable $trigger` when developers omit the slot; the manifest now reports `trigger` as `required: true`.
- **`Pushery\WireKit\Support\ClassPropsExtractor` — public Reflection-based prop extractor for class-based Blade components.** Companion to `PropsParser`. Class-based components like `<x-wirekit-chart>` (registered via `loadViewComponentsAs(...)`) don't have an `@props([...])` block — the prop surface IS the constructor signature. `ClassPropsExtractor::extract(string $className)` walks the constructor parameters via Reflection and returns the same shape `PropsParser` emits, so downstream callers don't branch. Constructor parameter names are kebab-cased for Blade-attribute compatibility (`wireStream` → `wire-stream`); type hints stringify to their declared form (`string`, `?string`, `int`, `bool`, `array`, union types). Default values stringify as PHP literals matching what PropsParser captures. `ComponentRegistry::extractProps('chart')` now returns 11 props (was empty). The schema entries from `wirekit:export-json` / `.wirekit-schema.json` / `wirekit:show chart` all carry the full constructor surface — AI tooling and IDE extensions see chart's `type` / `labels` / `datasets` / `wire-stream` / etc. instead of the previous empty `props: []`.
- **`@example "..."` annotations in `@props` blocks surface as a new `examples` field in the schema.** `PropsParser` now reads `// @example "value"` annotations from the trailing same-line comment after a prop's comma and exposes them as a `list<string>` field on every schema entry next to `name` / `default` / `comment`. Use for props whose accepted value-shape is non-obvious from the default alone — the canonical first user is `<x-wirekit::grid>` `cols`, which accepts a Tailwind-style space-separated string (`"1 md:2 lg:4"`); the default `1` doesn't telegraph that. Multiple `@example` annotations per comment are all captured; backslash-escaped quotes inside the value are supported (`@example "Press \\"Enter\\" to confirm"`). Empty array (not null) when no annotation present, so the field is type-stable across the catalog.
- **`wirekit:icons` artisan command** — list every icon alias shipped with WireKit, grouped by preset. Each section shows the alias-count summary, an `[active]` / `[opt-in]` indicator against the current `wirekit.icons.preset` / `wirekit.icons.presets` config, and every alias → Blade-Icon identifier mapping. Same API shape as `wirekit:list` / `wirekit:fonts` (`--preset=...`, `--as=count|presets|aliases|json`, `--format=` alias, Levenshtein-ranked Did-you-mean on unknown preset). The `--as=aliases` flavor emits a unique alphabetised alias list across the (optionally filtered) preset set — useful for "do any presets define `bolt`?" lookups. JSON output per preset: `{key, count, active, requires, aliases}` so AI tooling sees the active-vs-opt-in distinction without re-reading config. The pre-existing `IconResolver::availablePresets()` PHP-side helper is unchanged; the CLI is a thin discovery wrapper. Also: the `bolt` example in `docs/components/icon.md`'s Sizing section is replaced with `search` (which is in the default `heroicons` preset) — `bolt` lives only in `heroicons-marketing` and required opt-in to render.
- **`wirekit:fonts` artisan command** — list every font preset shipped with WireKit, grouped by category. Mirrors `wirekit:list`'s API surface (`--category=sans|serif|mono`, `--as=count|slugs|categories|json`, `--format=` alias, Levenshtein-ranked Did-you-mean on unknown category). Each row shows the preset key, label, and font-family; the keys map 1:1 to the values accepted by `wirekit:install --font={key}`. Previously the only way to discover the preset list was via `tinker` or a typo'd install-error path. New PHP-side surface: `Pushery\WireKit\Fonts\FontRegistry::all()` / `category()` / `get()` (already existed; the CLI now wraps it).
- **`sub_components` array on every entry in `wirekit:export-json` + `.wirekit-schema.json`.** Previously only `wirekit:show <name>` surfaced the sibling-directory `.blade.php` files (e.g. `card.body`, `card.header`, `card.footer`). The JSON-manifest paths read by AI tooling, IDE extensions, and the `.wirekit-schema.json` written by `wirekit:install` now carry the same list — first-class field next to `props` and `slots`. Sorted alphabetically; skips `index.blade.php` (Laravel's anonymous-component index file). Components with no sibling directory get an empty array (not null) so the field is type-stable. Closes the "card looks empty because the body wrapper is invisible to tooling" gap.
- **`<x-wirekit::tabs>` accepts `items` as array-of-objects in addition to the legacy keyed-assoc form.** Two list-input shapes now coexist on the component: `['profile' => 'Profile']` (compact, ergonomic for static inline data) AND `[['key' => 'profile', 'label' => 'Profile']]` (matches typical API responses, easier to compose from a Livewire `@computed` property). The component normalizes both at the template edge — pick whichever your data source already produces. Both shapes produce byte-identical rendered HTML (same `aria-controls` / `id` wiring, same Alpine bindings) so screen-reader behavior is stable across the two. When the `label` field is missing in array-of-objects form, the `key` is used as the visible label.
- **`<x-wirekit::input>` HTML5 form-state props in `@props`.** Five props now declared explicitly: `required`, `disabled`, `readonly`, `autocomplete`, `placeholder`. Previously these only worked via attribute-bag passthrough (`<x-wirekit::input required>`) — invisible to the schema, AI tooling, and IDE autocomplete. The `required` prop additionally flows through to the associated `<x-wirekit::label>` so its required indicator (`*`) renders without manual wiring. Back-compat preserved: the attribute-bag passthrough still works exactly as before, and mixing the two forms (`:required="true" required`) produces a single `required` token on the underlying `<input>` — guarded by an explicit regression test.
- **`<x-wirekit::profile>` `interactive` prop for dropdown-trigger composition.** Profile previously rendered a presentational `<div>` with no focusable child. Wrapping it in `<x-wirekit::dropdown.trigger>` (the canonical user-menu pattern) opened the menu on mouse-click but was unreachable by keyboard — Tab skipped past the profile entirely because the trigger's focusable-descendant search returned null. New `interactive` prop (default `false`) emits `role="button"`, `tabindex="0"`, Enter / Space keyboard handlers synthesizing a click, plus a focus-visible ring matching the canonical button focus state. Default `interactive=false` preserves the pre-existing presentational `<div>` byte-for-byte. New "Interactive profile inside a dropdown trigger" preview block on the docs page shows the canonical composition.
- **`<x-wirekit::brand>` `mobileLogo` + `mobileBreakpoint` props for responsive logo swap.** A wide wordmark (`/brand/wirekit-wordmark.svg`) on a brand-bar overflows narrow viewports — `mobileLogo` pairs with a compact mark (`/brand/wirekit-mark.svg`) that renders below the chosen Tailwind breakpoint, with the wide wordmark taking over at + breakpoint. `mobileBreakpoint` accepts `sm` / `md` / `lg` / `xl`, default `sm` (640 px). When `mobileLogo` is null, behavior is unchanged (single `<img>` at every viewport — back-compat). Both images carry the same accessibility shape (`alt=""` + `aria-hidden="true"`); the `<a>`'s `aria-label` provides the accessible name. Invalid `mobileBreakpoint` values throw in debug, fall back to `sm` in production via `StrictnessGate`. Six tests cover back-compat (single-logo unchanged), default breakpoint swap, custom breakpoint, invalid value throw, mobileLogo-without-main-logo no-op, accessibility (two images both decorative + aria-label preserved).
- **`Pushery\WireKit\Support\StrictnessGate` — public single source of truth for runtime validation strictness.** Central decision behind `WireKit::validateProp()` (component-level prop validation) and `IconResolver` (icon-alias / preset lookups): strict mode throws `InvalidArgumentException` with a Did-you-mean hint, lenient mode logs a warning (now WITH the actual fallback value annotated) and returns the first allowed value (or a caller-supplied override via the new `fallback` parameter). `StrictnessGate::isStrict()` returns the current decision; `StrictnessGate::enforce($context, $key, $value, $allowed, fallback: null)` is the canonical entry point; `StrictnessGate::formatMessage(...)` exposes the message-builder so callers that throw their own typed exception (like `IconResolver`) inherit identical wording.
- **`wirekit.validation.strict` config key (env `WIREKIT_STRICT_VALIDATION`)** — explicit override of the strict-vs-lenient decision. `null` (default) keeps the historical behavior (strict in `APP_DEBUG=true`, lenient in prod). `true` forces strict everywhere — useful for CI / staging hardening. `false` forces lenient everywhere — useful for snapshot CI runs that want to assert rendered output regardless of prop typos. Documented in `config/wirekit.php` with a per-value behavior matrix.

### Changed

- **Integration Guide URL moved from `/integration` to `/getting-started/integration`.** The guide is conceptually a sub-page of Getting Started — installation walkthrough, optional dependencies, asset publishing, verification checklist — so the URL now reflects that hierarchy. Every in-docs cross-link has been updated in lockstep (10 inbound references across `getting-started.md`, `cli-reference.md`, `livewire-starter-kit.md`, `components/resizable.md`, `extending/console-output-baseline.md`, `cli-reference/wirekit-doctor.md`). External bookmarks pointing at the legacy `/integration` URL will need updating; the new URL is the canonical location going forward.
- **`wirekit:doctor` now WARNs when `'charts.library' => 'chartjs'` is configured but `resources/js/app.js` is missing the `Chart.register(...registerables)` registration.** Catches the #1 first-run Chart.js gotcha upstream: `npm install chart.js` + `'chartjs'` in config IS NOT enough — the JS-side `import { Chart, registerables } from 'chart.js'; Chart.register(...registerables);` must be in `app.js` or the chart component renders + mounts but draws nothing (Chart.js logs a friendly console.error and gives up). The doctor's WARN prints the actionable snippet inline. Comments stripped before scanning so a `// Missing: Chart.register(...)` hint comment doesn't false-pass the detection. `getting-started.md` Optional Features section gains an explicit three-step callout (config + npm + JS-side registration) cross-linked to `/integration#optional-dependencies`.
- **`<x-wirekit::table>` warns in debug mode when plain HTML descendants (`<thead>` / `<tbody>` / `<tr>` / `<th>` / `<td>`) are passed inside the slot instead of the `<x-wirekit::table.*>` sub-components.** Same composition-required-for-padding pattern as `<x-wirekit::card>`: the bare `<table>` only carries the outer styling — padding, row dividers, stripe + hover wiring live on the sub-components. Dropping raw HTML inside compiles cleanly but produces a visually-broken table with no error. The console.warn now surfaces the silent breakage with a link to the canonical composition. Production stays silent. All 5 sub-components (`table.head` / `table.body` / `table.row` / `table.th` / `table.td`) now emit a `data-wk-table-*` marker on their root element — used by the detector to distinguish "wrapped" from "raw". Markers are inert at runtime; they exist purely as detection signals.
- **`<x-wirekit::tabs>` warns in debug mode when `wire:model*` is passed on the component tag.** Pre-fix the wire:model attribute was silently dropped into the outer `<div>`'s attribute bag (Livewire only watches `<input>`, `<select>`, `<textarea>` — not divs). Tabs are client-only Alpine state; clicking a tab mutates a private `x-data="{ active: '...' }"` scope and does NOT update Livewire state. The console.warn now surfaces the silent-breakage with a link to `docs.wirekit.app/components/tabs` for the named-slot contract. Production stays silent (no console.warn). The component's behavior is unchanged — the warn is observability-only. Docs page rewritten with a "Contract: named slots, NOT wire:model" section under the H1 explaining the client-only state model, the named-slot pattern with a complete example, and the @computed-property workaround for server-side initial state.
- **`<x-wirekit::alert-dialog>` ESC key always closes, even when `dismissible="false"`.** Pre-fix the dialog was completely keyboard-locked when non-dismissible — ESC inert, backdrop click inert, Cancel button wasn't pre-wired. The only escape was page reload. Now: backdrop click stays inert on non-dismissible (the safety-strict half — don't approve destructive action by a stray click), but ESC always fires the close event. Keyboard users always have an escape hatch. Implementation: `createOverlay` gained an `escapeAlwaysCloses` option; alert-dialog passes `true`; focus-trap uses `escapeDeactivates: dismissible || escapeAlwaysCloses`. JS bundles rebuilt.
- **`<x-wirekit::replay-button>` removed from the README components table and the `wk-replay-button` row in the Public CSS API catalog no longer carries a docs link.** The component remains fully callable in the package (the `<x-wirekit::replay-button>` tag still renders, the `wk-replay-button` CSS class still ships in `dist/wirekit.css`) — replay-button is preview-chrome infrastructure rather than a developer-pickable primitive, so it's no longer surfaced in the catalog tables.
- **`IconResolver` exception messages now include Did-you-mean hints** on close-typo alias AND preset names. Previously `Unknown icon alias 'clse'` listed only the available aliases (long list); now reads `Unknown icon alias 'clse'. Did you mean: close, eye, plus? Available aliases: …` — same Levenshtein-ranked suggestion contract as `WireKit::validateProp()` and every `wirekit:*` CLI surface. Same enhancement for `Unknown icon preset 'heroicns'` (suggests `heroicons`). Behavior-preserving — the `InvalidArgumentException` class and the leading message prefix are unchanged so existing exception-catching tests stay green.
- **`WireKit::validateProp()` lenient-mode log message now annotates the fallback value.** The pre-existing log line read `WireKit [button]: Invalid variant "purple". Allowed: primary, secondary.` — readers had to know the fallback contract to interpret what the component actually rendered. Now reads `… Falling back to "primary".` so prod log readers see the concrete substitution without consulting the source. The new annotation flows automatically from `StrictnessGate::enforce()`.
- **`wirekit:show chart` / `wirekit:list --format=json` / `wirekit:export-json` / `wirekit:export-api-map` now emit the class-based Blade tag form `<x-wirekit-chart>`** (not the broken anonymous form `<x-wirekit::chart>`). The chart component is registered class-based via `loadViewComponentsAs(...)` so the working tag is the prefixed-class shape; the anonymous Blade file at `resources/views/components/chart.blade.php` exists for the class-based path to render through but reaching it via `<x-wirekit::chart>` hits an undefined `$alpineComponent` variable and 500s. New `ComponentRegistry::tag(string $name): string` helper is the single source of truth, with a small per-component override map (currently only `chart`). Every CLI surface that prints a "Tag:" field reads from it, so every "what tag do I use?" report now prints the WORKING form.

### Fixed

- **`<x-wirekit::file-upload>` file list now visibly separates from the dropzone above it.** Pre-fix the `<ul>` carried an inline `style="list-style: none; margin: 0; padding: 0;"` (required so the bullet-list cleanup works in the sandbox-iframe rendering context where Tailwind classes don't resolve) AND a class string with `mt-[var(--padding-wk-y-sm)]` (the intended dropzone gap). Inline `margin: 0` wins over the class via CSS specificity, so the gap was always zero and the file list sat flush against the dropzone's bottom edge. The spacing BETWEEN file rows (driven by `gap-[var(--space-wk-sm)]` on the flex parent, not by per-child margin) was unaffected, which made the bug look like "spacing works between files but not against the dropzone above". The fix migrates the dropzone gap into the inline declaration (`margin: var(--padding-wk-y-sm) 0 0 0`), so the gap resolves in BOTH rendering contexts — developer apps and sandbox iframes both load `dist/wirekit.css`, so the CSS variable resolves identically.
- **`<x-wirekit::reveal>` `delay` prop silently dropped whenever the caller also passed an inline `style="…"` attribute.** The template emitted TWO `style=` attributes on the root `<div>` — one from `$attributes->merge(...)` carrying the caller-passed value, and one from a separate `@if($delayValue) style="..."` directive carrying `animation-delay: {value}`. Per HTML5 parsing rules, duplicate attributes on the same element resolve to the FIRST and silently drop the second, so the `animation-delay` was never honored whenever any caller-supplied inline style was present. Every gallery preview on `docs/animations.md` passes `style="--wk-stagger-step: 0ms"`, so the bug was universal on the gallery and on every developer composition that passed inline style for any reason — `delay="lg"` / `:delay="1000"` / etc. all rendered but had zero visual effect. The fix composes caller-style + internal-animation-delay into a single attribute via a `@php`-block merge before the `<div>`, so the root now carries exactly one `style="caller-styles; animation-delay: …"` attribute and the delay actually takes effect.
- **Every `<x-wirekit::reveal>` and `animateIn`-driven entrance animation (`<x-wirekit::hero animateIn>`, `<x-wirekit::card animateIn>`, `<x-wirekit::feature animateIn>`, `<x-wirekit::cta animateIn>`, `<x-wirekit::stat animateIn>`, `<x-wirekit::callout animateIn>`, `<x-wirekit::alert animateIn>`, `<x-wirekit::empty-state animateIn>`, `<x-wirekit::footer animateIn>`) no longer flashes when the element is already in the viewport at page-load.** Pre-fix the `animation-fill-mode: both` on `[class*='wk-animate-']` included `backwards`, which snapped the element to its keyframe `from` state (opacity 0) the instant Alpine added the animation class. For elements not yet in viewport this was invisible — the element wasn't visible anyway. For elements ALREADY in viewport at page-load (the marketing-landing-page hero, for example), the IntersectionObserver fired almost immediately on Alpine init, the class arrived, and the browser visibly snapped the element from `opacity: 1` (no class) to `opacity: 0` (from-state) before animating back. User perceived a brief "flash to invisible → 300 ms fade-in" — the flicker. New CSS rule in `dist/wirekit.css` pre-hides any element with `x-data="wirekitAnimate('*-in')"` and no `wk-animate-*` class yet, scoped to viewport-trigger only (click and manual triggers are explicitly excluded so click-to-animate cards stay visible until clicked). The `:not([class*="wk-animate-"])` clause deactivates the rule the moment Alpine fires; the keyframe `from` opacity matches the pre-hide opacity, producing a clean entrance with no snap. Pure CSS fix — no Blade or JS code changes — upgrades every existing reveal / animateIn usage transparently.
- **`<x-wirekit::reading-toc>` link horizontal padding now follows the design-token grid (`--padding-wk-x-sm`, 10px) instead of the hardcoded `px-2` (8px) Tailwind atom.** The link's `px-2 py-1` declaration was a pre-token-system convention left over from the component's first authoring — never reconciled with the project's `--padding-wk-x-*` scale. The 2px offset misaligned the active-link highlight chrome with the surrounding content-padding column (every other prose container sits on the 10px grid), reading as "the strip is 2 px out". Now `px-[var(--padding-wk-x-sm)] py-[var(--padding-wk-y-xs)]` — 10px × 4px — so the link's left edge lands on the same vertical line as the prose paragraphs beneath the strip. Y-axis kept at the equivalent token (`y-xs` = 4px) to preserve the strip's overall height.
- **`<x-wirekit::number-input>` stepper buttons now snap to the step grid instead of skipping values when the starting value is off-grid.** Pre-fix `value=1.78 step=0.1` clicked `+` jumped to `1.9` (skipping `1.8`) because the Alpine handler added `step` to the current value (`1.78 + 0.1 = 1.88`) then ran `toFixed(precision)` which rounded `1.88 → 1.9`. The intermediate grid point `1.8` was unreachable. The fix snaps to the step grid anchored at `min` (falling back to `0` when no `min` is set): increase goes to the next grid point ABOVE the current value (`1.78 → 1.8`), decrease goes to the next grid point BELOW (`1.78 → 1.7`) — matching the W3C native `<input type=number>` stepper contract. A `1e-10` tolerance absorbs binary-float drift so on-grid values like `1.8` (which can be stored as `1.7999999998`) still advance to the correct next step. Anti-regression test `it('emits the precision/round helpers + grid-snap stepper wiring in Alpine x-data')` pins the new shape.
- **`<x-wirekit::file-upload>` file-row layout now pushes size + remove button to the right edge with proper horizontal padding.** Pre-fix the filename, size, and `×` remove button clustered on the LEFT of each row leaving a large empty area on the right, AND the row's all-sides `p-[var(--padding-wk-y-xs)]` declaration used the tight Y-axis token for horizontal padding too — content felt cramped against the row's left edge with no breathing room. Filename now carries `flex-1` so it grows to fill available space (pushing the size + X to the right edge in the standard file-uploader UX shape), and padding split into `px-[var(--padding-wk-x-sm)] py-[var(--padding-wk-y-xs)]` so horizontal breathing room is generous while vertical stays compact. File rows carry variable-length content, not nav-chip text, so the asymmetric padding shape is correct.
- **`<x-wirekit::stat animate animateIn="…">` race condition that left random counters stuck at "0" on hard refresh.** When both `animate` and `animateIn` were set, the Blade template wrapped the inner counter root in an outer `<div x-data="wirekitAnimate('…')">` carrying the entrance keyframe (e.g. `wk-slide-up-in`: `translateY(1rem)` → `translateY(0)`). The entrance keyframe shifted the inner element's geometry for ~300 ms. The inner counter's own `IntersectionObserver` (`threshold: 0.4`) could fire while the entrance keyframe was active — the shifted bounding box pushed the inner below threshold, the callback returned without starting the counter, and the stat stayed at "0" indefinitely. The race was random per card because it depended on Alpine mount-order vs paint-timing; no console errors and no determinate reproduction in a static test environment. Now: when the entrance wrapper is present, the counter listens for `animationend` on the outer rather than running its own `IntersectionObserver` — the outer's `wirekitAnimate` plugin already owns scroll-into-view via its own observer, so the counter just needs a deterministic "start" signal that doesn't depend on transform-affected geometry. Standalone counters (no entrance wrapper, the dominant deployment shape) keep the IO behavior unchanged. The counter-tick logic was factored into a shared `runCounter` closure so both paths call the identical ease-out-cubic-over-1.2 s tick.
- **`<x-wirekit::tour.step>` `index` prop now auto-assigns sequentially within its parent `<x-wirekit::tour>`.** Pre-fix every step defaulted to `index=0`, so every step rendered with `data-wk-tour-step="0"`. The tour's `start()` correctly counted 3 steps, `next()` incremented `currentStep` to 1, then `_positionStep()` queried `[data-wk-tour-step="1"]` — found nothing → all step divs hidden because none match `currentStep === 0` anymore. Tour appeared to "break" after the first Next click — actually all steps just disappeared. Now: `index` defaults to `null`; when null, the step picks the next integer from a per-render counter (`Pushery\WireKit\Support\TourStepCounter`) that the parent tour component resets at the top of its `@php` block. Steps with explicit `:index="N"` bypass the counter (the developer's choice; the counter only advances on `next()` calls). Multiple tours on the same page each restart numbering from 0 — Alpine scopes isolate per-tour `currentStep`. The natural usage pattern — just list the steps, no `:index` props — now works correctly.
- **`<x-wirekit::range-slider>` value-display spans referenced undefined Alpine variables.** The two `<span x-text="minVal">` / `<span x-text="maxVal">` elements rendered AFTER the `</div>` that closed the x-data scope — every page using the slider flooded the browser console with `Alpine Expression Error: minVal is not defined` (twice per render) and the displayed numbers never updated when the user dragged. The slider handles still rendered + dragged correctly because their bindings sit on Alpine-scoped attributes (`:style`, `@pointerdown`) within the x-data root; only the read-out spans were affected. Now restructured so the value-display row sits INSIDE the x-data root, between the max-thumb `<div>` and the scope-closing `</div>`. Regression test depth-counts `<div>` opens / closes through the rendered HTML and asserts both `x-text` substrings appear before the matching close — catches future structural regressions.
- **`<x-wirekit::badge intent="accent">` threw `Invalid intent "accent"` in debug mode** (broke live previews on `docs.wirekit.app/recipes/marketing-landing-page` + `docs.wirekit.app/components/hero` after v2.1.x). `accent` was missing from the badge's `validateProp` allowed list — a regression vs the canonical 7-value intent enum documented in `docs/extending/prop-naming-conventions.md`. Now restored as a first-class intent with a high-contrast filled rendering (`bg-[var(--color-wk-accent)]` + `text-[color:var(--color-wk-accent-fg)]`) — the "stand out against surrounding chrome" variant used on CTA hero pills, pricing-card "Most popular" markers, and marketing "New" eyebrows. Dot indicator uses accent-fg for contrast on the filled bg. Lockstep enforcement scans every `<x-wirekit::badge intent="X">` literal in the docs and asserts X appears in the validateProp allowed list — catches future drift in either direction (intent enum change OR docs author using a fresh intent name).
- **Animation-emitting components now uniformly emit `data-replayable="true"`** when their `animateIn` prop is set — `<x-wirekit::card>` / `<x-wirekit::feature>` / `<x-wirekit::hero>` / `<x-wirekit::cta>` / `<x-wirekit::footer>` previously emitted the `x-data="wirekitAnimate(...)"` hook without the replay marker, so `docs.wirekit.app`'s preview-chrome replay button missed them. Existing emitters (`<x-wirekit::reveal>`, `<x-wirekit::stat animate>`, `<x-wirekit::chart>`) are unchanged. The attribute is otherwise inert; on developer apps without a replay surface it's a harmless no-op. New `docs/animations.md` subsection documents the same contract for utility-class animations (raw `class="wk-animate-fade-in"` on HTML elements — developer adds `data-replayable="true"` manually since there's no PHP render path). The contract is pinned across all 11 animation-emitting components: every one carries the attribute when the `animateIn` prop is set, and does NOT carry it when inactive.
- **`<x-wirekit::hero>` aside column overflowed the page on `<lg` viewports when fed wide unbreakable content.** The most common shape: developer drops a `<x-wirekit::code-block>` in the `<x-slot:aside>`; `<pre>`'s `white-space: pre` keeps the longest line non-breakable, so a 40-char code line drives the aside column to ~425 px on a 393-px iPhone viewport — overflows the section's padding-x boundary, code-block hangs out of the right edge. The copy column carried the same theoretical risk (long URL in lede, ascii-art header). Both columns now carry `min-w-0 w-full lg:w-auto` on the flex-col / flex-row break: `min-w-0` escapes flex-shrink's default `min-content` floor, `w-full lg:w-auto` takes parent's full inline-size on flex-col then reverts to content-sized on `lg:flex-row`. The code-block's own `overflow-x-auto` on its `<pre>` then handles intra-line horizontal scrolling within the contained column. Back-compat: existing layouts at `lg+` keep the same `flex-row` content-sized columns.
- **`<x-wirekit::button loading>` without a `wire:*` action attribute was a visual no-op.** The component emitted `wire:loading` on the spinner SVG + `wire:loading.attr="disabled"` on the button — both Livewire directives that only activate WHILE a Livewire request is in flight. So `<x-wirekit::button :loading="true">` on a non-Livewire page (or a button with no `wire:click` / `wire:submit`) rendered an invisible spinner and a clickable button. The component now distinguishes two intents: (a) `loading=true` + any `wire:*` action present → unchanged behavior (spinner + disabled while the Livewire request runs), (b) `loading=true` + NO `wire:*` action present → DECLARATIVE loading state, spinner always visible + native `disabled` attribute set. Developers who want "this button is in a loading state, period" now get the expected visual without wiring up a Livewire request.
- **Reading-family widgets (`<x-wirekit::reading-spine>` / `<x-wirekit::reading-toc>` / `<x-wirekit::reading-minimap>` / `<x-wirekit::reading-progress>`) silently rendered empty when the developer-supplied `target` selector matched no element.** A typo'd id (`target="#summry"`) or a renamed ancestor class produced a blank spine / empty TOC / non-rendering minimap with no console signal — the developer had to reverse-engineer the silent miss from the DOM. Each widget now emits a single `console.warn` at init time when `target` is non-default AND `document.querySelector(target)` returns null, naming the offending selector and the component tag form (`Check the selector on <x-wirekit::reading-spine target="…" />`). The default `'main, article'` selector stays silent — a miss there is a page-shape concern rather than a developer typo. reading-progress is the special case: its default `target` is null (track viewport), so any supplied selector triggers the check.
- **`<x-wirekit-chart>` (or `<x-wirekit::chart-mixed>` / `<x-wirekit::chart-spark>`) crashed the whole page on a fresh install** with a `RuntimeException: Charts are disabled. Set 'charts.library' in config/wirekit.php …` because the package ships `'charts' => ['library' => null]` as the safe default (avoids forcing every developer into Chart.js installation). In `APP_DEBUG=true` the chart now renders a developer-visible placeholder div instead — names the missing config step inline (`Set 'charts.library' => 'chartjs' in config/wirekit.php, then npm install chart.js`) and preserves the developer-supplied `height` so layout stays stable when the developer fixes the config. In production (`APP_DEBUG=false`) the hard-throw stays — silent fallback would hide a real misconfiguration from end users. New `wirekit:doctor` check `checkChartUsageWithoutAdapter()` surfaces the same diagnostic at install time: WARN when any Blade file under `resources/views/` references the chart tag and `config('wirekit.charts.library')` is null. Both diagnostics ignore Blade-comment references (`{{-- <x-wirekit-chart> --}}`) so docs pages don't false-positive.
- **`wirekit:show <name>` printed `Docs: docs/components/<name>.md`** — but `/docs` is export-ignored from the published package, so the path never resolved on a developer install. The line now prints `Docs: https://docs.wirekit.app/components/<name>` (the live URL), matching what `wirekit:export-json`'s `docs_url` field already emitted everywhere else.
- **`<x-wirekit::chart-mixed>` silently dropped six props** that the underlying class-based chart delegate accepts: `wireStream`, `wireStreamMode`, `wireStreamCap`, `annotations`, `inline`, `replayable`. The anonymous-Blade wrapper only declared `labels`/`datasets`/`options`/`height`/`scope`, and Laravel's attribute-bag doesn't flow into a nested `<x-wirekit-chart>` tag unless the props are bound by name. Added all six to `chart-mixed`'s `@props` and forwarded via explicit `:wireStream="$wireStream"` etc. Existing callers pass through unchanged; mixed charts with `wire:stream` bindings or annotations now work as documented.
- **`wirekit:doctor` warned about font assets on every fresh install** because the package ships `'sans' => 'inter'` as the default config and the matching asset directory isn't auto-published. The warning read as if something failed; it's the natural state of a bare install. Now distinguishes the package-default font from a developer override — default + assets-not-published emits a new INFO hint (still suggests `vendor:publish --tag=wirekit-fonts` for self-hosting; system-ui fallback works without it); a developer override (e.g. `'sans' => 'roboto'`) + assets-not-published stays WARN because the requested font genuinely won't render until shipped.
- **`wirekit:doctor` printed two FAIL lines for missing `@wirekitStyles` / `@wirekitScripts` directives on a fresh Laravel 12 install** before the developer had a chance to add a layout file at the conventional path. The report read like the install broke when actually the next step is on the developer. The bare-install case (no `resources/views/components/layouts/*.blade.php`, no `resources/views/layouts/*.blade.php`, no `resources/views/components/layout.blade.php`, AND no `@import 'wirekit.css'` in `resources/css/app.css`) now emits a single INFO line — ``Layout file not yet created — `wirekit:install` injects @wirekitStyles + @wirekitScripts on a re-run once you add …`` — and skips the directive scan. Once any of those paths exists OR the `@import` alternative is configured, the scan runs normally and FAILs on missing directives in real misconfiguration scenarios.