What we learned while building WireLayer – pitfalls of the tools and libraries and how the code works around them. Worth reading before changing the affected areas.
- pnpm 12 has been stable since August 2026 and is used. The store lives in
node_modules/.pnpm-store. - pnpm security defaults: packages are only installed once they are 1 day old (
minimumReleaseAge). This is intended and is not circumvented. When a version range is too fresh, pnpm addsminimumReleaseAgeExcludeitself – that entry is reverted and an older, released version is used instead (~58.0.0). - The export condition
sourceis ambiguous:@internationalized/number(from Ark) uses it as well and thereby pulled its TypeScript sources into our type check. Our condition is calledwirecore:source. - Storybook composition in Docker: the host checks its refs on the server at startup. All Storybooks therefore share
one network namespace (
storybook-net), and the host only starts once Vue and React are healthy. - Storybook composition does not pass globals on to composed Storybooks (“globals from a non-local ref … not
supported”). The color scheme switcher in the host therefore sends the theme via
postMessage(apps/storybook/shared/theme.ts). The “Preview background” addon is switched off because it only changed the hidden page background. The a11y panel also exists only in the individual Storybooks (:6007/:6008). - The Storybook a11y addon starts axe itself. The browser check uses its instance and runs with
prefers-reduced-motion, otherwise axe measures in the middle of the color transition when the theme changes. - Dev Storybooks (development only, not builds/CI): the first time a story with a new Ark component is opened, Vite
optimizes the dependencies and reloads the page. A new
--animate-*variable in@wirecore/wirelayer-tokensonly reaches the running Vue/React Storybooks after a restart (until then Tailwind does not generate the utility). After new exports from@wirecore/wirelayer-recipes, Vite served an old version ofi18n.ts(“does not provide an export named …”) – restart the container.pnpm --filter @wirecore/wirelayer-css builddeletesdist/, and the watcher of the HTML Storybook loses it – restartstorybook-htmlafterwards.
- Ark gaps we close: the
progressbargot the value as its name instead of the label. TheSegmentGroupwas vertical by default. The switch had norole="switch". The pagination was labeled in English only. - More Ark gaps:
Popover.CloseTriggerlabels custom buttons with “close” (replacing the visible text) –PopoverClosetherefore closes via the context. Popover title/description are not linked withlazyMount– own IDs. The DatePicker does not attach toField– we do the linking (label, status, description) ourselves. - Even more Ark gaps (overlays): Ark Vue's
Pagination.Rootdoes not passboundaryCounton to the machine – Vue therefore usesusePagination+RootProvider. A toast that was updated fromloadingto another type ignorestoaster.dismiss()afterwards –toast.dismiss()then removes it itself after the exit duration. Menus ignorepositioning.getAnchorRect(they set their own) – points go in asanchorPoint, elements asgetAnchorElement. - Ark gaps with dates and times: the inline
Calendarhas noDatePicker.Content, but Ark looks for the day to focus inside it. The arrow keys therefore only changed the highlighted day, not the focus; the root now stands in for the content viaids. With several months, the days of the neighboring month create duplicate IDs (the focus landed in the invisible duplicate) – they are rendered as empty cells. Ark hides the weekday header from screen readers (aria-hidden); the calendar weeks are therefore named as row headers (“Week 43”). A click into a completed range does not move the focus in Ark (found by the E2E test). - Ark gaps with trees and lists: Ark's checkbox cascade counts a branch without loaded children as checked (
everyover an empty list) – with lazily loaded nodes,checkStrictlyorcheckedStrategythe Tree therefore computes the state itself (@wirecore/wirelayer-recipes,tree-data.ts). Ark Vue'sTreeView.Rootdoes not passscrollToIndexFnon to the machine – the VirtualTree usesuseTreeView+RootProviderin Vue. The options of Ark's Listbox prevent the default behavior ofmousedown(the focus stays in the list); that also prevents dragging – PickList/OrderList catch the press withdraggablefirst, and the browser focuses the list itself. - Ark gaps in the NavigationMenu (MegaMenu): Ark's
Contentcloses as soon as the pointer leaves the panel – even withdisableHoverTrigger, i.e. also when opened by click – and names the panel witharia-labelledby(not allowed on adivwithout a role). The MegaMenu therefore renders the panel itself with Ark's content props minus these three; hover (delay, safe triangle, instant switching, never on touch) is handled bycreateHoverIntentfrom@wirecore/wirelayer-recipes, Ark's hover is off. - Ark gaps in the newer building blocks: Ark Vue's
AngleSlider.ValueTextloses its attributes with custom content (the value is therefore a separate display).FloatingPanel.CloseTriggeris hard-coded to “Close Window”, theSignaturePadhas the role description “signature pad” and a<title>Signature</title>in its SVG – own names oraria-hidden. The pause on hover/focus of theMarqueewould undo a pause set by the button – the component controls the pause itself; Ark knows no reduced motion (the animation only runs withmotion-safe:). Ark'sSelectandColorPickertake over the IDs of a surroundingField– second controls inside it (the country of the PhoneInput) getstandalone, the color button of the ColorInput its own name. - Focus after Ark dialogs:
finalFocusElis only called on close – the function looks the element up again at that point. That way the focus lands on the (re-rendered) event after the recurrence prompt instead of on a removed element. - Numbers in German format: Ark reads the value of a NumberInput with the locale – “1234.5” would become 12345 in
de-DE. Values are therefore passed formatted (“1.234,5”), and withoutformatOptionsArk does not format at all (we set a default). - Ark Rating:
setValue()takes the value under the pointer while one is hovered – clearing (allowClear) therefore usesclearValue()(Ark: −1, 0 to the outside). - Ark toasts are stacked via CSS variables (
--x,--y,--height…) that the styling has to apply. Without them the toasts lay on top of each other. The tests were green anyway; only the screenshot comparison found it. - Toggletip without Ark's Popover: Ark's Popover is a
dialogand moves the focus. The Toggletip is its own logic: a button plus arole="status"region into which the bubble is inserted on open (announcement); the focus stays on the button. - Ark UI Vue wires up
aria-describedbyonly after mount. Tests therefore wait for anextTickso that Vue and React are comparable.
- Object literals as props of Ark components (Vue):
:positioning="{ placement: … }"in a template creates a new object on every render. Ark updates its machine with it, which renders again – in menus, where every highlight renders, this became an endless loop. Vue only stops it in development (“Maximum recursive updates exceeded”); the production build froze (submenus of Menu, Menubar and CascadeSelect, found by the E2E tests against the static Storybooks). Fixed objects are constants in the script, ones that depend on props are acomputed. - Vue component names have no prefix (
Button,Input) – decided, identical to the React API. - Naming: light/dark is
data-mode, the color themedata-theme, as in PrimeVue. Previouslydata-thememeant light/dark; renamed before the first release. - Vue renders asynchronously: in the controlled NumberInput, Ark wrote the formatted value back before Vue passed the typed text on – a comma disappeared. Vue therefore keeps the text itself and passes changes from outside on via Ark's API.
- Vue renders
class=""when an Ark part gets:class="undefined"(React omits the attribute). Optional classes are therefore bound viav-bind="cond ? { class } : {}". - Vue boolean props: a boolean prop without a default is
false, even whenundefinedis passed. Inner components therefore use negative names (hideClear,plainToday), and the public props (clearable,highlightToday) default totrue. - Generic Vue components (
generic="T …"): type exports must come before the first statement (“Modifiers cannot appear here”), and types used in thegenericattribute must be imported (DateRangePicker/types.ts). - Vue SSR and
valueOf: a functionvalueOffrom<script setup>made server rendering resolve toObject.prototype.valueOf(“Cannot convert undefined or null to object”). Do not name helper functions like methods ofObject. - Stored settings in React:
react-hooks/set-state-in-effectforbids taking values fromlocalStorageviasetStatein an effect. Banner and AppShell read the entry withuseSyncExternalStore(storage.ts,nullon the server and during hydration) and keep their own changes in state. - Hydration and generated IDs: a default value with a random ID (ConditionBuilder root) differs between server and
browser – the root is therefore always called
root, new nodes only get IDs on user actions. - Returning focus:
useFocusReturnremembers the focused element synchronously on open (Vueflush: 'sync', React layout effect) – otherwise a focus trap has already moved the focus. - Returning focus and reopening: the focus return after closing runs a frame and 50 ms later (the menu is still being removed). A menu opened again in between had its focus taken away – on a slow machine even reliably (ContextMenu: choose an action, then Shift+F10). The return now skips when the overlay is open again.
- Focus after reordering (React): the next arrow key arrived before the focus was on the moved item (moving between
lists, found by the E2E test). Sortable therefore renders the new order with
flushSyncand focuses immediately; Vue focuses afternextTick. - Multi-part texts in React:
{a} · {b}creates several text nodes in React, Vue's{{ a }} · {{ b }}one – the DOM comparison flagged it. Composed texts are template strings in React. - Vue
v-modelwithundefined: av-modelwithout a value writes''into the textarea on mount and thereby overwritesdefaultValue(jsdom did not show it, the browser test did).defaultValueis therefore a prop that initializes the internal text. - Explicit
undefinedin props:aria-label={undefined}after spreading the field props overwrites the field name. Fields only pass their own names on when they are set. - React Compiler rules: refs may neither be read nor written during render – “latest props” for later callbacks
(loaders, data sources) are synced in an effect, and state that matters during render is state.
react-hooks/exhaustive-depsapplies only to.tsxfiles; in.tshooks a disable comment for it is an error. - React Compiler and render props: functions that are passed to
renderItem/renderEditorduring render and write a ref are reported byreact-hooks/refs. Focus requests after render therefore go through state ({ index, pick }) and an effect. - React Compiler and functions during render:
toggleOnSpace(choose)is called during render – ifchoosereads a ref (through further functions),react-hooks/refsreports an error. Kanban and DataTable therefore keep such values in state or set the data source only in the effect. - Controlled form elements: Vue additionally writes the value of a
<select>as the attributevalue(which<select>does not know), React only sets the property – the DOM comparison ignores this attribute. For<textarea>, React writes the value as text content and Ark Vue's factory additionally as an attribute – the passed value is compared (attribute, otherwise text content). For<input>, React additionally writes the value as an attribute (defaultValue), Vue only sets the property – the current value is compared.
- TanStack Table sorts numbers descending first. Columns set
sortDescFirst: falseso that the first click sorts ascending everywhere, asaria-sortannounces. - TanStack
autoResetExpanded: newdatacollapses all groups and tree rows by default. It showed immediately when moving rows between groups (the focus got lost) – it is switched off withreorderableRowsandcellSelection. - Validating on blur swallows clicks: if a field's error text disappears when the field is left, the fields below
move up between
mousedownandmouseup– the click lands elsewhere. Recommendation (and story):revalidateLogic()– errors only after submitting, then live while correcting. - Standard Schema and TanStack Form: an optional
typesfield with a different input type makes a validator incompatible with TanStack's types (StandardSchemaV1<TData>). Our validators (function and schema at the same time) therefore do not declare it; TanStack uses~standardat runtime. - TanStack store in Vue:
useStoreupdates synchronously – afterfield.pushValue()the length is already the new one. FormList remembers the index beforehand (found by the behavior test). - Clipboard: the browser sends
copy/pasteto the text selection, not to the focused element – after the arrow keys in the grid, Ctrl+C would arrive atbody. The DataTable therefore listens on the document and checks whether the focus is inside the table. In jsdom, user-event only copies a text selection; the test dispatches thecopyevent itself, the browser test uses the real clipboard. - Selection lists with arbitrary values: Ark only works with strings.
@wirecore/wirelayer-recipes(list-items.ts) maps every value to a key – strings stay unchanged (form values and DOM as before), objects byvalueKeyor content. The original value goes to the outside; the component remembers labels of selected values that are not (or no longer) among the items (server search, loading more). - Time zones without Temporal: Node 26 knows Temporal, Safari and Firefox (browser target) not consistently yet. The
conversion in
@wirecore/wirelayer-recipes(date-time.ts) usesIntl.DateTimeFormatand behaves like Temporal'scompatible: a time in the gap of the daylight saving change moves forward by the gap, a repeated time in autumn is the earlier one. - Recurrences according to RFC 5545: the start (
DTSTART) always counts as the first occurrence, even if it does not match the rule;COUNTincludes excluded (EXDATE) occurrences. Days that do not exist in a month (31st, “5th Monday”) are skipped, not moved. - JSON error positions: V8 no longer reports a position for
JSON.parse(“… is not valid JSON”).jsonErrorOffsetfinds the position itself (a small scanner) – JsonInput shows line and column in every browser. - Percentages:
Intlputs a non-breaking space before%in many locales.toHaveTextContentnormalizes it,toEqualdoes not. - Arabic digits: ICU gives
arLatin digits andar-EGArabic-Indic digits. The language modules format withar; onlylocalizeTextswith the active locale makes the digits consistent. - QR code without new logic: the matrix comes from
uqr(MIT, no dependencies) – the same encoder that Zag/Ark use on the web;qrCodePathin@wirecore/wirelayer-recipesturns it into an SVG path (runs of a row as one rectangle).
- jsdom gaps (setup.ts): Ark's positioning needs
ResizeObserver/scrollTopolyfills, Ark's Splitter and the AppShell handles needsetPointerCapture, ProseMirror (scrolling to the selection) needsRange.getClientRects.getContext('2d')is missing –textColumnWidthreturns 0 there. Keyboard, focus, keyboard resizing and text widths are therefore checked by the browser tests. - Virtual lists: jsdom cannot virtualize (no layout dimensions) –
VirtualSelect/VirtualTreeare tested in the browser. In a flex container the spacer needsshrink-0, otherwise it shrinks (VirtualSelect could not be scrolled, in the VirtualTreeEndlanded in empty space – found by the E2E test). Ark focuses in the next frame afterscrollToIndexFn; the row sometimes appears only after that, so the VirtualTree retries the focus for a few frames. - Browser axe finds more than jsdom: it found a “Label in Name” violation (2.5.3) for the icon button counters that jsdom had not reported. That is why both checks run.
- jsdom/user-event and Ark input fields: user-event remembers typed text even when Ark clears the field right afterwards; fast typing overtakes Ark's sync of the field (React effects). Tests type there with a short delay or clear the field first – neither happens in the browser (E2E).
- Server data in jsdom: if the items of a virtual list only arrive after the first render (data source), TanStack draws no rows in jsdom (the scroll area measures 0 px). The behavior tests check requests and announcements there, the browser test checks the rows.
- Names from several
<span>s in jsdom: jsdom puts no space between the inline parts of a button (“Review10:00–11:00”), the browser does (flex children are blocks). Behavior tests check such names with an optional space, browser tests with one. - Spaces in hidden text parts: jsdom loses a space at the end of an
sr-only<span>before the following text (“Unread:Backup”), the browser does not. The space therefore sits outside the<span>. - user-event:
{and[start key descriptors – the tests type JSON as{{and[[. - Shiki in the parity cases: the highlighting arrives asynchronously – to a different extent in Vue and React depending on timing. DOM cases therefore use plain text; behavior and browser tests check the highlighting.
- Failed requests and
networkidle: pdf.js throws on an error status before reading the response body and keeps the request open, so Playwright'snetworkidlewas never reached. The PDF engine therefore destroys the loading task when loading fails, which aborts the request. - Vitest workers and memory: the default is one worker per core; each holds jsdom with Vue, React and Ark (~300 MB).
On 24 cores that was ~6.5 GB – next to the dev Storybooks the machine started swapping, runs stalled for minutes and
time-dependent tests failed.
maxWorkers: 6is just as fast (parity.test.ts determines the duration). - Static Storybooks: the small server (
scripts/serve.ts) has to serve.mjsas JavaScript, otherwise pdf.js does not load its worker. The browser a11y run opens a new page every 50 checks – a reused page grew to several GB. - Focus after a render (React): a focus target that an event sets and an effect fulfills belongs in a layout effect – a still pending passive effect of an earlier render can otherwise consume it first (DataTable: F2 right after saving).
- Search field in lists: when the focus is in the search field (VirtualSelect with
filter), Home/End stay with the caret – browser tests scroll the list directly.
- Generated CSS classes: variants apply via
:is(.wc-x-value, .wc-x-value *)to the element and all parts inside it, defaults via:not(…). That way the specificity matches the order of tailwind-merge (part < variant < compound). Markers likegroup/itemcannot be@applyed and becomein-[.wc-…:first-child]:. - Generator and tailwind-merge: in the recipes,
text-xsremoves an earlierleading-*(tailwind-merge); in@wirecore/wirelayer-cssboth end up in separate rules – Tailwind then takes the line height fromleading-*. Variants that change the font size of a part withleading-*therefore set the line height explicitly (text-xs leading-4). The HTML style comparison found this. - The CSS generator test reads
packages/css/dist, and its build takes@wirecore/wirelayer-recipesfromdist. After changing recipes, buildrecipesfirst and thencss– otherwise the test checks the old state and does not report new variants. - Marker groups and the CSS generator:
group-open/<name>:cannot be translated into.wc-*rules (only pseudo classes like:hover). For<details>,in-open:sits on the child. - Tailwind
grid-cols-(--x)sets the value unchanged asgrid-template-columns– a number needsgrid-cols-[repeat(var(--x),minmax(0,1fr))]. Found while reviewing the EmojiPicker in the browser. - Native
<dialog>: the recipe class setsdisplay:flexand would show the closed dialog –src/native-elements.csshandlesdialog.wc-dialog(incl.::backdrop) by hand. - Animations are only switched on via
motion-safe:. A latermotion-reduce:animate-noneloses against the more specificdata-[state=…]selector. The E2E test that checks the animation in the browser found this. - Animation and pause: the
animationshorthand resetsanimation-play-state. The pause of the toast progress bar therefore hangs on a more specific selector ([[data-paused]_&]:), not onin-data-[paused]:(:where()has no specificity). Found while reading the generated CSS. - AvatarGroup: separating border as an inner
border(as in the design). Aringsits outside and thereby covers the neighbor's initials. - Lucide and
fill: an explicitly passedfill={undefined}replaces Lucide'sfill="none"– the icons were filled black.Icononly setsfillfor filled symbols (Rating). - Contrast: several color pairs from the original design were below AA and were adjusted (see accessibility.md).
- Info and warn buttons only fade to 90 % on hover (
hover:opacity-90): at 85 %, white text would drop below 4.5:1. Tinted buttons (soft) use the tint of the badges (/10, hover/14), colored outline/text buttons/8– all pairs are in the token contrast test. - Contrast on bg-2: tinted badges and the hover of
softbuttons were at ~4.2:1 onbg-2(light only). Status tones are darker (info0.50,warn0.505,danger0.515); the contrast test now checks every tint on bg, bg-2 and card. - Tinted selection and tinted badges: a badge (
success/14) on a selected row (primary/8–10) dropped to ~4.2:1. Selected rows and nodes in RN now only tintprimary/5(the border carries the state), and so do highlighted code lines. - Editor colors: colored text on a colored background (25 %) often only reached ~3:1. Text colors are mixed with
fgby a quarter (color-mix(in srgb …)), backgrounds 20 % – all pairs are in the token contrast test.
- Descriptions in links: if the description is inside the link, it is part of the accessible name. Panel links with
a description are therefore named only by text and badge via
aria-labelledby, and the description is attached viaaria-describedby. - Chat and live regions: a
role="log"would announce a streamed answer on every token. The list hasaria-live="off"andaria-busywhile streaming; announcements come via the LiveAnnouncer (new messages from others, streamed ones only when complete). - axe and chips: the name “Locations: Frankfurt, Vienna” did not contain the chips' visible text “Frankfurt Vienna”
(comma) –
label-content-name-mismatch. In chip mode the name separates with spaces; the same goes for buttons with a counter (“Notifications 4 new”). - Textarea as a combobox: axe does not allow
role="combobox"on<textarea>– Mentions links the suggestions only viaaria-controls/aria-activedescendant.
- Tiptap menus in React:
BubbleMenu/FloatingMenucreate their element withdocument.createElementduring render – they are only rendered once the editor exists (never on the server). - pdf.js in the viewer:
pdf_viewer.mjsreads the API fromglobalThis.pdfjsLibwhen it loads – import and setpdf.mjsfirst, then the viewer. Its container must be absolutely positioned. pdf.js hands bytes to its worker (the caller's buffer would be empty afterwards) – the engine copies them. pdf.js appends the structure of tagged PDFs as fallback content to the<canvas>(roles witharia-ownspointing to the text layer); the browser test checks this link because Playwright does not find the heading by role. - pdf.js layers:
textLayerModeandannotationModeonly apply when thePDFVieweris created. Switching them rebuilds the viewer (page, zoom and rotation are kept). Printing uses CSS named pages (@page s0 …,page: s0) so that portrait and landscape stay mixed.AbortSignal.anyis missing in Safari 16.4. - pdfjs-dist and Node canvas: pdf.js optionally brings
@napi-rs/canvas(native binaries, Node only). Fresh platform packages of it addedminimumReleaseAgeExclude– pnpm now ignores the dependency (ignoredOptionalDependencies), rendering only happens in the browser. In the jsdom tests pdf.js is a stub (setup.ts), the viewer stays in the loading state.
- Expo pins React (SDK 58: React 19.3.0, RN 0.88.0).
@wirecore/wirelayer-nativeuses the same versions, otherwise Metro bundles two copies of React. - Uniwind and themes from packages: Uniwind translates package imports of the CSS file one by one to find theme
variants – custom themes are still unknown there on the first pass.
native.csstherefore declares them itself, and@wirecore/wirelayer-native/styles.cssimports Tailwind's theme anduniwindas well. - react-native-web only triggers Space on
role="button"(Checkbox/Switch additionally toggle with Space on the web) and sets no focus when a modal opens – the dialog focuses its close button itself. The E2E tests (with animation) found this. - react-native-web wraps every modal in its own unnamed
dialog. Our sheets and dialogs are marked up as a named dialog inside it. - react-native-svg loads native TurboModules in the web build that Vite cannot resolve. The Storybook therefore uses
lucide-react(same icons and props); apps keeplucide-react-native. - React Native in Storybook: react-native-svg cannot be bundled in Vite (see above); the RN Storybook replaces it via
an alias with a small DOM SVG stand-in (
.storybook/react-native-svg.tsx) (Svg, Path, Circle – SignaturePad, rings, Gauge, QR code; withviewBoxin the flow, without overlapping the drawing area). A""text as a child of a View ({hint && …}with an empty string) breaks react-native-web – conditions use!!. - React Native – announcements on the web: react-native-web does not know
AccessibilityInfo.announceForAccessibility(empty function).useAnnouncerannounces via AccessibilityInfo on iOS/Android and renders a hiddenrole="status"region on the web. - React Native – roles:
listboxandaria-presseddo not exist in the RN types. Suggestion lists getlistboxonly on the web (iOS/Android read a list), toggle buttonsaria-pressedon the web andaccessibilityState.selectednatively. Selection lists in sheets and the Listbox are radio or checkbox groups – VoiceOver and TalkBack announce these states reliably. - React Native – roles in menus:
menuitemcheckbox/menuitemradiodo not exist in RN. Menus with checkbox/radio items are therefore not amenulist but checkbox and radio groups (like the selection lists); pure action menus staymenu/menuitem. The BottomNavigation is a tab bar (tablist/tab, selected) – that is how VoiceOver and TalkBack announce tab bars; on the web additionallyaria-current="page". - React Native – modals on the web: react-native-web only calls
onShowwhenvisiblechanges, not for a modal that is mounted visible (confirm(),openDialog()) – the dialog therefore sets the focus in an effect (a field withautoFocuskeeps it). ItsTextInputstopskeydown(Escape in the field did not arrive) –useEscapelistens in the capture phase. A closing dialog must not nest following ones in its (invisible) modal, otherwise the next one only appeared after the fade-out – both found by the E2E tests. - Focus after sheets (react-native-web): the modal returns the focus to its trigger when hiding – a focus on a moved
element (Kanban) got lost, especially with animation.
Sheetreports the end viaonHidden(Modal.onDismiss), where the component sets the focus. - axe and names from several texts (RN web): axe joins the visible parts of a button without spaces
(“Team meeting09:00–10:00”) and did not find them in the button's own
aria-label(label-content-name-mismatch). Events, Gantt rows, BarsList links and day headers therefore take their name from the visible text on the web (contentName); iOS/Android read thearia-label. - Scroll areas on the web: horizontally scrolling tables, code and heatmaps need
tabIndex={0}on the web, otherwise the keyboard cannot reach them (axescrollable-region-focusable); loading/load-more rows sit below arole="list", not inside it. - React Native – gestures and the React Compiler:
PanResponder.createinuseStatereads refs during render (react-hooks/refs). Toast swiping and the image viewer therefore use the responder props directly (like SignaturePad), with start points in refs from the handlers. - Empty form values: TanStack forms often start select fields with
''. Select (RN) treats''like no selection, otherwise the button would only say “Service” instead of “Service: Please select”.