• v0.4.0 b0cb7a92e8

    v0.4.0 Stable

    pienter-release-bot released this 2026-08-28 13:45:01 +00:00 | 260 commits to main since this release

    Breaking

    • Sidebar follows the D13 layered split instead of a shared controller, so
      mountSidebar is no longer exported from @[secure]/ui/utils/controllers.
      utils/controllers/ is for code both tiers run; Sidebar's imperative
      orchestration is Astro-only and now lives beside the component, exported as
      @[secure]/ui/components/Sidebar.mount. Vue no longer wraps it at all —
      Sidebar.vue is reactive throughout, which is what D13 already required
      ("wrapping an imperative DOM controller from Vue fights Vue's data flow…
      the controller mutates the DOM, Vue patches it back, and race conditions
      ensue"). Both tiers now derive from shared primitives in
      @[secure]/ui/utils/navigation/sidebar: isHrefActive, deriveGroupKey,
      computeSubmenuPosition, isSubmenuActive, groupContainsActive.
      isHrefActive and deriveGroupKey moved there from the controller barrel.
    • Sidebar's items prop is now topItems, alongside a new bottomItems.
      The column has two nav regions — the top nav, which takes the leftover
      height and scrolls, and the bottom nav, pinned to the foot below a divider
      for settings, account and the like. Entries in either get identical wiring:
      active-href matching, keyboard navigation, and collapsed flyouts for their
      groups. The #footer slot is renamed #bottom and still takes arbitrary
      content beside bottomItems. Migrate :items="nav" to :top-items="nav".
    • The private recursive renderer is SidebarMenuItem (was
      SidebarItemRender), matching the data-sidebar-menu-item hook it emits.
      Internal to the component — no export changed — but the file names did.
    • The nav list classes are .pui-sidebar__top-nav and
      .pui-sidebar__bottom-nav (was .pui-sidebar__menu), and the foot wrapper
      is .pui-sidebar__bottom (was .pui-sidebar__footer). Consumers styling
      those internals will need to follow.
    • Sidebar no longer uses a roving tabindex. Every menu entry is its own tab
      stop, as an ordinary <nav> full of links should be. Roving left the whole
      sidebar with a single tab stop, so a rail of four destinations had three
      that Tab could never reach. This also matches CONVENTIONS.md, which reserves
      roving for "single-focus-per-group controls … where one item represents the
      entire group's focus target" — a navigation landmark is not that. Arrow keys,
      Home and End still work, now as an accelerator on top of the natural order
      rather than a replacement for it. Consumers relying on exactly one sidebar tab
      stop will see more; nothing else changes.

    Changed

    • --space-sidebar-width-collapsed is 4.75rem (was 4rem). At 4rem the new
      rail captions had about 2rem of usable width once the panel's padding was
      taken out, and real labels ellipsised to two characters.
    • The Sidebar focus ring colour follows the entry's tone via
      --pui-sidebar-ring, matching how --btn-ring works for Button: neutral by
      default, brand on the entry wearing the brand wash, so the ring no longer
      fights the fill underneath it.
    • The collapsed rail marks which icon the open flyout belongs to. Previously
      only hover did, so a flyout opened from the keyboard left nothing pointing
      back at its icon once focus moved into the panel.
    • The collapsed submenu flyout now sits on --layer-below, so it genuinely
      passes under the rail rather than across it — the panel's own background
      hides where the slide starts. The open-flyout rule pairs the rung with
      isolation: isolate, because "behind the rail" only holds while the sidebar
      is the stacking context containing the flyout; without it the panel would go
      behind the page in any shell that doesn't happen to give the root a
      context.
    • The collapsed submenu flyout slides in from behind the rail when it opens,
      and disappears instantly when it closes. The transition is declared only on
      the open state — a transition belongs to the state being moved to — so
      there is nothing to undeclare for the closing direction. An exit animation on
      a dismissal only delays the content behind it, and the panel covers page
      content. visibility stays untransitioned so the entries are focusable the
      instant the group takes focus; under prefers-reduced-motion the panel
      simply appears.
    • The collapsed Sidebar rail stacks a caption under each icon instead of
      going icon-only, so it is navigable without hovering. The rail sets its own
      inline padding rather than inheriting the expanded panel's, and captions wrap
      to a second line instead of truncating — an ellipsis hides the one thing a
      caption is for.
    • A collapsed rail group whose subtree contains the current page carries the
      brand wash. On the rail its children are hidden, so the group is the only
      indicator of location, and a tint alone lost to the hover fill — a hovered
      entry read as more current than the current one. The wash is reverted in
      drawer mode, where the sidebar is a full expanded list: there the group sits
      directly above its own current child and two identical fills merged into a
      single block. Brand ink alone carries "this section holds the current page"
      there, and the wash is left to the entry that actually is the current page.
    • The Sidebar focus ring uses --outline-width / --outline-offset instead
      of hard-coded 3px / 2px. It is the standard outset ring, so it reads as a
      ring around the entry rather than a line cutting into it and stays legible
      over the selected entry's wash; the collapsed rail reserves inline padding so
      the column edge never clips it.
    • A Sidebar link's selected state uses --bg-clr-brand-soft rather than
      --bg-clr-accent, which is what hover already uses — the current page was
      indistinguishable from whatever the pointer was over.

    Fixed

    • Sidebar's focus ring is no longer clipped in the top nav. That list is a
      scroll container — overflow-y: auto forces overflow-x: auto — and a
      scroll container clips at its padding box, so an entry filling it
      edge-to-edge had the ring sliced off both sides. The list now reserves the
      ring's width as padding-inline with a matching negative margin-inline,
      so nothing moves.
    • Consumers must import @[secure]/ui/styles before any component. Each
      component stylesheet is a bare @layer components { … }, and layer order is
      fixed by first encounter — so a component loading first registers
      components ahead of base, and every base element rule starts outranking
      component rules. In the Vue playground that made base's a { color: … } beat
      .pui-sidebar__link, turning sidebar links brand-coloured. The playground
      entry point now imports the stylesheet first; the library contract is
      unchanged (main.css declares the order once).
    • The mobile Sidebar drawer no longer paints under the consumer's app header.
      The rule that lifts the sidebar while a collapsed flyout is open applied at
      every width, and a z-index other than auto makes a grid or flex item a
      stacking context even while position: static — which is what a sidebar in a
      layout shell normally is. The drawer's own z-index: 50 was therefore sealed
      inside that context and lost to any header above 30. The lift is now scoped
      above the drawer breakpoint, where the flyout actually exists, and the drawer
      lifts the root itself while open.
    • Sidebar's focus handler no longer rewrites every entry's tabindex. The
      roving pattern was removed from the initial render but survived in the
      focusin handler, which demoted every other entry to -1 the moment one was
      focused — so the sidebar looked fully tabbable until you actually tabbed into
      it, and then collapsed to a single stop.
    • A collapsed submenu flyout's entries are reachable by Tab. visibility was in
      the flyout's transition list, so it flipped partway through the fade and the
      entries were still unfocusable when Tab arrived — landing on a group jumped
      straight past its flyout to the next rail item. visibility now steps
      immediately on open and is delayed only on close, so the fade-out still plays.
    • The collapsed rail no longer stays visible below drawerBreakpoint. The
      drawer's width: 0 sat on a bare .pui-sidebar inside a media query, which
      grants no specificity, so .pui-sidebar[data-state='collapsed'] outranked it
      and left an empty bordered strip down the page on mobile.
    • The collapsed submenu flyout aligns flush with the top of the sidebar column
      instead of the menu region. The menu's box starts inside the panel's padding,
      which left a visible seam above the flyout. With a #brand slot filled the
      flyout starts below the brand, so it still never covers the wordmark.
    • Icon.astro forwards a class prop instead of silently dropping it. It
      hardcoded class="pui-icon" and spread the rest of the props after, so an
      incoming class was discarded — Vue applied it, Astro did not. One casualty
      was Sidebar's pui-sidebar__group-caret hook, which meant the caret never
      rotated on expand in Astro.
    • A Sidebar brand slot no longer spills across the page when collapsed. The
      rule that was meant to hide everything without data-keep-when-collapsed was
      an empty placeholder, so a wordmark kept its full width in a rail narrower
      than most product names.
    • The collapsed submenu flyout no longer renders behind page content. It is
      position: fixed, which escapes overflow but not a stacking context, and
      consumers routinely make the sidebar position: sticky for scroll — which
      creates one. The root now raises its own z-index while a flyout is open.
    • A closed flyout is positioned as soon as it enters panel mode. A fixed
      element with no coordinates resolves to its static position inside the rail,
      so anything that made it briefly visible flashed the panel over the icons.

    Added

    • Sidebar takes a bottomItems prop — the bottom nav. Same
      SidebarItem[] shape as topItems, rendered at the foot of the column
      below a divider, for settings / account / help. See the Breaking entry for
      the accompanying itemstopItems rename.
    • Sidebar takes a collapsedSubmenu prop ('hidden' | 'panel', default
      'hidden'). With 'panel', a collapsed rail's groups are no longer a dead
      end: hover, focus, or click opens a group's children in a full-height flyout
      beside the rail. Escape closes it and returns focus to the group toggle;
      clicking the toggle pins the flyout so it survives pointer-out; only one
      flyout is open at a time. The default keeps the previous behavior, so
      existing consumers are unaffected.
    • mountSidebar gains openSubmenu(key), closeSubmenu(), and
      openSubmenuKey(), plus a collapsedSubmenu config field. They are no-ops
      unless the policy is active — which needs the sidebar collapsed and the
      viewport above drawerBreakpoint, since below it there is no rail.
    • A stacking ladder: --layer-below, --layer-0--layer-5, --layer-top.
      Every positioned layer in the library names a rung instead of inventing a
      number, so the whole order is legible in one place and two components cannot
      accidentally tie. Toast, Table's loading pane, NumberField's focused
      step button and every Sidebar layer moved onto it; the numbers they
      resolve to are unchanged.
    • --shadow-lg, for a surface that covers page content rather than sitting on
      it. --shadow-md is tuned for a card in the flow and reads flat under
      something as large as a sidebar flyout.
    Downloads