WireKit v2.48.0
Minor. Almost nothing in this release could have gone red. That is the thread running through it: a focus indicator that is missing in exactly one rendering mode, a prop that sits in the documentation table and was never declared, a class assembled at runtime and therefore never compiled, a focus trap that reports itself active while focus sits on <body>. Each renders a healthy page, throws nothing, logs nothing — and is broken anyway. The two worth reading first fail that way and are the most costly: wirekit:install could truncate your config/wirekit.php to zero bytes and report success, and every control that draws its focus with a ring had no focus indicator whatever in forced-colors mode.
Fixed
- An animated
<x-wirekit::stat>now counts to the number you wrote, and puts the symbol back on the side it came from. The counter re-derived its number from the display string with one fixed convention — a period separates decimals, a comma groups — so a German dashboard passingvalue="€31.200", the ordinary spelling of 31,200 euros, painted the correct figure and then had the counter overwrite it with €31,20: wrong by three orders of magnitude, with nothing thrown and nothing in the console. The other half was wrong in English too. Everything that was not a digit was concatenated AFTER the number, so$1,250.50settled as1250.50$— the very example the plugin documented — and any value with decimals shipped ungrouped, because the branch that applied thousands separators could not be reached for one. The value, the prefix, the suffix and the decimals you wrote are now resolved where your locale is known and handed to the counter, which groups the in-flight number in that locale rather than in the reader's browser preference.data-targetstill carries the display string, so the documented replay hook is unchanged. - Every date and time in
<x-wirekit::event-calendar>is now written in your application's locale instead of the reader's browser preference. The month heading, the weekday columns, the hour gutter, the agenda day labels, each event's accessible name and the "+N more" control were the only text on the component not coming from the translation catalog, so a German page opened on an English laptop put German chrome over "March 2026 / Mon Tue Wed" — the same page in two languages, differing per reader, with no prop to correct it. A newlocaleprop overrides it for the case the application locale cannot serve: a calendar showing a schedule that belongs to a different region than the page around it.<x-wirekit::calendar>and<x-wirekit::countdown>have worked this way since the same defect was found in each of them. wirekit:theme brutalistnow writes the theme its own page teaches. The command emitted four radius tokens, a border width and--shadow-wk-*: none, while docs.wirekit.app/theming/brutalist has documented the opposite since the preset shipped: a hard 2D offset shadow with zero blur, which is the thing the name is about. Two themes under one name, and which one you got depended on whether you ran the command or copied the block out of the guide. The command now writes the page's token set — the offset shadows, the squared radius, zero-duration motion, the 3px focus ring, the near-black accent, and the explicit border colors. That last one was also an accessibility defect: without them the 2px border inherited a neutral that renders at 1.45:1 on white, which fails WCAG 1.4.11 and leaves the borders nearly invisible on the one preset whose whole point is that the structure is visible. Form controls take their edge from a separate token family, so both are pinned. Dark mode is emitted too, where the offset shadow needs an explicit light gray becausecurrentColoris not controllable there. The JetBrains Mono pairing remains a documented prerequisite — the command cannot writeconfig/wirekit.php. The live theme picker on docs.wirekit.app already previews Brutalist this way, so the command and the site now agree.- The "Start Tour" button in the tour documentation's live example now starts the tour. Its demo wrapper had no Alpine scope, and Alpine only attaches a handler inside one — so the click did nothing, with no error and nothing in the console. The example on docs.wirekit.app/components/tour works as written; the component itself was never affected.
- Removing a tag from
<x-wirekit::tags-input>now leaves focus on the chip that took its place, instead of jumping to the text field. It already tried to: the handover looked for the chip row after the button that was pressed had been removed with its chip, found nothing, and took the last-resort branch meant for an empty field. Removing the last chip is where it showed — focus went to the input instead of the chip that became last. Same cause as the toast fix below, found by the same sweep. - Dismissing a toast with the keyboard no longer throws focus to the top of the page. The dismiss button sits inside the list, so pressing it removes the element that has focus — and focus on a removed element falls to
<body>, which with a stack of toasts means tabbing through the whole page again between each one. The component was already written to hand focus to the next toast (or, for the last one, back to wherever you tabbed in from); the handover reached for the surrounding region after the button was already gone, so it found nothing and did nothing. It now takes that reference while the button is still there. WCAG 2.4.3. wirekit:installcould truncate yourconfig/wirekit.phpto zero bytes. The command edits that file to record a chart-library choice, and it drove threepreg_replace()calls whose return value it did not check.preg_replace()answers a failure withnull— and a/upattern fails on a subject that is not valid UTF-8, which is what one save from an editor in a single-byte encoding produces. Thenulltraveled into a write that opens the file for truncation, the error notice was suppressed, and the command then printed that it had set the key and exited successfully. Thepreg_match()one step earlier had the same shape: it answers1,0orfalse, and read as a boolean the error is indistinguishable from "the line is not there", which routed into the branch that produced thenull. Every result is now checked, the error is a third state with its own answer, and nothing is written unless the rewrite produced a file — on a failure the command prints the snippet to paste by hand and leaves your configuration alone.- Keyboard focus was invisible in Windows High Contrast on the controls a keyboard-only reader meets first. Tailwind v4 swapped the meanings of two utilities:
outline-nonebecame a real suppression, and the transparent outline that forced-colors mode repaints in a system color moved tooutline-hidden. WireKit was still writing the older name across the library. The ring beside it rescues nothing —ring-*compiles tobox-shadow, which that mode drops outright — so button, select, textarea and pagination had no focus indicator at all there, failing WCAG 2.4.7. Nothing could have gone red on it: the class is emitted, the element carries the rule, and the computed style in an ordinary browser is exactly what the source says — only the forced cascade removes the paint. Both spellings are byte-identical everywhere else, so nothing changes outside that mode. - A checked radio, a switch that is on and a partly filled progress bar all looked unset in forced-colors mode. Each signaled its state with a background color alone, and that mode replaces every author color with the reader's own palette. The markup and the ARIA state were correct throughout — only the thing a sighted reader looks at was gone. Toggle, radio and progress now redraw the state with a border or a system color, the one channel the mode preserves. The four class hooks that carry it (
wk-progress-track,wk-progress-fill,wk-radio-dot,wk-toggle-knob) are documented rather than merely present. - The focus trap in six overlays reported itself active while focus sat on
<body>. Modal, drawer, alert dialog, popover, command palette and lightbox hand the trap their panel as the last-resort focus target, and the library implementing it requires such a target to carry a negativetabindex— none of them wrote one. On a panel whose content is plain text, with nothing focusable inside it, that produced the worst version: the trap swallowed every Tab and handed focus to an element that cannot take it, so the key did nothing and the panel could not be left by keyboard. The container is now made programmatically focusable before it is focused, and only when it declares notabindexof its own — a panel with real controls is never touched, and the trap keeps treating the fallback as a fallback. - Five more controls dropped keyboard focus to the top of the page when something was removed. Removing a pill from multi-select, a chip or the whole set from filter builder, a row from file upload, a card mid-move in a kanban column, or replaying an assistant message destroys the very element holding focus — and focus on a removed element falls to
<body>, so the next Tab restarts at the top of the document. WCAG 2.4.3, on the gesture each of those controls exists for. Each now hands focus to the successor at the same position, then to the new last item, then to the control that adds one. File upload also says which file went: its only live region was the error paragraph, gated on there being an error, so a removal was silent (WCAG 4.1.3). - Four form controls reported themselves invalid whenever any unrelated field on the page failed validation. Combobox, date picker, file upload and inline edit read the shared errors bag without first checking they have a name, and asking that bag about a
nullkey falls through to "is there anything in it at all". The first three painted an error border, setaria-invalid="true"and pointed at a description paragraph with nothing in it — identifying an error they never described. Inline edit was worse: it displayed a foreign field's validation message as its own. WCAG 3.3.1 both ways. Every read is now guarded on the control's own name. - Four components wrote their parent's
@awarekeys into your DOM as HTML attributes. Multi-select and editor renderedannounce-errorson their root element, wizard's step renderedcurrent, and sidebar's item renderedmode,selectedandcollapsibleon the link it emits. None is a valid attribute on the element it landed on, and all of them reached every adopting application's markup. The sibling components that declare an@awareblock already stripped them; these four now do too. Nothing about a documented call site changes — only the stray attribute goes away. - Nine props were documented and inert, and one rendered twice. Each of the following sat in a props table, did nothing, and reported nothing.
- OTP input's
disabledwas in the props table and in the theming reference and was never declared, so a one-time-code field writtendisabledstayed typeable, pasteable and submittable while announcing as editable. - Text assembled its
line-clampclass at runtime, and Tailwind only generates a rule for a class name it finds spelled out in source — so the markup carried the class and nothing styled it, at every value except the one its documentation example happens to use. - Color picker declared neither
errornorhint, so a validation message landed on the native input as an invalid attribute and was never shown. It takesannounceErrortoo, with the same precedence every sibling control uses. - Date picker dropped the whole attribute bag in
rangemode, so a binding written on a range bound nothing at all — no error, no console entry, and a control that looks and behaves normally. - Modal's
dismissiblewas never normalized, and an unbounddismissible="false"is the string"false", which is truthy — so the spelling a developer reaches for first turned the switch back on. - Button's
disable-on-loadingwas read on only one of its two paths, so with a declarative loading state and no bound action the opt-out was inert. - Reading spine raised a fatal error when
levelsfiltered down to nothing, instead of degrading to its default. - Multi-select accepted one of the three options formats its page promises: a list of strings submitted array indexes under labels that looked right, and a list of
['value' => …, 'label' => …]arrays labeled every option "Array". - Range slider printed its hint twice under one duplicated element id, which made the description both handles point at ambiguous by construction.
- And fourteen boolean props whose default is written as a
config()call were never cast, so the same unbound-"false"trap applied to them — among them the drawer's and alert dialog'sdismissible, table'sstriped,hoverable,compactandresponsive, and data table'sselectableandsearchable. - Thirty-odd strings were hardcoded English and could not be translated. The editor's twenty-three toolbar command labels, four strings in the notification center, the all-day word every event calendar accessible name prints and its week/month view-switcher label, the state a screen reader hears from a status matrix and the legend explaining it, the drag announcements a kanban column makes, the combobox placeholder, and the accessible name on both of color picker's native branches. All of them now route through the translation catalog, and the keys ship in all eight bundled languages. The kanban announcements ship as templates carrying
:positionand:totalrather than finished sentences — the numbers are only known in the browser, and "position of total" is not the word order every language uses. - The publicly served API map advertised ten CSS classes WireKit emits nowhere. Five were morph identities, four were DOM-id prefixes and one was a Tailwind group name, all listed as stable class hooks — and the page that points AI tooling at that group by name gives no error for a class that does not exist. The cause was two hand-kept copies of one scan, of which the exporter's had learned none of the three exclusions the other had. The scan now lives in one place and both readers call it.
- Four command-line reports were wrong in ways that read as findings.
wirekit:class-by-area --areapruned the area map after every layer had been measured but before the differences were computed, so a filtered-away layer arrived as an empty set rather than as an absent question — falsifying all five difference rows in both directions, including the example on the reference page. A row whose operands are not all in scope is now suppressed with its missing operands named, rather than computed from nothing.wirekit:export-api-mapannounced an unavailable group on standard output, ahead of the JSON — in every real install, because the folder it looks for is not shipped — so the| jqpipe its own documentation sells failed while the command exited successfully; the notice goes to the error stream now.wirekit:show --validate-againstcompared props against the raw written spelling, so the kebab-case this library's documentation teaches never matched, and its passthrough allowlist was anchored against the raw attribute, so it skippedclassand flagged:class,:styleand:aria-label. Andwirekit:doctorcounted exactly one of its thirteen informational lines toward "passed", depending on which internal path printed it — most of those lines say a check was skipped, so counting them at all claims a pass for something that did not run; they now count toward no total, and the page says so. wirekit:csp-auditreported correctly registered components as registered by nothing, and its own printed repair made it worse. A package serving its own JavaScript from a route of its own is in neither source the command reads by default, so every factory it registers came out in the same sentence as a genuinely dead panel. The report now names the flag that fixes it and hands over the finished command with that package's source path filled in, and says plainly when the package has no such folder rather than guessing one.- Contrast and paint fixes across the theme layer. The success button's label sat below the WCAG AA floor precisely while the pointer was on it — the hover fill moved toward the label instead of away from it, which the dark palette had been getting right all along; light now takes the same step. The Aurora preset stopped claiming its retint holds AA at any hue, and publishes the cool-hue co-tune it actually needs. The glass extension's first-tier fallback no longer paints an opaque layer in dark mode, so the second tier has a backdrop left to bend. And the app shell's mobile backdrop is drawn from the overlay token rather than a literal.
- Accessible names, roles and keyboard details. A sparkline and a mixed chart forwarded a caller's name to a wrapper instead of to the element carrying
role="img". A labeled divider had no accessible name at the source. A scrolling card had no tab stop and no focus ring, which WCAG 2.1.1 requires of any scrollable region. A tab list had no tab stop at all when the server selected no tab. Accordion looked for its headers at one heading level while its ownlevelprop allows six. An arrow key in the calendar lost the weekday when it crossed a month boundary. Heading interpolated its tag name instead of resolving it through the validator. Menubar and context menu held Tab instead of closing and letting it through. The notification center's teleported dialog bridged only one of its two Tab edges, and its filter radios were resolved from the wrong root. Escape during a kanban card move abandoned the move and lost the card. Profile's interactive branch had no Alpine scope, so Enter and Space bound to nothing. The command palette minted item ids that could collide. Menu rows whose focus background matches the panel gained a focus ring, and the active option in a selection sidebar is now visible on screen rather than only in the accessibility tree. Every one of those is a keyboard or screen-reader path that looked correct in an ordinary pointer session. - Four shipped surfaces taught something the code does not do. Two recipe stubs — the Blade the scaffolding command writes into your own application — passed a prop the component never declared, so it shipped into your code as a raw attribute: valid markup, no error, no effect. And a blueprint's legal section claimed a capability that is no longer there.
- The drawer's size table quoted widths the component does not use.
- The date picker's config snippet taught a
formatkey that could never have had an effect — a native date input renders in the viewer's locale by specification — so a reader pasting it concludes date formatting is broken rather than that the option never existed. - The copy button in the popover documentation taught an expression Alpine's CSP build cannot parse, so on that bundle the binding was never evaluated and the button was inert.
Added
list_presetsandget_preseton the MCP server — the bundled theme presets with the command that applies each one, and the exact CSS custom propertieswirekit:themeappends, light and dark. A preset is applied by running a command rather than by hand-copying tokens, and an assistant that could not name the presets had no way to offer that. Both read the same registry the command writes from, deliberately: a preset already reaches you through the command, the theming guide and the live picker on the documentation site, and a fourth retelling would be a fourth thing to hold in step. An absent dark block is reported as null rather than as an empty string — most presets inherit dark mode from the bundled defaults, which is a fact about the preset and not a gap in the answer.list_recipesandget_recipeon the MCP server. The recipe library ships as real Blade — a documentation reader, a marketing landing page, a live KPI strip, an on-page table of contents and more — andwirekit:make recipe:<name>writes one into a project. Nothing on the MCP path mentioned they exist, so an assistant asked for one of those pages assembled it out of primitives while the finished composition sat one command away.list_recipesreturns each one's title, summary, documentation URL and the exact command that scaffolds it;get_recipeadds the Blade source, so the composition can be read before the command runs or adapted inline instead. Both read the shipped stubs rather than the documentation pages — the pages carry the same titles and are export-ignored, so a catalog built on them would answer in the WireKit repository and return eleven blanks in every real install. Documented at docs.wirekit.app/ai-tooling.get_conventionson the MCP server — the house rules for authoring WireKit markup, served over the protocol. The server has always exposed the catalog: an assistant could read every prop of every component and still not learn that a Tailwind palette class, adark:prefix, a hand-written color, an outer margin or an icon sized withh-4 w-4is rejected — each of those fails a build guard rather than merely looking off, and a prop list does not mention any of them. Editors reading.cursor/rules/wirekit.mdcoff disk already had the rules; an MCP client has no filesystem and could reach neither that file norAGENTS.md, which says so about itself — it tells assistants the MCP server exposes the same catalog, and it meant it. The tool serves those two shipped documents rather than a third copy of the same rules: the short entry point by default, the full authoring ruleset withdetailed. Read at call time, so it answers for the version installed. Documented at docs.wirekit.app/ai-tooling.wirekit:csp-auditnow asks the question that comes before the repair when an offending view sits undervendor/. A package that serves its own JavaScript from a route of its own is in neither source this command reads by default, so every factory it registers correctly was reported as registered by nothing — the same sentence the report prints for a genuinely dead panel. Following the printed repair meant filing a bug against a package that had none and switching off a screen that worked. The report now names--registrations, hands over the finished command with that package's own source path filled in, and says plainly when the package has no such directory rather than guessing one.data-wk-charton every rendered chart, carrying the active library as its value (chartjs,apexcharts, or your own adapter's identifier). Neither chart library is bundled, so deferring the import until a chart is actually on the page is the usual way to keep it out of the entry chunk — and that needs one reliable signal in the DOM. Until now the only handle was the Alpine factory's name, which is an internal detail: renaming it is not a breaking change, so a selector built on it would stop matching in silence. The marker is emitted once per chart (the replayable wrapper andchart-mixeddo not add a second), and the debug placeholder shown when no adapter is configured deliberately does not carry it — no chart renders there, so no library should be fetched for it. Documented at docs.wirekit.app/components/chart.wirekit:boost-skills --check— report whether the published.boost/wirekit.jsonstill describes the installed package, and write nothing. The manifest is a snapshot that no upgrade rewrites, and until now the only way to ask whether it was current was to regenerate it and read the diff — which overwrites the file the question was about. The check compares the whole catalog rather than the version stamp, so a release that adds a prop is reported as what it is: the stamp and the components move together, and an agreeing stamp only proves both sides came from the same release. Exits0when the file matches,1with the components and props that moved. Documented at docs.wirekit.app/cli-reference.- A slide in
<x-wirekit::carousel>takes alabel.aria-roledescription="slide"is what makes a screen reader say "slide" rather than "group", and it is unreliable on an element with no name — so a track of unnamed slides read as N identical groups with nothing to tell them apart. It is deliberately not defaulted: an anonymous Blade slide has no index of its own, so any name this component invented would name every slide identically. placeholderdefaults for combobox and tags input, and amax-tagsdefault for tags input, in the published config.placeholderisnullrather than a literal so the component keeps resolving its own translated default; set a string to override it in every locale at once.wirekit:csp-audit --registrations-only— read Alpine registrations only from the paths you name, ignoring the built and published bundles. This is the behavior--registrationsused to have; see below.
Changed
- A month pill in
<x-wirekit::event-calendar>now carries its start time next to the title. A day cell with three pills told you that three things happen and nothing about their order — while the information was already present twice over: the week view has always shown a secondary time line, and the pill's accessible name has always ended with the time, so a screen-reader user heard "Sprint planning, Monday June 8, 10:00 AM" where a sighted reader saw "Sprint planning". The time is formatted in your application's locale, and the title truncates to make room rather than the other way round — a clipped time is unreadable, a clipped title is still recognizable. An all-day event shows none, because the cell it sits in is already the date. Nothing was recolored: the pill's background comes from the event's intent, so the time is set apart by size, which avoids introducing a new contrast pair for every intent. wirekit:csp-audit --registrationsnow ADDS to the bundles it already reads instead of replacing them. Under the replacing semantics the command's own printed repair walked you into a wall: it detects a package whose factories nothing registers, hands over a paste-ready--registrations=<that package's source>, and the flag then discarded the built output, the published assets and this package's own bundle on the way in — trading one false report for several others. The scan only ever widens, so a run reports a subset of what it did before. The old behavior is unchanged and reachable as--registrations-only.