Skip to content

rn-motion-ui@5.0.0

Choose a tag to compare

@github-actions github-actions released this 08 Aug 00:02
· 58 commits to main since this release
48d295e

Major Changes

  • 44a0672: FileSystem: drag and drop now runs on Draggable/Dragzone/MultiDragManager.

    Every view had brought its own drag. The list and icons grids shared one hook, the
    columns pane had a second one that mirrored its architecture, web had a third for
    the HTML5 half and external drops a fourth — five hooks, each measuring boxes,
    hit-testing points and tracking a session, and each with its own idea of which
    folder a release belonged to. Adding a view meant writing a sixth.

    They are gone, replaced by the components the library already ships. An entry is a
    <MultiDraggable>, a folder is a <Dragzone>, and the panes and the background are
    zones too — so the ladder a drop falls down (entry, then the column under it, then
    the open folder) is expressed as zone priority rather than as branches inside a
    resolver. props are unchanged: draggable, onMove and onExternalDrop mean
    exactly what they did.

    What changes is behaviour that used to differ per view, and now cannot:

    • The three draggable views drag identically — list, icons and columns — because
      none of them implements dragging any more. They resolve a drop through one hit
      test rather than three that agreed by hand.
    • A multi-select drag carries the selection, via MultiDragManager: drag one of
      three selected rows and all three move. The members left behind fade, and lifting
      an unselected entry still moves just it.
    • Autoscroll while dragging near an edge now works in the columns panes too,
      each scrolling on its own, where before only the list and icons grid had it.
    • A drop is resolved from measured boxes, so a touch pan and a mouse drag land
      on the same folder. Previously the web path read the DOM drop target and the
      pan path hit-tested rows, which could disagree at a row boundary.

    Two fixes fall out of the same work:

    • onExternalDrop now fires for an in-library <Draggable> from elsewhere on the
      page
      , not just for an OS file drag. Its documented contract always covered "a
      custom element on the page that sets drag data"; a payload with no FileSystem
      entries in it is foreign whether or not this library started the drag, and it
      reaches the consumer either way.
    • The hover highlight stands down for the length of a drag, in the list, icons
      and columns views alike, so it cannot mark one cell while a zone outlines
      another. It comes back on the first pointer move after the drop. A mouse drag is
      an HTML5 drag and the browser stops the pointer stream while one runs, so the
      highlight now takes the lift itself as its cue rather than waiting for a
      pointercancel that not every engine sends.

    FS_DRAG_CONTAINER_TEST_ID still names each draggable view's scroll surface, and
    every entry answers to <root>-entry-<path> in all four views. The internal hooks
    useFileSystemDrag, useFileSystemColumnsDrag, useFileSystemIconsDrag,
    useFileSystemDragWeb and useFileSystemExternalDrop are deleted; none was
    exported from the package.

  • 706dac3: Breaking: rn-motion-ui/icons is gone. Icons live in rn-motion-ui-icons.

    The 109 icons this package used to re-export were a subset of Lucide,
    hand-picked because each one had to be committed as generated source. That is a
    bad deal for a consumer: the icon you want is either in that list or it does not
    exist for you, and the list only grew when a component here happened to need
    something.

    rn-motion-ui-icons replaces it with the whole MingCute set — 3335 icons, one
    subpath each. Every icon this package renders internally now comes from there,
    so what components use and what you can use are the same set.

    -import { Check, ChevronRight } from 'rn-motion-ui/icons';
    +import { CheckLine } from 'rn-motion-ui-icons/icons/check-line';
    +import { RightLine } from 'rn-motion-ui-icons/icons/right-line';

    IconProps moved too, and is no longer exported from this package at all:

    -import type { IconProps } from 'rn-motion-ui/icons';
    +import type { IconProps } from 'rn-motion-ui-icons/icon-props';

    Install it alongside this package — rn-motion-ui depends on it, so anything
    that takes an icon (ThemedIcon, CommandIcon, BloomIcon, FileSystem's
    action icons) is already typed against the new IconProps and needs no change
    beyond the import.

    strokeWidth is gone from IconProps. Lucide's geometry is stroked and
    took a width; MingCute ships fill and stroke variants with the weight baked into
    the path, so there is nothing to widen. Drop the prop — it is a type error now.
    Where this package passed strokeWidth={2.5} for a slightly heavier check
    (Input, OTPInput, StatefulButton, AnimatedBadge), those icons now render
    at MingCute's own weight, which is a visible but deliberate change.

    Names do not carry over: MingCute names its own icons, and most differ from
    Lucide's. Every icon is suffixed -line or -fill (1667 line, 1668 fill), and
    the component name is the PascalCase of the file — icons/check-line exports
    CheckLine. The mapping used for the internal migration, if you were relying on
    the same names:

    was (Lucide) now (MingCute)
    AlertCircle, Info icons/information-lineInformationLine
    AlertTriangle icons/alert-lineAlertLine
    Check icons/check-lineCheckLine
    ChevronDown / Up / Left / Right icons/down-line / up-line / left-line / right-line
    Circle icons/round-lineRoundLine
    FileText, ScrollText icons/file-lineFileLine
    FolderClosed, FolderKanban icons/folder-lineFolderLine
    GripVertical icons/dots-vertical-lineDotsVerticalLine
    LoaderCircle icons/loading-lineLoadingLine
    MoreHorizontal icons/more-1-lineMore1Line
    Plus icons/add-lineAddLine
    Trash2 icons/delete-2-lineDelete2Line
    User icons/user-2-lineUser2Line
    X icons/close-lineCloseLine

    The rest resolve the same way: kebab-case the concept, add -line or -fill,
    and the export is its PascalCase.

Minor Changes

  • a4e9e3e: AdaptiveDropdown: rename headerRightheaderSuffix, remove showClose prop; simplify header layout (no fixed height, no border, no built-in close button).

    BottomSheet: increase top corner radius to rounded-t-2xl.

  • 99e42b1: Breadcrumbs: new component — the trail FileSystem already drew, now its own.

    A breadcrumb trail: the levels above the current one, each a way back, with the
    current one as plain text at the end. It knows nothing about what a level is
    pass the segments outermost-first and read back the pressed id, so a folder
    path, a route key and a wizard step are all the same thing to it.

    import { Breadcrumbs } from "rn-motion-ui/breadcrumbs";
    
    <Breadcrumbs
      items={[
        { id: "", label: "Files" },
        { id: "documents", label: "Documents" },
        { id: "documents/reports", label: "Reports" },
      ]}
      onNavigate={navigateTo}
    />;

    A deep trail scrolls horizontally by default, keeping one line. Set maxVisible
    instead to hold it to a fixed number of levels: the middle folds behind a
    that says how much it hides and hands those levels back when pressed, so nothing
    becomes unreachable. scrollable={false} wraps instead of scrolling.

    The rest of the surface: separator replaces the chevron with any node, size
    picks the text scale ('sm' | 'base') and takes the separator and icons with
    it, an icon per item rides ahead of its label, and currentId picks which level
    is the destination — null makes every level pressable, for a trail whose leaf is
    not where you are. className, contentClassName and itemClassName reach the
    container, the segment row and each segment.

    Accessibility: the container is a list named Breadcrumb, every earlier level
    is a button named Go to {label} (override per item with accessibilityLabel),
    and the current level is text — being the one unpressable segment is what marks
    it as current. RN has no aria-current, so the trail does not claim one.

    FileSystem now renders this component instead of its own private trail. No
    API change and no visual change: same placement under the header, same hiding at
    the root, same rootLabel as the leading segment, and the same Go to {label}
    names its stories already query. Both trails — the bar and the per-row ones under
    search results — are now built from one buildCrumbs.

  • ae616b8: Card: pass onPress and the surface becomes pressable

    A card that stands for something you can open had to be wrapped in a Pressable
    by hand, which meant a second element around the one that already draws the
    frame. Give Card an onPress and it renders as the Pressable itself:

    <Card elevation={2} onPress={() => open(project.id)}>
      <Text>{project.name}</Text>
    </Card>

    Omit it and nothing changes — the card is the plain View it always was, with no
    press responder in the tree. The size, elevation and className handling are the
    same either way.

  • 92504b5: Dragzone, DragManager: the receiving half, so a drag can mean something.

    Draggable could carry a payload but had nowhere to put it: you got points in
    window coordinates and wrote the hit testing yourself, per screen, per platform.
    Two components close that, and they compose rather than nest — a source and a
    zone find each other through one module-level store, so a pair of them works with
    no provider anywhere above:

    <Draggable data={{ 'application/x-card': card.id }} groups={['cards']}>
      <Card {...card} />
    </Draggable>
    
    <Dragzone groups={['cards']} overClassName="border-info bg-info/10" onDrop={({ transfer }) => move(transfer.getData('application/x-card'))}>
      <Column />
    </Dragzone>

    groups is the whole compatibility mechanism, and eligibility and acceptance are
    one predicate rather than two: the same check that lights a zone up decides whether
    it takes the release, so a zone cannot highlight and then refuse. accepts gets
    the last word for a rule only the payload knows, disabled removes a zone from
    the decision entirely, and eligibleClassName/overClassName — or the render-prop
    form, {(state) => …} — handle the affordance without a state machine of your own.

    One authority on both platforms. Zones publish their measured boxes to the
    store and the store hit-tests points against them, so a native pan and a browser
    drag resolve a drop through the same code. Overlap is settled without
    configuration: explicit priority, then nesting depth, then the smaller box, then
    mount order — which is what makes a trash can inside a board work with neither
    side declaring anything. priority is there for when the geometry does not say
    what you meant.

    <DragManager> is optional, and adds the four things that need a place in the
    tree to mean anything: a default group for its whole subtree, a boundary drags
    cannot cross (isolate), a frame to draw the ghost in that survives a clipping
    ancestor, and one vantage point to observe every drag beneath it from —
    onDragStart/onDragMove/onDragEnd/onDrop cover the subtree, not just the
    children you can point at. Managers nest by publishing a path of ids rather than
    by stacking providers, so an inner board isolates from an outer one without either
    knowing the other exists, and a zone mounted through a portal or on another screen
    stays reachable as long as no isolating manager sits between.

    The payload is readable for the whole drag, which on web it is not by default.
    The DOM drag data store is protected on every event between dragstart and the
    drop — types still lists the formats, getData returns '', a privacy rule so a
    page cannot read what is merely being dragged across it — and that applies to the
    source's own listeners too, dragend included. So a zone asking accepts what is
    coming, or an onDrop reading it out, would get nothing under a real mouse drag.
    The transfer handed to every callback is therefore a readable mirror, snapshotted
    at lift while the store still answers, with writes going through to the browser's
    own so a format added mid-drag still crosses to a plain drop listener.

    External drags. acceptsExternal lets a zone take a payload the library never
    saw start — OS files, another tab — arriving as drag: null, external: true, and
    files on the event. Off by default, because a zone that has not asked for files
    should not swallow the page's own drop handling.

    Two subscription channels keep the cost honest: render-visible state (a drag
    starting, a zone edge crossed, a drag ending) goes through useSyncExternalStore,
    while pointer movement goes to a separate move channel that no component re-renders
    for. Travelling inside one zone publishes nothing at all.

    Both are pointer-only, on every platform. A manager is the natural home for the
    non-pointer path the same outcome owes its users, since it already sees every drop.

    Draggable, Dragzone and DragManager now live under a gestures category —
    rn-motion-ui/draggable is unchanged, and the store and its hooks are exported at
    rn-motion-ui/drag-store and rn-motion-ui/use-drag-store for custom transports
    or zones. New types: DragzoneProps and DragManagerProps from their own
    subpaths, and — from rn-motion-ui/drag-typesDragzoneHandle,
    DragzoneRenderState, DragzoneDropEvent, DragzoneAcceptEvent,
    DragManagerHandle, DragManagerEvent, ActiveDrag, DragSnapshot,
    DragEndOutcome.

  • b7ea1df: Draggable: one grab-and-carry wrapper for web and native.

    Making something draggable meant writing the platform down. On web you set
    draggable on a DOM node and ride the HTML5 events — and under
    react-native-web you cannot even do that from JSX, because View drops unknown
    HTML attributes, so it took a useEffect reaching for ref.current as an
    HTMLElement. On native there is no such API at all, so you wired a pan gesture
    by hand. Draggable is that work done once:

    <Draggable
      data={{ "application/x-my-item": JSON.stringify(item) }}
      effectAllowed="copy"
      onDragEnd={({ canceled, transfer }) => {
        if (!canceled) console.log(transfer.getData("application/x-my-item"));
      }}
    >
      <Chip label={item.name} />
    </Draggable>

    data is a MIME-keyed payload, written into the transfer when the drag starts.
    onDragStart/onDragMove/onDragEnd fire the same shapes on both platforms,
    with points in window coordinates.

    Three transports sit behind that one contract, each the one actually native to
    where it runs. A mouse on web rides a real HTML5 drag rather than a synthesized
    one, and hands the browser's own DataTransfer straight through — which is what
    makes the payload cross to code that has never heard of this component: an
    existing dragover/drop listener, or <FileSystem onExternalDrop>, receives
    these drags with no adapter. Touch on web gets a pointer-driven pan instead,
    because mobile browsers fire no HTML5 drag for touch at all and the component
    would otherwise simply not work on a phone. Native arms an RNGH pan. Both pans
    wait out a 300ms hold, matching the context-menu hold so the two never both fire,
    and draw a ghost that follows the finger. transports pins the choice when you
    need to: 'pan' keeps a drag inside the library with a uniform ghost and no OS
    drag session, 'html5' opts a component out of touch dragging.

    onDragEnd reports the platform's verdict instead of guessing: dropEffect is
    what a zone claimed, and canceled is dropEffect === 'none' on both sides. A
    native zone claims a drag exactly as a browser one does, by writing
    transfer.dropEffect while the drag is over it.

    The ref is a DraggableHandle: isDragging(), getTransfer(), getNode(),
    measure() (a promise on both platforms, since native's measureInWindow is
    callback-based), and cancel(). cancel() is honestly partial on web — once the
    browser owns a drag, only the user can end it, so it clears component state and
    the store's session while the browser's drag image keeps following the cursor.

    groups names what this drag is, and a <Dragzone> takes it when their labels
    intersect — omit them on both sides and everything matches everything, which is
    the right default for a tree with one kind of drag in it. See the drag system
    changeset for the receiving half.

    A drag is pointer-only on both platforms, so anything expressed only as a drag
    needs a second non-pointer path to the same outcome. New types:
    DraggableProps, DraggableTransports, DraggableHandle, DragTransfer,
    DragStartEvent, DragMoveEvent, DragEndEvent, DragPoint, DragRect,
    DragDropEffect, DragEffectAllowed, DragGroups.

  • 072fe70: FileSystemColumnsView: cross-column drag and drop.

    draggable and onMove now work in the columns view as they do in the others:
    an entry can be dragged across panes and dropped on any valid folder, or on a
    column's own background to land in the folder that pane is showing. A ghost chip
    tracks the pointer, and the receiving row — or the whole pane, for a drop on its
    background — outlines itself.

    • Geometry constants COLUMN_ROW_HEIGHT, COLUMN_ROW_STRIDE, COLUMN_PADDING
      and the columnRowHitAt hit-test helper are exported from
      file-system-column, shared with the marquee and hover resolvers.
    • FS_DRAG_CONTAINER_TEST_ID gains a column key.
  • e86dced: FileSystem: onExternalDrop — accept a drop from outside the component.

    onMove covers dragging entries around inside the browser. It has nothing to say
    about a file dragged in from the OS, or a chip dragged from elsewhere on the page.
    onExternalDrop is that second half, and it hands you the raw transfer rather
    than trying to interpret it:

    <FileSystem
      entries={entries}
      onExternalDrop={({ dataTransfer, destination }) => {
        for (const file of dataTransfer.files) upload(file, destination);
      }}
    />

    destination is the folder the drop landed in, with a trailing slash; '' is the
    implicit root. Read dataTransfer.files for OS files or
    dataTransfer.getData(mime) for data another element set in its dragstart. The
    component never inspects the transfer, so any MIME the browser will carry works.

    Passing the prop is what arms it — the file area accepts external drags and shows
    a dashed drop-zone overlay while one hovers. Leave it out and nothing binds.

    The list and columns views resolve the pointer to a row on every dragover, so a
    folder row under the cursor takes the drop and gets the same per-row highlight an
    internal drag draws. A file row, the padding, or empty space falls back to the
    open folder and the background overlay. Icons and gallery take the background
    overlay for the whole area.

    Web only, and a no-op on native: the HTML5 drag API this rides on
    (dragenter/dragover/dragleave/drop) does not exist there. New type:
    FileSystemExternalDropEvent.

  • d6b0d95: FileSystem: filtering goes headless, and the browser gets breadcrumbs and a
    real search view.
    The component used to own its own toolbar: a sort select, a
    filter menu, a Finder-style search field that collapsed to a button at narrow
    widths, a row of filter pills beneath the header, and the date-range modal the
    filter menu raised. All of it is gone. What stays is the pipeline behind it —
    search, sort, file-type and date filtering, custom ranges — now reachable
    through a renderFilters slot that hands you every action and no markup.

    <FileSystem
      items={items}
      renderFilters={({
        searchValue,
        setSearchValue,
        fileTypeOptions,
        toggleFileType,
        count,
      }) => (
        <MyFilterBar
          count={count}
          onSearch={setSearchValue}
          onToggleType={toggleFileType}
          search={searchValue}
          types={fileTypeOptions}
        />
      )}
    />

    The header keeps back/forward, the folder name and the view switcher.

    Migrating:

    Before Now
    built-in search field, sort select, filter menu and filter pills supply them through renderFilters, or ship no filter UI at all
    FileSystemHeaderState.isSearchExpanded / setSearchExpanded gone — the collapse-to-a-button behaviour was the built-in field's, and there is no built-in field
    renderHeader receiving filters, fileTypeOptions, toggleFileType, selectDatePreset, openCustomRange, clearFilters those moved to renderFilters; renderHeader keeps navigation, view, sort and the raw search value
    openCustomRange(type), which raised the built-in date-range modal applyCustomRange(type, from, to) — bring your own picker, hand the two ends over

    renderFilters gets everything the old toolbar drove — searchValue /
    setSearchValue, sort / setSortKey, filters, fileTypeOptions,
    toggleFileType, selectDatePreset, applyCustomRange, clearFilters,
    hasActiveFilters, isSearching — plus count, the visible entry count after
    search and filtering. Omit the prop and no filter row renders.

    Headless goes all the way down: the date-range modal the filter menu used to
    raise is gone too, so the component now ships no filter UI whatsoever. Dates come
    in through selectDatePreset(type, preset) for a relative cutoff ('1 week ago'
    and friends) or applyCustomRange(type, from, to) for two explicit ends, which
    is where your own calendar hands off.

    Each active filter carries an id, and three actions take one: setFilterOperator
    negates a row, setFilterDatePreset re-values a date row, and removeFilter
    drops it. That's what a filter-pill UI needs to reach one row without rebuilding
    the rest — previously only clearFilters was reachable, which emptied all of
    them.

    Two additions that are not about the slot:

    Breadcrumbs. A trail between the header and the file area, one segment per
    folder down to the current one, each navigating on press. Hidden at the root,
    scrolls horizontally on deep paths. Nothing to opt into.

    Search shows every match at once. A query used to filter the current
    folder's view in place, which meant a match three folders down showed up only as
    the ancestor folder leading to it. It now swaps the view for a flat result list
    — every matching file at every depth, each row naming the folder it came from,
    so a search reads as a search rather than as a filtered folder.

    Search input is also debounced 200ms before it recomputes, so typing into a
    large manifest no longer re-runs the pipeline per keystroke. The field stays
    immediate; only the results wait. Navigating clears the query and cancels a
    pending recompute, so a debounce in flight can't land on the folder you just
    opened.

    Also: computeVisiblePaths short-circuits the ancestor walk for direct children
    of the current folder, instead of walking the parentPath chain through the
    index every time.

  • 1d16717: FileSystem: context menus now use HoldContextMenu throughout — the same interaction the rest of the app uses.

    Every entry long-press opens a HoldContextMenu panel instead of the previous custom modal. The background long-press (empty space in list/icons views and the empty-folder placeholder) does the same. On web the right-click path was already correct; this change brings native into line with it.

    Breaking changes

    FileSystemProps.contextMenuWideBreakpoint is removed. The breakpoint that switched the old modal into a sidebar is no longer meaningful — HoldContextMenu handles its own sizing, and the panel never needed a sidebar mode. Remove the prop from any <FileSystem> usage.

    FileSystemContextMenuProvider is no longer exported from this package. It was an internal implementation detail of the old modal approach. If you imported it directly, remove the import; the context menu is now self-contained inside each entry row.

    HoldContextMenu: new trigger="passive" mode with controlled open/onOpenChange

    When the host needs to control exactly when the menu opens — for example, a button that calls setOpen(true), or an entry row that already owns the long-press gesture — set trigger="passive":

    const [open, setOpen] = useState(false);
    
    <HoldContextMenu
      items={items}
      open={open}
      onOpenChange={setOpen}
      trigger="passive"
    >
      <Pressable onPress={() => setOpen(true)}>
        <Text>Open menu</Text>
      </Pressable>
    </HoldContextMenu>;

    trigger="passive" skips HoldContextMenu's own Pressable wrapper entirely; the host renders whatever gesture target it needs inside. The web contextmenu listener (right-click / Shift+F10) remains active so keyboard users still reach the panel without extra wiring.

  • 2f9bfc7: FileSystem: isLoadingCurrentFolder is now isLoading.

    Breaking. The field named the folder it was about, which every other field in
    the same snapshot also is — currentPath, entries and hasActiveFilters are
    all the current folder's, and none of them say so. The qualifier only made this
    one longer.

    FileSystemBodyState.isLoadingCurrentFolderisLoading, which is what
    renderBody receives:

    // Before
    <FileSystem renderBody={({ content, isLoadingCurrentFolder }) => } />
    
    // After
    <FileSystem renderBody={({ content, isLoading }) => } />

    Same value, same meaning: true while the current folder's children are being
    fetched. Nothing else about the snapshot changes, and a slot that never read the
    field is unaffected.

  • e7acea7: FileSystem: pinnedAt, favoritedAt, and renderEntryIcon.

    Three new capabilities land together because they share the same wiring path through the component tree.

    Pinned entries

    Add pinnedAt (ISO-8601 string or null) to any item and it floats to the top of its parent folder, ahead of every unpinned sibling, regardless of the active sort key or direction. Within the pinned group the chosen sort still applies normally.

    { kind: 'file', path: 'README.md', pinnedAt: '2026-06-01T00:00:00Z',}

    Favorited entries

    Add favoritedAt to mark an entry as a favorite. The flag is surfaced visually in every view and is already consumed by search (boosts hits) but does not reorder entries within a folder — that stays the caller's responsibility.

    { kind: 'file', path: 'Invoice.pdf', favoritedAt: '2026-05-01T00:00:00Z',}

    Visual badges

    All four views (list, column, icons, gallery strip) now render a Pin badge and a Heart badge when the corresponding field is set:

    • List and column: inline icons flanking the entry name.
    • Icons tile: inline in the label chip, tinted to match the selection state.
    • Gallery strip: absolute badges pinned to the tile corners, with a translucent halo for readability over thumbnails.

    renderEntryIcon

    Pass a renderer to substitute a custom icon for any entry. The component falls back to its default glyph when the callback returns null or undefined, so partial overrides work without branching on every entry type.

    <FileSystem
      renderEntryIcon={(entry, size) => {
        if (entry.kind === 'folder' && entry.path.startsWith('Archive/'))
          return <ArchiveIcon size={size} />;
      }}
      
    />

    The prop is forwarded into every view context — icons, list, column, gallery strip — so one callback covers the whole component.


    All three additions are purely additive. Existing items without pinnedAt or favoritedAt render exactly as before, and renderEntryIcon is optional.

  • 234a3cb: FileSystem: search results always say where they live, and show what matched.
    Three changes to the flat result list a query swaps the view for.

    Every row now carries its folder trail, root included. It used to drop the second
    line for a hit sitting at the top level, which meant the one thing a result list
    is scanned for — where each match lives — was answered for some rows and not
    others. The trail is now always there, and the root segment is named rather than
    implied.

    That name is the new rootLabel prop:

    <FileSystem items={items} title="Files" rootLabel="My Drive" />

    It defaults to title, so nothing changes unless you set it. It also names the
    leading segment of the breadcrumb bar under the header, which previously always
    used title — one prop for how the root reads in a trail, with title left as
    the header's own name.

    Whatever matched is highlighted, in the name and in the trail both — a folder
    can be the reason a row is in the list at all, since its own name matching is
    what puts it there. Every occurrence is marked, case-insensitively, the label's
    own casing is untouched, and a label the query matches end to end is marked
    whole.

    The trail separator is a caret, not a slash. Files › invoices › 2024 rather
    than invoices/2024 — it reads as a trail rather than as a path to copy, and it
    matches the chevrons the breadcrumb bar above already uses.

    One note for tests. Highlighting splits a matched label across nested nodes, and
    testing-library's getByText reads a single node's own text — so
    getByText('Q1-report.pdf') no longer finds a search result row whose name the
    query matched. Query the row by its entry test id instead, which is stable across
    every view:

    // `<root testID>-entry-<path>`, or `file-system-entry-<path>` untagged
    const row = await canvas.findByTestId(
      "file-system-entry-Reports/Q1-report.pdf"
    );
    expect(row).toHaveTextContent("Files › Reports");

    toHaveTextContent reads the whole subtree, so it sees through both the
    highlight spans and the trail separators. Rows outside a search are unaffected —
    nothing is split when there is no query.

  • 234a3cb: FileSystem: search can be scoped to the open folder or to the whole tree.
    A query used to always run over the open folder's subtree, so finding something
    you could not place meant navigating back to the root first and searching again.

    renderFilters now hands the slot a scope it can offer as a control:

    renderFilters={({ folderName, isAtRoot, rootLabel, searchScope, setSearchScope }) => (
      <View className="flex-row items-center gap-1.5">
        <Text>Search:</Text>
        <Chip active={searchScope === 'root'} onPress={() => setSearchScope('root')}>
          {rootLabel}
        </Chip>
        {isAtRoot ? null : (
          <Chip active={searchScope === 'folder'} onPress={() => setSearchScope('folder')}>
            {folderName}
          </Chip>
        )}
      </View>
    )}
    • searchScope'folder' (the open folder and everything under it, the
      previous behavior and still the default) or 'root' (the whole manifest).
    • setSearchScope — switches it, taking effect immediately on a live query with
      no debounce, since the press is the whole gesture.
    • rootLabel, folderName, isAtRoot — enough to name both scopes and to know
      that at the root they are the same tree, so only one is worth offering.

    The exported FileSystemSearchScope type is the union.

    Two deliberate boundaries. Only a query widens: filters stay scoped to the
    folder they are shown against whichever way the scope is set, because a filter
    bar reads as being about the folder you are looking at. And the scope outlives a
    query — navigating clears the query but keeps the scope armed, so switching to
    root once does not have to be redone for every subsequent search.

    Nothing changes for existing consumers: the default scope is what the component
    already did, and a slot that ignores the new state keeps behaving exactly as it
    did before.

  • 3f7a5df: Headless calendar, date picker and date range picker. Three hooks that own
    the date arithmetic, the keyboard, and the accessibility payload, and render
    nothing. There is no styled <Calendar /> here on purpose: a calendar is mostly
    markup decisions — seven cells in a row, or a FlatList, or a table — and every
    styled one ends up fought with. FileSystem's applyCustomRange(type, from, to)
    has been waiting for exactly this on the other side of the handoff.

    import { useCalendar } from "rn-motion-ui/hooks/use-calendar";
    
    const calendar = useCalendar({
      mode: "range",
      numberOfMonths: 2,
      minDate: "2026-01-01",
    });
    
    <View {...calendar.getRootProps()}>
      {calendar.months.map((month) => (
        <View key={month.month} {...calendar.getMonthProps(month.month)}>
          <Text {...calendar.getMonthLabelProps(month.month)}>{month.label}</Text>
          <View {...calendar.getGridProps(month.month)}>
            {month.weeks.map((week, index) => (
              <View key={index} {...calendar.getWeekProps(month.month, index)}>
                {week.map((day) => (
                  <Pressable key={day.date} {...calendar.getDayProps(day)}>
                    <Text>{day.day}</Text>
                  </Pressable>
                ))}
              </View>
            ))}
          </View>
        </View>
      ))}
    </View>;

    Dates are ISO 'YYYY-MM-DD' strings everywhere — arguments, return values,
    callbacks. No Date crosses the API, so a value compares with ===, sorts as a
    string, survives JSON, and sits in a dependency array without a stable-reference
    dance. The arithmetic underneath runs in UTC, because +1 day in local time
    lands back on the same date across a DST boundary, and new Date().toISOString()
    names tomorrow for anyone east of UTC in the evening.

    Data arrives decorated. Each cell carries isSelected, isToday, isInRange,
    isRangeStart, isRangeEnd, isPreview, isDisabled, outside and
    isWeekend, so styling is a lookup rather than a recomputation per render — and
    the preview band that follows the pointer while a range's second endpoint is
    unchosen is maintained for you.

    What the getters carry beyond the obvious:

    • A roving tab stop. Exactly one cell per calendar has tabIndex: 0. Tab
      reaches the grid once and arrows move within it; 42 cells in the tab order is
      not the grid pattern.
    • Keyboard. Arrows step a day or a week, Home/End reach the ends of the
      week, PageUp/PageDown step a month and a year with shift. A step past the
      visible months pages the view, and focus follows onto the destination cell even
      when that cell mounts after the step. isRTL mirrors the horizontal axis
      only — up is still up. preventDefault fires only for keys the grid acts on,
      so Tab still leaves.
    • Disabled days keep their tab stop. They get aria-disabled and
      accessibilityState but not disabled, because a day you cannot reach cannot
      tell you why it is unavailable. The press handler refuses.
    • Both a11y dialects, always together. Native accessibilityRole/
      accessibilityState and web aria-*, since react-native-web maps only the
      aria- form.

    The two pickers add a disclosure, a typeable field per date, and a backdrop:

    const picker = useDatePicker({ onSelectDate: setValue, testID: 'depart' });
    
    <TextInput {...picker.getFieldProps()} />
    <Pressable {...picker.getTriggerProps()}><Text>Pick a date</Text></Pressable>
    {picker.isOpen ? (
      <>
        <Pressable {...picker.getDismissProps()} />
        <View {...picker.getPanelProps()}>{/* the calendar, as above */}</View>
      </>
    ) : null}

    The field is forgiving where a date field has to be: typing shows a draft without
    committing, blur commits, submit commits and closes. Unparseable text snaps back
    to the current value instead of silently discarding it, and a complete allowed
    date moves the grid as you type so field and calendar never disagree. format
    takes a { parse, format } pair for a non-ISO field order.

    useDateRangePicker differs only where a range does: two months by default, two
    independent field drafts, and it closes when the range is complete rather
    than on the first press, which only starts it. A range entered backwards is
    reordered rather than rejected, clearing one field leaves a half-open range the
    next press completes, and a date typed into the end field is revealed in the
    last month on screen so the start stays visible beside it.

    The trigger is a button with aria-expanded, deliberately not a combobox: RN
    has neither aria-controls nor aria-haspopup, so a combobox would announce a
    popup assistive tech cannot then find. The panel is a dialog whose three modal
    flags all follow one modal option, so an inline calendar never claims to trap
    focus that nothing has trapped. A day cell is a button rather than a gridcell,
    which RN's role union does not have.

    Pass testID and every child derives one (depart-day-2026-08-05,
    depart-grid-2026-08, depart-trigger, depart-panel); pass nothing and no
    testID is emitted anywhere, so a tree stays clean by default.

    New subpaths: hooks/use-calendar, hooks/use-date-picker,
    hooks/use-date-range-picker, plus the pure modules behind them — calendar
    (the date core), calendar-format, calendar-props, calendar-selection,
    date-field and date-picker-props — exported so a consumer can type a render
    function or reuse the arithmetic without the hooks.

  • 4c88409: HoldContextMenu: trigger="passive", and a controlled open.

    The component has always owned the press: it wraps children in a Pressable,
    reads the gesture activateOn names, and squeezes under the finger. That is the
    wrong shape when the children are already a button, or already own a long-press —
    you get a second button nested in the first, an extra tab stop per item in a long
    list, and on native two press responders competing for the same touch.

    trigger="passive" drops the Pressable entirely. What is left is the measured
    wrapper the panel anchors to, and the host opens the menu itself:

    const [open, setOpen] = useState(false);
    
    <HoldContextMenu
      items={items}
      open={open}
      onOpenChange={setOpen}
      trigger="passive"
    >
      <Pressable onPress={() => setOpen(true)}>
        <Text>Open menu</Text>
      </Pressable>
    </HoldContextMenu>;

    open makes the component controlled and works under either trigger mode. The
    anchor is measured when it flips true, so a host can open the menu without a
    gesture having measured anything first — the panel paints one commit later, once
    that measurement lands. Leave open out and the trigger keeps the state, exactly
    as before.

    Two things survive the missing Pressable. Web's right-click still opens the
    panel: the contextmenu listener sits on the wrapper and the event bubbles to it
    from whatever the children render, so keyboard users reach the menu through
    Shift+F10 without extra wiring. And nothing squeezes — the press it previewed
    belongs to someone else now — so the lifted copy springs from rest rather than
    from HOLD_ITEM_SCALE, which would otherwise read as a 5% pop out of an item the
    user never touched.

    New: wrapperRef, which hands you the measured node — a real DOM element on web —
    for attaching your own listeners to it. New type: HoldContextMenuTriggerMode.

  • dfbc2da: HoldContextMenu: new component — an action menu that reads as the platform's
    own. On iOS and Android, hold an item: it lifts off the page, the rest of the
    screen dims, and an iOS-style panel grows out of the corner nearest it, both
    travelling together so the pair stays on screen. On web it is a right-click, and
    what opens is a plain dropdown anchored to the item — no hold, no lift, no dim.
    A mouse already has a button for this, and once the gesture is a click there is
    no press to animate and nothing to lift out from under a finger. One component,
    two honest presentations; activateOn="tap" and "double-tap" read the same
    either way, and get the dropdown on web too.

    A port of react-native-hold-menu
    (MIT, Enes Öztürk) onto this package's primitives. Same interaction, none of the
    upstream dependencies: no @gorhom/portal, expo-blur, expo-haptics, nanoid,
    lodash.isequal or react-native-gesture-handler.

    import { HoldContextMenu } from "rn-motion-ui/hold-context-menu";
    
    <HoldContextMenu
      items={[
        {
          id: "reply",
          label: "Reply",
          icon: MessageCircle,
          onPress: () => reply(message.id),
        },
        {
          id: "copy",
          label: "Copy",
          icon: Copy,
          onPress: () => copy(message.body),
          separator: true,
        },
        {
          id: "delete",
          label: "Delete",
          icon: Trash2,
          destructive: true,
          onPress: () => remove(message.id),
        },
      ]}
    >
      <MessageBubble message={message} />
    </HoldContextMenu>;

    Coming from upstream:

    Upstream Here
    <HoldMenuProvider> at app root + portal host nothing — each menu owns a Modal through OverlayShell
    expo-blur scrim native dims and blurs with backdrop-blur-xs; web paints nothing, since a dropdown does not dim the page
    hapticFeedback="Medium" (expo-haptics style name) hapticstrue buzzes on Android, or pass your own function
    items[].text / isTitle / isDestructive / withSeparator items[].label / heading / destructive / separator, plus id and disabled
    actionParams map keyed by label close over what you need in the item's onPress
    bottom + menuAnchorPosition side + align, matching Popover and AdaptiveDropdown
    theme="light" | "dark" theme tokens — follows the active scheme on its own
    menu width fixed at 60% of the screen menuWidth, default 240, clamped to the viewport
    closeOnTap defaults to false defaults to true, the iOS behaviour — native-only, as nothing lifts on web to tap
    panel scales out of the corner from 0.6, lift on a spring of its own the shared anchored-menu motion — scale from 0.96 with an 8px slide toward the trigger, lift on the panel's own transition so the two move as one thing. motion={{ scale: 0.6, offset: 0 }} restores upstream's entrance

    Beyond the port: the right-click that opens the menu on web (openOnContextMenu,
    on by default) is also the keyboard path — browsers raise contextmenu for
    Shift+F10 and the ContextMenu key on the focused trigger, so the panel is
    reachable without the pointer a hold gesture requires. Enter and Space are left
    alone there, so they still reach whatever you nest inside the trigger. On native,
    screen readers open it through a longpress accessibility action. Rows are
    menuitems, a heading row is presentation, a disabled row carries
    aria-disabled, and the scrim is a named button rather than a bare tap target.
    useReducedMotion swaps every spring for a short fade.

    The right-click is the mouse path on web; a touch press still holds — mobile
    web has no right button, so holdDuration is always pinned into the gesture's
    holdDelay, even for a draggable trigger whose drag tuning would otherwise
    have no hold on web at all. While the hold charges, the trigger squeezes to
    95%, and the squeeze releases the moment the menu opens, not when the press
    ends: on native the lifted copy takes the scale over, and on web — where
    nothing lifts — a squeeze that outlived the open would spring back to full size
    exactly as the menu closed or a drag escaped, a flicker on the trigger at the
    worst moment. Either way, closing the menu leaves the trigger motionless. The
    drag ghost a menu-escape lifts starts exactly over the item it came from, too —
    its first frame used to sit offset by wherever the finger grabbed.

    onHold fires alongside the menu, right after it opens — one gesture, both
    outcomes — and still fires when the menu has no items to show, so a
    multi-select toggle riding it keeps working while the panel stays silent.

    Panel placement is measured per-open from useWindowDimensions and the safe-area
    insets, not from module-level Dimensions constants read at import time, so it
    survives a rotation. The height estimate the layout runs on is corrected from the
    panel's real onLayout height, so a row that wrapped under a large accessibility
    font still gets a panel that fits.

    The panel opens on the same motion as every other menu a trigger summons, and
    motion retunes it — enter, exit, scale and offset shared with
    AdaptiveDropdown, HoverMenu and Popover, plus scrim and lift for the two
    surfaces only this menu has. useReducedMotion still wins over all of it.

    // Upstream's entrance, for anyone who ported from it and wants the old feel back.
    <HoldContextMenu motion={{ offset: 0, scale: 0.6 }} items={items}>
      <MessageBubble message={message} />
    </HoldContextMenu>
  • e9f5fe0: feat(Menu): composable menu list for dropdowns and context menus

    Menu takes an entries array and renders the inside of a menu — action rows,
    separators, group labels, and arbitrary nodes — so consumers stop hand-rolling a
    View full of MenuItems. Entries compose with &&, so a conditional row is
    just condition && { id, label, onSelect }; falsy entries are dropped.

    Four entry kinds, discriminated on type, which is optional for the common one:

    • { id, label, onSelect } — an action row (type: 'item' implied)
    • { type: 'separator' }
    • { type: 'label', label } — a non-interactive group caption
    • { type: 'node', node }, or a bare ReactElement as shorthand

    It carries the semantics (role="menu", role="menuitem", accessibilityState,
    aria-disabled, role="presentation" on captions), closes the panel before
    running the action so a navigating row does not strand a modal, and aligns labels
    across a mixed list via an auto-detected icon gutter (iconGutter).

    Menu owns no frame: no background, border, radius, width, or horizontal
    padding. The surface belongs to whatever holds it — AdaptiveDropdown's panel,
    HoldContextMenu's, a Card, a sidebar column. The one exception is the
    vertical inset, which the list keeps for itself so the end rows clear a rounded
    panel corner without every container having to remember to pad for them.

    Also: MenuItem gains a destructive prop (danger-tinted label and icon, with
    the bg-info active fill still winning over it) and now dims while disabled.

  • f3e006d: MenuItem: mode prop, hover/press feedback, retuned default scale

    New: mode. The default (non-iconBackgroundColor) variant now comes in two
    flavours, because the same row serves two jobs: a command-palette list, where
    every entry reads as equally available, and a sidebar, where the selected entry
    has to win against its neighbours.

    mode Label Leading icon
    'menu' (default) foreground, normal weight — active or not foreground, active or not
    'sidebar' font-medium; muted-foreground when inactive muted-foreground when inactive
    // Sidebar: the active row is the only one at full contrast
    <MenuItem
      label="General"
      icon={Settings}
      mode="sidebar"
      active={tab === "general"}
      onPress={go}
    />

    mode is ignored when iconBackgroundColor is set — that variant keeps its own
    treatment (coloured icon square, filled active row, label inverted over the
    fill), and 'sidebar' no longer leaks its medium weight onto that label.
    The default is 'menu', which is the previous behaviour for active rows, so
    existing call sites keep their look except for the scale changes below.

    New type: MenuItemMode.

    Hover and press feedback. The row now fills on hover (surface-hover) and
    on press (surface-selected). Both are suppressed while active or disabled,
    so the active highlight is never double-painted and a disabled row stays inert.

    Driving those fills means the row owns onHoverIn, onHoverOut, onPressIn and
    onPressOut, so each one forwards to a caller's handler of the same name after
    setting its own state. Passing any of the four still works exactly as before —
    CommandPalette relies on this, using onPressIn to move its active row.

    Retuned default scale. The md and lg rows were loose next to the
    palettes and sidebars they're used in — md lost vertical padding and a label
    step, lg gained horizontal padding and a larger icon:

    Size Changed
    sm label pinned to 12px (was text-xs, the same size)
    md py-2py-1.5; label 16px → 14px; icon spacer h-4 w-4h-5 w-5
    lg px-3px-4; icon 22 → 24; icon spacer h-4.5 w-4.5h-6 w-6; label pinned to 18px

    Label sizes are now explicit pixel values rather than Tailwind's text-* steps.
    The iconPlaceholder spacer sizes are matched to the rendered icon at each size,
    so a row without an icon now aligns with one that has it.

  • 603c921: Menu motion is now one definition, and it is yours to retune. Every panel a
    trigger summons — AdaptiveDropdown, HoverMenu, Popover, HoldContextMenu
    had drifted onto its own spring, its own exit and its own idea of whether to scale
    or slide. Four menus, four entrances. They now share one: a fade up from 0.96
    with an 8px travel toward the trigger, growing out of the corner facing it, and a
    200ms ease-in on the way out.

    Each of the four takes a motion prop to change that:

    import { HoverMenu } from 'rn-motion-ui/hover-menu';
    
    // Slower, softer, and further.
    <HoverMenu motion={{ enter: { type: 'spring', stiffness: 140, damping: 22 }, offset: 16 }} items={items}>
      <Button>Insert</Button>
    </HoverMenu>
    
    // No movement at all — just the fade.
    <Popover motion={{ offset: 0, scale: 1 }}></Popover>

    enter and exit are Partial<MotiTransitionProp>, merged over the preset the
    same way Button, Tabs, Switch, Radio and Checkbox already take theirs, so
    a partial override changes only what it names. scale: 1 drops the scale and
    offset: 0 drops the slide. HoldContextMenu adds scrim and lift for the two
    surfaces only it has. useReducedMotion overrides all of it — a motion prop
    cannot animate a menu for someone who asked the OS for less.

    rn-motion-ui/theme/motion exports what the four run on, for anyone matching a
    custom overlay to them: resolveMenuMotion returns the whole
    from/animate/exit/transition set for a side, menuTransformOrigin gives
    the corner a panel should grow from for a side/align pair, and
    MENU_ENTER_SCALE / MENU_ENTER_OFFSET / MENU_EXIT_TRANSITION /
    MENU_SCRIM_TRANSITION are the tokens behind the defaults.

    Visible changes from this: AdaptiveDropdown, HoverMenu and Popover now scale
    slightly as they open rather than only sliding, and all three grow from the corner
    nearest their trigger instead of their centre. HoverMenu's exit goes 180ms →
    200ms.

  • 1d9a279: Menu: captions, separators and node entries are addressable by testID.

    Give the list a testID and every action row already derives one from it —
    ${testID}-item-${id}, which is how HoldContextMenu's rows have been reachable
    in a test. The other three entry kinds got nothing, and they are the ones with no
    alternative: a caption is role="presentation" and a separator is a bare band, so
    neither carries a role or an accessible name to query by. Selecting them meant
    getByText against user-facing copy, or nothing at all.

    Each non-action entry now takes the list's testID plus the React key the list
    had already assigned it:

    <Menu
      testID="row-actions"
      entries={[
        { type: "label", id: "group", label: "Message" }, // row-actions-group
        { id: "reply", label: "Reply", onSelect: reply }, // row-actions-item-reply
        { type: "separator", id: "after-reply" }, // row-actions-after-reply
        { type: "separator" }, // row-actions-separator-0
      ]}
    />

    An entry with an id is named by it. One without falls back to the same per-type
    running count that already keys it — and that count only advances for the unnamed
    ones, so the numbering is positional among them rather than among all separators.
    Pin an id on anything you plan to select. Any entry can also set testID
    outright to override the derivation, as action rows could already.

    MenuSeparator and MenuLabel take a testID too, so a node entry drawing its
    own matching hairline can name it the same way the list would have.

    HoldContextMenu inherits this: its panel already passes ${testID}-menu down,
    so a heading row is now ${testID}-menu-<id> and the band a separator: true
    row ends its group with is ${testID}-menu-<id>-separator.

  • d8c1833: refactor(menus)!: the anchored panels fill themselves with Menu

    Menu arrived as the list you could put inside a panel. This makes it the
    list the panels actually use. HoldContextMenu had its own row component — a
    port of react-native-hold-menu's MenuItem, with its own hover and press fills,
    its own disabled dimming, its own heading branch — which is now a second
    implementation of something this package already has. It is gone, and the panel
    renders a Menu.

    hold-context-menu-row.tsx is deleted. Nothing imported it directly: it was
    never an entry point, and both types it exported (HoldContextMenuItem,
    HoldContextMenuIcon) are still exported from rn-motion-ui/hold-context-menu.
    The item type is unchanged and stays the component's public API — it reads in
    upstream's vocabulary (heading, separator) rather than Menu's, so a new
    hold-context-menu-item.ts translates one into the other.

    The rows look different. Adopting Menu's row means adopting its layout:

    • the icon leads the label instead of trailing it
    • a heading row is Menu's group caption — left-aligned and muted, where it
      used to be centred
    • rows are shorter (44 → 32px floor), and the heading with them (34 → 24px)
    • the hairline under every row is gone. Only a row with separator: true
      draws anything, and what it draws is the band that ends a group

    Breaking: the panel's testID is now `${testID}-panel`; it used to be
    `${testID}-menu`. Row testIDs are unchanged
    (`${testID}-menu-item-<id>`). Queries by role are unaffected.

    role="menu" moved onto the list. The panel used to carry it while the rows
    carried menuitem, with a wrapper in between; now the element with the role
    owns its rows directly, and there is no chance of one menu nesting inside
    another. The accessible name moved with it, so accessibilityLabel still names
    the menu.

    Menu now owns its vertical inset. Asking every container to remember
    contentClassName="p-1" was the wrong split: the clearance the first and last
    row need from a rounded panel corner is the same in every panel that holds the
    list, and forgetting it left rows sitting against the corner. So the list caps
    itself top and bottom (py-2.5) and still draws no horizontal padding, no
    surface, no border, no radius, no width. Retune it with className="py-*", or
    py-0 to drop it — HoldContextMenu pins its own, because it has to predict
    its height before layout and wants a number it chose.

    Also in Menu: a separator is a 4px band with margins around it (12px total at
    md) rather than a 1px hairline, group captions sit tighter to the group they
    name, and MENU_SEPARATOR_HEIGHT is exported for panels that have to predict
    their own height before layout.

    HoldContextMenu's pre-layout height estimate follows the new metrics, and now
    counts the panel's border and the list's inset once rather than leaving them
    out. HOLD_MENU_MIN_PANEL_HEIGHT is the floor a panel is clamped to — border,
    inset and one row, so the row that survives the clamp is a whole one.

    CommandPalette caps its scroller with max-h-[60vh] instead of measuring
    useWindowDimensions(). uniwind compiles vh against the same window
    dimensions and the same resize event, so this is the same height with one less
    hook.

  • 97a2b25: Breaking: rn-motion-ui/moti/progress and rn-motion-ui/moti/motify-svg are gone.

    Both came over with the Moti layer and neither earned its place. MotiProgressBar
    was a progress bar with hardcoded hex defaults (#333, #eee, #00C806) that
    answered to nothing in the token system, so it could not sit next to anything else
    in this library without looking foreign. Nothing here rendered it.

    -import { MotiProgressBar } from 'rn-motion-ui/moti/progress';

    A bar is two views and a translate. If you were using it, the replacement is a
    MotiView inside a clipping parent — which is all it ever was, minus the
    re-render warnings:

    import { MotiView } from "rn-motion-ui/moti/view";
    
    <View className="h-3 w-full overflow-hidden rounded-full bg-muted">
      <MotiView
        className="h-full w-full rounded-full bg-primary"
        animate={{ translateX: `${Math.round(progress * 100) - 100}%` }}
        transition={{ type: "timing", duration: 200 }}
      />
    </View>;

    motifySvg was a second motify that wrote to animatedProps instead of style,
    for animating SVG attributes like r or strokeDashoffset that are props rather
    than styles. Every SVG animation in this package is written directly against
    Reanimated's useAnimatedProps instead — Loader, ScrollProgress,
    AnimatedBadge, Checkbox, StarRating — so the wrapper was carrying an API
    surface no caller used.

    -import { motifySvg } from 'rn-motion-ui/moti/motify-svg';
    -const MotiCircle = motifySvg(Circle)();
    +import Animated, { useAnimatedProps } from 'react-native-reanimated';
    +import { Circle } from 'react-native-svg';
    +const AnimatedCircle = Animated.createAnimatedComponent(Circle);

    react-native-svg stays a peer dependency — the components above still need it.

  • b2aabe1: MultiDragManager, MultiDraggable: drag a selection, not an item.

    <Draggable> knows what it holds and nothing about the list it sits in. So
    dragging one of three selected rows moves one row — the other two stay put,
    because no single draggable was ever told they existed. Every list with
    multi-select ends up re-deriving the same three things to fix that: which ids a
    lift should carry, one payload built from all of them, and a way for the members
    left behind to know they are moving too.

    <MultiDragManager
      selectedIds={selected}
      getGroupData={(ids) => ({ "application/x-rows": JSON.stringify(ids) })}
      renderPreview={(ids) => <Chip label={`${ids.length} items`} />}
    >
      {rows.map((row) => (
        <MultiDraggable id={row.id} key={row.id}>
          <Row row={row} dimmed={useIsLifting(row.id)} />
        </MultiDraggable>
      ))}
      <Dragzone onDrop={({ transfer }) => move(readMultiDragIds(transfer))} />
    </MultiDragManager>

    Lifting a selected item carries every selected id; lifting an unselected one
    carries just it and leaves the selection alone. That rule is resolveIds, and
    the default is the one a file manager, a mail list and a canvas all want —
    replace it for a list where it is not.

    The ids in flight are read back off the drag's own transfer rather than recorded
    at lift time, which is what keeps them right on every transport and after a
    cancel: nothing to clean up, because the set empties when the drag does.
    useIsLifting(id) is how a member that is not under the pointer knows it is
    nonetheless moving — the hook that fades the rest of the selection.

    The group also travels as application/x-multi-drag-ids on the transfer, so a
    plain <Dragzone onDrop> reads it with readMultiDragIds — and under the HTML5
    mouse transport, so does a drop listener that has never heard of this library.
    withMultiDragIds adds the same key to a payload you are building yourself.

    It is a <DragManager> underneath, with all of its props: zones, isolation,
    groups and the ghost overlay behave exactly the same. renderPreview draws the
    group ghost for the pan transports; under HTML5 the browser draws its own drag
    image and it is not consulted.

    A multi-select drag needs its keyboard equivalent more than a single one, since
    the selection it acts on is already reachable without a pointer — a "Move
    selected to…" command belongs next to the onDrop that performs it.

    New subpaths: rn-motion-ui/multi-drag-manager, rn-motion-ui/multi-draggable,
    rn-motion-ui/multi-drag and rn-motion-ui/multi-drag-scope. New types:
    MultiDragManagerProps, MultiDraggableProps, MultiDragIdResolver,
    MultiDragScope.

  • 51dd604: Seven components now name their repeated children from the root testID.

    Each of these already took a testID and put it on its outer element, which left
    the interesting parts unaddressable: the stars, the wheel options, the swipe
    actions. A test could reach them only through accessibilityLabel, which ties the
    selector to user-facing copy, or not at all where the element carries no role and
    no name.

    Component New testIDs
    StarRating -star-<n> — both the interactive and readOnly paths
    WheelPicker -option-<value>
    SwipeableList -row-<id>, and -row-<id>-action-<id> per swipe action
    BouncyAccordion -item-<id>
    BloomMenu -item-<index>, -trigger, -close
    CommandPalette -item-<id>, -group-<group>
    OverflowActions -item-<id>

    Five of the seven add nothing when testID is omitted, so a component that does
    not ask for one renders exactly as before. The exceptions are WheelPicker and
    SwipeableList, whose roots already defaulted to 'wheel-picker' and
    'swipeable-list' — their children derive from that default, so options and rows
    are named either way. Where an item type already had its own optional testID
    (BouncyAccordion, BloomMenu, CommandPalette, OverflowActions), it still
    wins; the derived name is the fallback.

    Two of these render their children twice, and only the live copy is named.
    WheelPicker paints a second aria-hidden drum for the bright centre band and
    OverflowActions keeps an offscreen measurer to feed its width spring; naming
    both copies would return two nodes per getByTestId. aria-hidden does not hide
    an element from getByTestId the way it hides one from findByRole, which is why
    the role queries in these components were never ambiguous but the testIDs would
    have been.

    OverflowActions had that bug already: its measurer duplicated any item.testID
    a caller set, so naming an action made every query for it ambiguous.
    ActionButtonProps now takes an already-derived testID instead of reading
    item.testID itself, which is what lets the measurer stay silent no matter what
    the item carries.

    SwipeableList keeps its old default. Rows were hardcoded to
    swipeable-row-<id>, ignoring the root entirely — two lists on one screen
    collided. Rows now derive from the root when there is one and fall back to the
    old string when there is not, so existing selectors keep working while a named
    list gets <testID>-row-<id>.

    StarButton (exported) gains an optional testID. MenuItem itself is untouched —
    it already forwarded one; what changed is the components above it passing a derived
    name down.

    Four stories now assert through the new IDs rather than around them. SwipeableList
    is the clearest case: every row repeats the same four action labels, so its old
    findAllByRole('button', { name: 'Trash' })[0] could assert that something was
    pressed but not which row, and the buttons sit behind the draggable surface with no
    swipe to reveal them in jsdom. It now names the row and the action together and
    asserts the payload. The WheelPicker and OverflowActions plays query by testID
    specifically so a single match proves the duplicate copy stayed anonymous.

  • f3e006d: Switch: reduced motion cuts the thumb, and a tighter label gap

    Reduced motion now wins over thumbTransition. When the OS asks for reduced
    motion, the thumb cuts straight to its position (TIMING_INSTANT) instead of
    springing, matching how every other animated control in the library treats the
    setting. Previously the spring ran regardless, and a caller-supplied
    thumbTransition was merged in even under reduced motion — now it is ignored in
    that case. With reduced motion off, thumbTransition merges over the default
    spring exactly as before.

    The gap between the track and the label shrinks from 12px to 8px, and is now
    set by a gap-2 class on the root rather than an inline style. Rows of
    switches read slightly tighter; pass a style to set your own spacing.

  • 04e622d: Switch: sm, md, and lg size variants.

    The track, thumb, travel distance and label text scale now all respond to a single
    size prop. 'md' is the default, so existing usage is unchanged.

    <Switch isSelected={on} onSelectedChange={setOn} size="sm" label="Compact" />
    <Switch isSelected={on} onSelectedChange={setOn} size="md" label="Default" />
    <Switch isSelected={on} onSelectedChange={setOn} size="lg" label="Large" />
    size track thumb travel
    sm 16 × 32 px 12 × 20 px 8 px
    md 20 × 44 px 16 × 26 px 14 px
    lg 28 × 56 px 24 × 36 px 16 px

    The thumb's height is not a per-size number: it insets 2px from the top and the
    bottom of whatever track holds it, so the two always agree and a retuned track
    carries the thumb with it.

    Size is threaded through context, so Switch.Thumb and custom children pick it up
    automatically — no extra prop is needed on sub-components.

  • 51c43f7: Breaking: AnimatedNumber and NumberTicker are merged into a single
    TextNumberTicker, exported from rn-motion-ui/text-number-ticker. The
    /animated-number and /number-ticker subpaths are gone.

    The two components animated the same thing two ways: NumberTicker rolled a
    column per digit, AnimatedNumber counted one label up to the value. That is
    now the mode prop — 'roll' (default) and 'count':

    // Before
    <NumberTicker value={48273} locale={true} stagger={0.04} />
    <AnimatedNumber value={129480} duration={1.2} />
    
    // After
    <TextNumberTicker value={48273} locale={true} stagger={0.04} />
    <TextNumberTicker mode="count" value={129480} duration={1.2} />

    duration keeps each component's old default per mode (0.9s per digit in
    'roll', 1.2s total in 'count'), so neither migration changes timing.

    Props that were only on one of the two now apply to both where it makes sense:
    'count' gained pad, locale, prefix and suffix, and 'roll' gained
    format. stagger and digitClassName stay 'roll'-only. A custom format
    in 'count' receives the in-flight fractional value and owns its rounding,
    which is what lets a compact formatter stay legible mid-count; without one the
    value is rounded before formatting.

    NumberTicker's blur prop is dropped rather than carried over. It was
    accepted for web API parity and documented as having no visual effect on React
    Native, so nothing rendered differently for it.

Patch Changes

  • 6605e30: fix(AnimatedBadge): the loading spin and the pulse survive a parent re-render

    Both loops were declarative MotiViews with transition={{ loop: true }}. Moti
    resolves its pose inside a useAnimatedStyle whose dependency list includes the
    animate object, and that object is a fresh literal on every render — so any
    re-render from above (a status change, an interval tick, a theme swap) re-ran the
    worklet and re-issued the tween from the current value. The spin restarted
    mid-revolution with a full second to cover the remaining arc, which read as a
    stutter and a speed change rather than one steady turn; a badge under a parent
    that re-renders every 50ms barely moved at all.

    withTiming's default easing was the second half of it. Easing.inOut(Easing.quad)
    eases to a stop at each revolution boundary, so even an uninterrupted loop paused
    once per turn.

    The spin and the halo are now two small components driving one shared value each,
    started in an effect and cancelled on unmount, with Easing.linear on the spin —
    the same shape the Marquee and TextShimmer loops already use. Re-renders never
    touch a shared value, so the loop keeps its phase.

    One shared value now drives both the halo's opacity and its scale, which also
    keeps them in phase: as two moti properties they drifted apart, since moti
    defaults scale to spring while opacity is timing.

    No API change — status="loading" and the pulse behave as documented, they just
    actually animate continuously now.

  • 0756779: fix(Draggable, FileSystem): one press, three outcomes — a tap, a hold, or a drag, arbitrated in one place

    Holding an entry in <FileSystem draggable> did nothing on touch: the context menu never opened. Right-click on web was unaffected, which is why this only ever showed up on native and on web touch.

    The cause was two timers in two different gesture systems with nothing arbitrating between them. Both pan transports started the drag straight off their own 300ms hold timer, and RNGH cancels the touches under an activating handler — so at t=300 the entry's Pressable lost its long-press timer, which under trigger="passive" is the only way into the menu. The drag always won, 200ms before the press could fire.

    <Draggable> now owns the whole timeline, because it is the only thing that sees every touch, and reports the outcomes it does not keep:

    • onHold — the press stayed down past the hold delay without travelling. Open a menu, toggle a selection, start a preview.
    • onHoldEscape — a drag took the gesture back off a hold that had already fired. Undo what the hold did.

    The press reads in three phases, off four numbers both transports share so the gesture feels identical on either:

    before armDelay (150ms) movement belongs to the scroll — the pan gives the gesture up, so a list inside stays scrollable
    after it slop (10px) of travel lifts a drag
    at holdDelay (300ms), still still onHold fires, and no drag lifts from this press unless it travels escapeSlop (24px)

    The last row is the escape hatch: whatever the hold put on screen is under the finger by then, so getting out from under it takes a deliberate shove rather than the drift of a hand that thought it had finished.

    Those four numbers are not one set. They default per platform and are overridable per platform, through behavior on <Draggable> or once for a whole subtree on <DragManager>, in Platform.select vocabulary — flat fields everywhere, native for everything but web, then a block per OS:

    <DragManager behavior={{ armDelay: 100, android: { slop: 12 }, ios: { holdDelay: 400 } }}>

    Web, macOS and Windows default to no hold at all (holdDelay: null, so no timer is armed and onHold never fires). A desktop already has a gesture for "tell me about this thing" and it is the right button; on web, a long press on touch is already the browser's own text selection and context menu, and a second meaning layered on top fights both. The arm window stays even there, because web touch still shares the surface with the page's scroll. Android tightens slop to 8px and escapeSlop to 20px, matching ViewConfiguration's scaled touch slop where iOS follows UIKit's more forgiving 10pt.

    The resolver is pure and separately tested: resolveDragBehavior(behavior, os) flattens the whole thing to four numbers, DRAG_TUNING_DEFAULTS is the per-OS table, and useDragBehavior resolves against the running platform. Everything downstream reads those numbers and never asks which OS it is on again.

    useDraggable, and a <Draggable> that draws nothing

    The drag is now available without the wrapper's markup. useDraggable() is the whole of <Draggable> bar the three elements it renders — transport selection, the press timeline, the session, store registration, the measured rect, the handle — exposed as getRootProps() / getGhostProps() plus gesture for the GestureDetector a hook cannot render itself. For a row in a FlatList that must not gain a wrapper View, or a Pressable host, or a ghost drawn a different way.

    <Draggable> stays, as that hook plus a View, and has lost its own styling: no cursor-grab, no cursor-grabbing, no lifted state. className and style land on the host untouched. The replacement is isDragging, now reactive render state rather than only the imperative handle.isDragging() — and true under every transport, including the ones that draw no ghost here. The one style the hook still supplies is userSelect: 'none' on web, which is functional: without it a drag starting on text selects the text instead of lifting.

    Native switches from activateAfterLongPress(300) to Gesture.Pan().manualActivation(true), with the phase decision in onTouchesDown/onTouchesMove worklets. That prop cannot express this gesture: it flips to ACTIVE off a timer without consulting distance, and fails the pan if the finger travels first.

    In FileSystem, useEntryHold replaces useEntryLongPress and reconciles all three claimants on an entry — the tap, multi-selection, and the context menu. Multi-selection still wins the hold when selectionMode="multiple", as before. Both hold paths stay wired (the pans see touch, the Pressable sees a mouse) and the first to fire locks the other out for that gesture, so a hold cannot run its action twice — which for a selection toggle meant undoing itself. A release that already produced a hold no longer counts as a tap, so the entry behind an open panel is not also selected.

    An entry stays a drag source while its own context menu is open, which is what makes the escape possible: the finger that opened the menu is still delivering to the view it started in, so that entry's own pan is what detects the shove and closes the panel.

    Holdable and HoldDraggable

    Two new components exposing the same timeline without requiring a <DragManager>:

    <Holdable> — hold only, no drag. Wraps children in the four-phase press timeline (pending → active → hold) and exposes the current state via a render-prop child:

    <Holdable onHold={() => select(id)}>
      {({ isPressed, isHeld }) => (
        <Chip pressed={isPressed} selected={isHeld} label={name} />
      )}
    </Holdable>

    isPressed flips at armDelay; onHold fires at holdDelay. A cancel or release ends the press quietly — onHoldEscape is a drag's crossing out of a fired hold, and a bare <Holdable> has nothing to drag, so it never reports one (the prop exists so a consumer can move between <Holdable> and <HoldDraggable> without rewiring). The hold defaults fire on every platform (unlike <Draggable>, which has no hold on web by default) — but a mouse press still does nothing: a held left button is a text selection and a held right button is the context menu; the hold-and-lift is a touch idiom.

    <HoldDraggable> — hold + drag in one. Identical to <Draggable trackPhase> but with the render-prop always enabled, so the child always gets a live phase without a separate state lift:

    <HoldDraggable
      data={{ "application/x-item": item.id }}
      onHold={() => openMenu(item)}
      onHoldEscape={closeMenu}
    >
      {({ isPressed, isHeld }) => (
        <Row row={item} pressed={isPressed} selected={isHeld} />
      )}
    </HoldDraggable>

    Web's drag transport defaults to holdDelay: null, so onHold on web needs behavior={{ holdDelay: 300 }} (or a platform-specific web: { holdDelay: 300 } block) to fire. Touch on web and all native platforms hold by default.

    HoldContextMenu — hold gesture rebuilt on Holdable

    HoldContextMenu no longer drives the squeeze animation from Pressable.onLongPress. On native for activateOn="hold", the trigger is now a <Holdable> (or a <HoldDraggable> when dragOptions is set), and the squeeze fills exactly the gap between armDelay and holdDelay from the resolved tuning.

    Two new props:

    • behavior?: DragBehavior — timing and slop overrides forwarded to the gesture widget; merged with holdDuration if given.
    • dragOptions?: HoldContextMenuDragOptions — upgrades the hold gesture to <HoldDraggable>. A move past escapeSlop after arming lifts a drag; the hold still opens the menu, and an escape closes it before the drag takes over. Works wherever the hold does: native, and touch on web. A desktop mouse keeps the right-click, whose dropdown has no gesture to escape from.

    Touch on the web: the gesture now survives the browser's own ideas

    Three fights with the browser's touch pipeline, each found by driving the real
    gesture with real input rather than synthetic events:

    • A hold that fired no longer "clicks" on release. The browser synthesizes
      mousedown/click at the release point after touchend — and with a hold
      menu open, the topmost element there is the menu's own scrim, so the phantom
      click dismissed the menu the instant the finger lifted. Both pointer
      transports now cancel the touchend of a press that reached its hold, which
      is the documented way to suppress the compat events.
    • The drag no longer dies one move after it lifts. Taking explicit pointer
      capture at the lift releases the touch's implicit capture on the child under
      the finger, and that lostpointercapture bubbles — the transport read its
      own capture handoff as "the system took the pointer" and cancelled the drag
      it had just started. A capture loss now only aborts the trip when it happens
      on the captured node itself.
    • Chromium's native drag is refused while the pan owns the press. A touch
      long-press on any draggable=true element starts a native HTML5 drag,
      cancelling the pointer stream under the pan. The HTML5 transport now
      preventDefaults that dragstart whenever the press timeline is mid-gesture
      — and once a hold has fired, touchmove is cancelled from the first move, so
      the escape's opening travel cannot be read as a scroll either.
  • 631606c: Separate FileSystem and FileIcon into their own file-system category.

    • Moves FileSystem out of display into a new top-level file-system category
    • Extracts FileTypeIcon, FileSystemFolderGlyph and their supporting utilities into a standalone FileIcon component at ./file-icon
    • Storybook titles updated to File System/FileSystem and File System/FileIcon
    • No API changes; existing ./file-system and new ./file-icon export paths are stable
  • e86dced: fix(FileSystem): the marquee works in the columns view, and no longer collapses the column it runs in

    Two bugs stopped the selection box from being usable in ColumnsView.

    It could not start. The web drag transport captured the pointer as soon as the
    press passed the drag slop, then asked begin() whether there was anything to
    drag. On empty space begin() returns false and the trip resets — but the capture
    had already happened, so the column's marquee listener never saw the pointer it
    was waiting for. Capture now happens after begin() confirms a source, which
    leaves the pointer free for a child listener when the press lands on nothing. The
    list and icons views run the same transport and gain the same ordering.

    It collapsed the trail. Each column past the first exists because a folder is
    selected in the column to its left, and a marquee replaces the selection with
    whatever it covers. Sweeping inside a sub-column therefore deselected the parent
    folder that opened it, and the column vanished from under the pointer mid-drag.
    Each column now injects the trail paths into the marquee's base, so the folders
    that opened it stay selected. Column 0 has no trail to protect and is unchanged.

    FileSystemColumn's onClearSelection is gone with this — an empty-space press
    now resolves through the marquee, which reports an empty covered set and clears
    the selection on its own. The component is internal, so the public API is
    unchanged.

  • 85d5ada: fix(FileSystem): list-view width init, external drop indicator, and store refactors

    • Initialize width state to null instead of 0 in FileSystemListView — distinguishes "not yet measured" from a genuine zero, so showDate defaults to visible and the date column no longer flickers on mount at wide breakpoints.
    • Replace the two conditional external-drop JSX branches with an ExternalDropIndicator component that encapsulates the folder-row vs. full-area fallback logic.
    • Use rowsRef.current.length directly in hitTest and remove the now-redundant rowCountRef.
    • Split react-native mixed import into a import type block + a value import block.
    • Extract resolveFolderName helper in file-system-context.tsx — eliminates four identical inline ternaries that computed the current folder display name.
    • Extract historyStep helper — goBack and goForward were duplicating the same nav/search/entries recompute patch; both now delegate to a single function.
    • Add result-caching to computeFileTypeOptions keyed on index identity — the walk-and-sort runs once per index change instead of once per store action.
    • Use cancelSearchDebounce() consistently in setSearchInput instead of a direct clearTimeout.

    fix(MultiStepMenu): reduce sidebar divider from border-r-2 to border-r

  • 56b1f2f: fix(FileSystem): correct search and subfolder scoping in computeVisiblePaths

    • Match search query against entry.name instead of the entry path — id-based paths don't embed the display name so the old path-substring test eliminated every result on any non-empty query.
    • Replace path.startsWith(currentPath) with a parentPath chain walk through the index — flat parentPath manifests assign each entry a single-segment id path, so string-prefix containment never held for nested folders.
    • Apply the same parentPath-aware ancestor walk in markVisible so highlighted entries correctly bubble up to the current folder in flat manifests.
  • ff3582a: fix(HoverMenu): hover opens a pressable trigger, and a controlled open renders the panel

    The hover pair was Pressable's onHoverIn/onHoverOut. react-native-web
    implements those with useHover({ contain: true }), which dispatches a bubbling
    react-gui:hover:lock event on enter — and an ancestor using the same hook reads
    a lock from a different target as its own hover-end. Every nested Pressable
    therefore cancelled its ancestors' hover:

    • A pressable trigger (a Button) fired the lock as the pointer reached it, so
      the wrapper's hover ended one tick after starting and handleHoverOut cleared
      the pending open timer. The menu only ever opened on press.
    • MenuItem is a Pressable too, so moving onto an item ended the panel's hover
      and scheduled a close while the pointer was still inside it.

    Both are now on onPointerEnter/onPointerLeave — plain DOM events with no lock
    protocol, which fire once for the element-plus-descendants region and ignore
    movement between children. That is exactly the wanted semantics, and a nested
    pressable is invisible to them. RNW forwards both props to the DOM node and they
    are part of RN's own ViewProps, so this stays type-safe and is inert on native,
    where canHover gates it anyway.

    Separately, a controlled open flipped from outside the menu left the panel
    invisible. The panel renders on open && rect, and the trigger was only measured
    on the paths the menu drives itself — the hover timer and toggle. A consumer
    setting open from a keyboard shortcut, a switch, or a route change never touched
    either, so rect stayed null. Measuring is now keyed on open becoming true,
    whatever set it, which is also correct in general: the trigger may have moved since
    the last measurement. measure bails on an identical rect so the extra pass costs
    no re-render.

  • e0b326e: MenuItem: the active icon-tile row fills with info rather than primary

    The iconBackgroundColor variant painted its active row bg-primary/75, with a
    font-semibold text-primary-foreground label. primary is the monochrome token
    consumers are meant to repaint with their own brand colour, so the active row
    came out near-black in light mode and near-white in dark — an inverted row rather
    than a selected one — and any consumer who retinted primary got their brand
    colour as the selection fill whether they meant to or not.

    It is bg-info now, at full opacity, with a text-info-foreground label: the
    same vivid blue that already reads as "this one is picked" elsewhere in the
    library, on both schemes, and not a token consumers are invited to repaint. The
    label drops to normal weight — the fill carries the state, so the extra weight
    was doing the same job twice, and it kept the active row a hair wider than its
    neighbours.

    text-info-foreground is white in both schemes, which is what a vivid blue fill
    wants. primary-foreground would have flipped to near-black in dark mode.

    MultiStepMenu's MenuRow picks this up, since it renders the variant. Nothing
    to change on your side unless you were relying on primary to tint the active
    row; that hook is gone on purpose.

  • 48e1a5c: fix(theme): native read the dark values in both schemes — tokens.css now declares each scheme through @variant

    Every surface on native rendered its dark value regardless of the active scheme. In the native Storybook the symptom was a page that disagreed with itself: white chrome, dark cards. Web was unaffected throughout.

    tokens.css expressed dark mode the way a plain Tailwind sheet does — light values in @theme, dark values in @media (prefers-color-scheme: dark) { :root:not(.light) } plus a bare .dark block. Both dark forms are correct CSS and both work on web. Neither is a shape uniwind recognises as a theme.

    uniwind builds vars.light and vars.dark from one shared global base that diverges solely via per-theme override buckets, and it fills those buckets only from declarations whose selector carries :where(.light, …) / :where(.dark, …). Compiling the sheet produced no buckets at all: the .dark block parsed as a utility class named dark, and the media-query dark values fell through to the global base as the last write. So both scheme maps existed, both were byte-identical, and both held dark values — which is also why toggling the scheme changed nothing.

    The per-scheme values now live in top-level @variant light / @variant dark blocks, which expand to exactly that :where() shape. @theme keeps the light values, since that is what registers each token with Tailwind and what lands on :root for the web base, and keeps the tokens that don't flip (--shadow-elevated-*, --spacing-button-*, --radius-button-*) — a value that lives only in @theme is now theme-independent by construction.

    Web behaviour is unchanged. @variant compiles to the same three rules the sheet previously spelled out by hand: a .dark class rule, a .light class rule, and an OS-preference rule that either class suppresses. A .light class is still an absolute override that opts out of OS dark.

    Two consequences worth knowing:

    • uniwind is now load-bearing for scheme switching on native, where before the sheet also carried a plain-CSS fallback. It was already required for className to do anything at all, so nothing that worked before stops working.
    • Both @variant blocks must declare the identical token set. uniwind logs a parity error per missing token rather than throwing, so drift is quiet. scripts/check-token-parity.mjs now holds @theme@variant light@variant dark ⇄ the native LIGHT_OKLCH/DARK_OKLCH tables to one another, and runs in CI.

    The native Storybook preview also drives the scheme through Uniwind.setTheme() instead of Appearance.setColorScheme(), seeded at module scope so the first paint is already correct, and synced from the darkMode arg in an effect rather than during render. setTheme() notifies every mounted className consumer; doing that mid-render was tearing down Storybook's in-flight render, which is where the cannot render when not prepared and canvasElement is unset rejections came from.

  • Updated dependencies [706dac3]

  • Updated dependencies [4eea56d]

  • Updated dependencies [9fd7f7d]

    • rn-motion-ui-icons@0.0.1