@iloveagents/foundry-web-ui 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,28 +17,78 @@ const ACTIVE_NAV_ROW_CLASS = "border border-transparent bg-sidebar-selected shad
17
17
  const ACTIVE_NAV_TEXT_CLASS = "font-semibold text-sidebar-foreground";
18
18
  const ACTIVE_NAV_ICON_CLASS = "text-primary";
19
19
  const ACTIVE_NAV_BADGE_CLASS = "border border-sidebar-border bg-background/60 text-sidebar-foreground";
20
- const NESTED_NAV_INDENTS = ["pl-5", "pl-7", "pl-9", "pl-11", "pl-13"];
21
- // Shared icon-column geometry. Top-level item rows render their
22
- // leading icon at ``size-4`` (16px) followed by ``gap-2`` (8px)
23
- // before the label. The group-header chevron uses the SAME column
24
- // width + gap so the group label and item labels share an exact
25
- // x-coordinate without this invariant headers visually drift right
26
- // (previously the chevron defaulted to ``size-6`` = 24px) and the
27
- // sidebar looks ragged. Verified empirically against the live DOM:
28
- // item icon x-min header chevron x-min, item label x header
29
- // label x (within 1px allowing for the item's ``border-transparent``
30
- // inset).
20
+ // Indent ladder for the nav tree, indexed by depth (0 = top-level item).
21
+ //
22
+ // ONE array of PAIRS rather than two parallel arrays, because the two
23
+ // numbers are not independent: ``body`` is always ``column`` plus the 20px
24
+ // disclosure cluster (16px cell + 4px gap). Split into separate lists they
25
+ // drift apart, and the tree stops looking like a tree.
26
+ //
27
+ // ``column`` is a LEFT OFFSET, not padding, because the twisty is absolutely
28
+ // positioned: it floats over the row body's own left padding instead of
29
+ // occupying a flex cell. That is what keeps the entire row — indent strip
30
+ // and disclosure cell included — a live click target for navigation, while
31
+ // the branch chevron still catches its own clicks on top. Laying the twisty
32
+ // out in flow instead makes every pixel left of the icon dead.
33
+ //
34
+ // Depth 0 sits at 12px so the twisty shares a column with the group-header
35
+ // chevron; every further level adds 8px, which keeps deep trees readable
36
+ // inside a ~260px sidebar.
37
+ const NAV_DEPTH_OFFSETS = [
38
+ { column: "left-3", body: "pl-8" },
39
+ { column: "left-5", body: "pl-10" },
40
+ { column: "left-7", body: "pl-12" },
41
+ { column: "left-9", body: "pl-14" },
42
+ { column: "left-11", body: "pl-16" },
43
+ { column: "left-13", body: "pl-18" },
44
+ ];
45
+ // Fixed-width cell every nav row leads with, holding either a chevron
46
+ // (branch) or a dot (leaf). Same width as ``SIDEBAR_ITEM_ICON_CLASS`` so
47
+ // the disclosure column and the group-header chevron share one x.
48
+ const NAV_TWISTY_CLASS = "absolute inset-y-0 flex w-4 items-center justify-center";
49
+ // The chevron is a real control, so it needs a real touch target: it draws
50
+ // at ``size-3.5`` but CLICKS in 24px (WCAG 2.2 target-size minimum). It can
51
+ // simply overflow the 16px cell — the cell is absolutely positioned, so
52
+ // nothing downstream moves. The leaf dot needs no target at all; it is
53
+ // decorative and passes its clicks through to the row.
54
+ const NAV_TWISTY_HIT_CLASS = "size-6 shrink-0";
55
+ // Hover-reveal clearance for the right-hand actions menu. That control is a
56
+ // 28px button inside ``right-1`` + ``px-1`` (4 + 4 + 28 + 4 = 40px), so 44px
57
+ // keeps the label from butting straight into it. Applied ONLY to rows that
58
+ // actually have an actions menu — on a row without one the reserved padding
59
+ // buys nothing and just re-truncates the label on every hover.
60
+ const NAV_ROW_ACTIONS_CLEARANCE = "group-hover:pr-11 group-focus-within:pr-11";
61
+ // Shared icon-column geometry. The group-header chevron and the
62
+ // per-row disclosure cell (``NavTwisty``) are the SAME width, and the
63
+ // header wrapper below pads to the same x as
64
+ // ``NAV_DEPTH_OFFSETS[0].column`` — the depth-0 rung the rows use — so
65
+ // every chevron in the sidebar (section headers and tree rows
66
+ // alike) stacks on one vertical line. That single line is what makes
67
+ // the tree scan as a tree; when the columns drift the sidebar looks
68
+ // ragged (an earlier regression had the header chevron defaulting to
69
+ // ``size-6`` = 24px). Item labels now sit one cell further right than
70
+ // group labels, because item rows carry a twisty AND an icon while
71
+ // group headers carry only the chevron.
31
72
  const SIDEBAR_ITEM_ICON_CLASS = "size-4 shrink-0";
32
73
  const SIDEBAR_ITEM_GAP_CLASS = "gap-2";
33
- // Outer padding for the group-header wrapper. Horizontal matches
34
- // ``px-3`` on item buttons so the chevron column lines up with the
35
- // item icon column. Vertical is asymmetric: ``pt-3`` (12px) above
36
- // gives breathing room between sections, ``pb-1`` (4px) below bonds
37
- // the header to its first child the ChatGPT / Linear / Notion
38
- // "header sits on its children" pattern. Symmetric ``py-2`` made
39
- // the header float midway between sections instead of belonging
40
- // to the group below it.
41
- const SIDEBAR_GROUP_HEADER_PADDING = "px-3 pt-4 pb-0";
74
+ // Outer padding for the group-header wrapper. The group header lays its
75
+ // chevron out in normal flow, so it needs the depth-0 column expressed as
76
+ // a PADDING where rows express it as an offset — the same number, two
77
+ // spellings, which is exactly how columns silently desync when the indent
78
+ // ladder is retuned. The group-header alignment test in
79
+ // ``sidebar.test.tsx`` parses both numbers back out and fails if they
80
+ // ever diverge.
81
+ // Vertical is asymmetric: ``pt-4`` above gives breathing room between
82
+ // sections, ``pb-0`` below bonds the header to its first child — the
83
+ // ChatGPT / Linear / Notion "header sits on its children" pattern.
84
+ // Symmetric ``py-2`` made the header float midway between sections
85
+ // instead of belonging to the group below it.
86
+ const SIDEBAR_GROUP_HEADER_PADDING =
87
+ // ``border border-transparent`` for the same reason every item row
88
+ // carries one: the border is part of the box model that decides where
89
+ // the padding box starts. Without it the header chevron lands 1px left
90
+ // of every row's disclosure cell and the column is visibly ragged.
91
+ "border border-transparent pl-3 pr-3 pt-4 pb-0";
42
92
  // Typography for the group-header LABEL text (CHATS / WORKSPACES /
43
93
  // ADMIN / …). Stays at ``text-xs`` (12px) — same size as before —
44
94
  // but bumps weight from ``font-medium`` to ``font-semibold``. The
@@ -49,8 +99,8 @@ const SIDEBAR_GROUP_HEADER_PADDING = "px-3 pt-4 pb-0";
49
99
  // Bumping size AND weight together stacked too much visual mass
50
100
  // against the already-wide uppercase tracking — felt shouty.
51
101
  const SIDEBAR_GROUP_HEADER_LABEL_CLASS = "text-xs font-semibold uppercase tracking-wider";
52
- function getNestedNavIndent(depth) {
53
- return NESTED_NAV_INDENTS[depth] ?? NESTED_NAV_INDENTS[NESTED_NAV_INDENTS.length - 1];
102
+ function getNavDepthOffsets(depth) {
103
+ return NAV_DEPTH_OFFSETS[depth] ?? NAV_DEPTH_OFFSETS[NAV_DEPTH_OFFSETS.length - 1];
54
104
  }
55
105
  function scrollNavRowIntoView(nav, row) {
56
106
  const navRect = nav.getBoundingClientRect();
@@ -349,6 +399,40 @@ function ContainerDropZone({ dnd }) {
349
399
  : "bg-primary/10 border border-dashed border-primary/30")
350
400
  : "h-1"), children: isDragOver && (_jsxs(_Fragment, { children: [isFileDragOver && _jsx(Upload, { className: "size-3 text-primary" }), _jsx("span", { className: "text-[10px] text-primary/70", children: getContainerDropZoneLabel(isFileDragOver) })] })) }));
351
401
  }
402
+ /**
403
+ * Leading disclosure cell on every nav row — the Confluence page-tree
404
+ * affordance.
405
+ *
406
+ * A BRANCH (item with children) gets a chevron that is always visible and
407
+ * rotates on expand; a LEAF gets a small dot. Both occupy the identical
408
+ * cell, so the whole tree shares one disclosure column and "does this row
409
+ * go deeper?" is answerable without hovering. That was the flaw in the
410
+ * previous design: the chevron lived in the hover-only right-hand controls,
411
+ * so a sidebar at rest carried no branch/leaf information at all.
412
+ *
413
+ * ABSOLUTELY POSITIONED over the row's own left padding, for two reasons:
414
+ *
415
+ * - The row stays clickable edge to edge. In flow the cell would be a dead
416
+ * strip — a <button> may not nest, so the twisty cannot live inside the
417
+ * navigate button, and everything left of the icon would stop navigating.
418
+ * - The chevron can take a 24px hit area out of a 16px cell without
419
+ * pushing the icon column right.
420
+ *
421
+ * The leaf dot is ``pointer-events-none`` so its clicks fall through to the
422
+ * row underneath; the branch chevron keeps its own, since positioned
423
+ * elements paint above the static button and win the hit test.
424
+ */
425
+ function NavTwisty({ hasChildren, isExpanded, onToggle, label, isActive, depth, }) {
426
+ const cell = cn(NAV_TWISTY_CLASS, getNavDepthOffsets(depth).column);
427
+ if (!hasChildren) {
428
+ return (_jsx("span", { "data-nav-twisty-cell": true, "aria-hidden": "true", className: cn(cell, "pointer-events-none"), children: _jsx("span", { className: cn("size-1 rounded-full", isActive ? "bg-primary/70" : "bg-sidebar-foreground/30") }) }));
429
+ }
430
+ return (_jsx("span", { "data-nav-twisty-cell": true, className: cell, children: _jsx("button", { type: "button", "data-nav-twisty": true, "aria-expanded": isExpanded, "aria-label": isExpanded ? `Collapse ${label}` : `Expand ${label}`, onClick: (e) => {
431
+ e.preventDefault();
432
+ e.stopPropagation();
433
+ onToggle?.();
434
+ }, className: cn("flex items-center justify-center rounded", NAV_TWISTY_HIT_CLASS, isActive ? "text-primary" : "text-sidebar-foreground/50 hover:text-sidebar-foreground"), children: _jsx(ChevronRight, { className: cn("size-3.5 transition-transform duration-150", isExpanded && "rotate-90") }) }) }));
435
+ }
352
436
  function NavItemActions({ actions }) {
353
437
  return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "size-7 rounded-md text-muted-foreground hover:bg-muted/80 hover:text-foreground", onClick: (e) => {
354
438
  e.preventDefault();
@@ -358,17 +442,23 @@ function NavItemActions({ actions }) {
358
442
  void action.handler();
359
443
  }, className: cn("gap-2", action.destructive && "text-destructive focus:text-destructive"), children: [_jsx(action.icon, { className: cn("size-3.5", action.destructive ? "text-destructive" : "text-muted-foreground") }), _jsx("span", { children: action.label })] })] }, action.label))) })] }));
360
444
  }
361
- function NavRowControls({ item, hasChildren, isExpanded, onToggleExpand, }) {
445
+ /**
446
+ * Hover-revealed controls pinned to the right edge of a nav row.
447
+ *
448
+ * Expand/collapse deliberately does NOT live here — it's the leading
449
+ * ``NavTwisty`` cell instead (see that component). This slot is for the
450
+ * item's own actions menu only.
451
+ */
452
+ function NavRowControls({ item }) {
362
453
  const isDisabled = item.disabled === true;
363
454
  const hasActions = !isDisabled && item.actions && item.actions.length > 0;
364
- return (_jsxs("div", { className: "absolute inset-y-0 right-1 flex items-center gap-1 px-1 opacity-0 translate-x-1 pointer-events-none transition-all group-hover:opacity-100 group-hover:translate-x-0 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:translate-x-0 group-focus-within:pointer-events-auto", children: [hasActions && _jsx(NavItemActions, { actions: item.actions }), hasChildren && (_jsx("button", { type: "button", onClick: (e) => {
365
- e.preventDefault();
366
- e.stopPropagation();
367
- onToggleExpand?.();
368
- }, className: "flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground", "aria-label": isExpanded ? "Collapse" : "Expand", children: _jsx(ChevronRight, { className: cn("size-3.5 transition-transform", isExpanded && "rotate-90") }) }))] }));
455
+ if (!hasActions)
456
+ return null;
457
+ return (_jsx("div", { className: "absolute inset-y-0 right-1 flex items-center gap-1 px-1 opacity-0 translate-x-1 pointer-events-none transition-all group-hover:opacity-100 group-hover:translate-x-0 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:translate-x-0 group-focus-within:pointer-events-auto", children: _jsx(NavItemActions, { actions: item.actions }) }));
369
458
  }
370
459
  /** Leaf nav item with optional drag-and-drop support via NavItem.dnd. */
371
- function NavLeafItem({ item, isActive, onClick, depthPl = getNestedNavIndent(0), }) {
460
+ function NavLeafItem({ item, isActive, onClick, depth = 0, }) {
461
+ const bodyPl = getNavDepthOffsets(depth).body;
372
462
  const isDisabled = item.disabled === true;
373
463
  const hasActions = Boolean(!isDisabled && item.actions && item.actions.length > 0);
374
464
  const { draggableProps, dropTargetProps, isDragOver, isFileDragOver } = useNavItemDnd(item.dnd);
@@ -376,7 +466,7 @@ function NavLeafItem({ item, isActive, onClick, depthPl = getNestedNavIndent(0),
376
466
  const activeButtonRef = useActiveNavRowScroll(isActive && !hasActions);
377
467
  // Items with actions use the group layout (button + action controls)
378
468
  if (hasActions) {
379
- return (_jsxs("div", { ref: activeRowRef, "data-active-nav-row": isActive ? "true" : undefined, ...(isDisabled ? {} : dropTargetProps), ...(isDisabled ? {} : draggableProps), className: cn("group relative flex items-center w-full rounded-xl border border-transparent transition-colors", isDisabled
469
+ return (_jsxs("div", { ref: activeRowRef, "data-active-nav-row": isActive ? "true" : undefined, "data-nav-depth": depth, ...(isDisabled ? {} : dropTargetProps), ...(isDisabled ? {} : draggableProps), className: cn("group relative flex items-center w-full rounded-xl border border-transparent transition-colors", isDisabled
380
470
  ? "opacity-40"
381
471
  : isActive
382
472
  ? ACTIVE_NAV_ROW_CLASS
@@ -385,20 +475,20 @@ function NavLeafItem({ item, isActive, onClick, depthPl = getNestedNavIndent(0),
385
475
  !isDisabled &&
386
476
  "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
387
477
  !isDisabled &&
388
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsxs("button", { type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, className: cn("flex items-center gap-2 flex-1 min-w-0 py-1.5 text-sm text-left", depthPl, "pr-3 transition-[padding] duration-150 group-hover:pr-10 group-focus-within:pr-10", isDisabled
478
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: false, label: item.label, isActive: isActive && !isDisabled, depth: depth }), _jsxs("button", { type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, className: cn("flex items-center gap-2 flex-1 min-w-0 py-1.5 text-sm text-left", bodyPl, "pr-3 transition-[padding] duration-150", NAV_ROW_ACTIONS_CLEARANCE, isDisabled
389
479
  ? "text-sidebar-foreground/45 cursor-default"
390
480
  : isActive
391
481
  ? ACTIVE_NAV_TEXT_CLASS
392
- : "text-sidebar-foreground/80"), children: [_jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item, hasChildren: false, isExpanded: false })] }));
482
+ : "text-sidebar-foreground/80"), children: [_jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }));
393
483
  }
394
- return (_jsxs("button", { ref: activeButtonRef, "data-active-nav-row": isActive ? "true" : undefined, type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, ...(isDisabled ? {} : draggableProps), ...(isDisabled ? {} : dropTargetProps), className: cn("flex items-center gap-2 w-full", "rounded-lg border border-transparent pr-3 py-1.5 text-sm text-left", depthPl, isDisabled
484
+ return (_jsxs("button", { ref: activeButtonRef, "data-active-nav-row": isActive ? "true" : undefined, "data-nav-depth": depth, type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, ...(isDisabled ? {} : draggableProps), ...(isDisabled ? {} : dropTargetProps), className: cn("relative flex items-center gap-2 w-full", "rounded-lg border border-transparent pr-3 py-1.5 text-sm text-left", bodyPl, isDisabled
395
485
  ? "opacity-40 cursor-default"
396
486
  : "text-sidebar-foreground/80 transition-colors hover:bg-sidebar-accent/70", !isDisabled && isActive && ACTIVE_NAV_ROW_CLASS, isDragOver &&
397
487
  !isFileDragOver &&
398
488
  !isDisabled &&
399
489
  "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
400
490
  !isDisabled &&
401
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: cn("truncate", isActive && !isDisabled && ACTIVE_NAV_TEXT_CLASS), children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "ml-auto size-3.5 shrink-0 text-primary pointer-events-none" }))] }));
491
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: false, label: item.label, isActive: isActive && !isDisabled, depth: depth }), _jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: cn("truncate", isActive && !isDisabled && ACTIVE_NAV_TEXT_CLASS), children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "ml-auto size-3.5 shrink-0 text-primary pointer-events-none" }))] }));
402
492
  }
403
493
  function SubNavItems({ items, parentPath, depth = 1, }) {
404
494
  const navigate = useNavigate();
@@ -407,7 +497,6 @@ function SubNavItems({ items, parentPath, depth = 1, }) {
407
497
  const navContext = useAppStore((s) => s.navContext);
408
498
  const isOnParent = currentPath === parentPath;
409
499
  const [activeLabel, setActiveLabel] = useState(items[0]?.label ?? "");
410
- const childDepthPl = getNestedNavIndent(depth);
411
500
  // Children with unique paths (e.g., SPACES documents) use URL matching;
412
501
  // children sharing the parent path (e.g., Documents sub-filters) use label matching.
413
502
  const allShareParent = items.every((c) => c.to === parentPath);
@@ -423,7 +512,7 @@ function SubNavItems({ items, parentPath, depth = 1, }) {
423
512
  const isActive = allShareParent
424
513
  ? isOnParent && activeLabel === child.label
425
514
  : child.to !== parentPath && currentPath === child.to;
426
- return (_jsx(NavLeafItem, { item: child, isActive: isActive, depthPl: childDepthPl, onClick: () => {
515
+ return (_jsx(NavLeafItem, { item: child, isActive: isActive, depth: depth, onClick: () => {
427
516
  setActiveLabel(child.label);
428
517
  setNavContext({
429
518
  ...navContext,
@@ -456,10 +545,11 @@ function NestedFolderItem({ item, depth }) {
456
545
  const [manualOverride, setManualOverride] = useState(null);
457
546
  const isExpanded = manualOverride ?? isInsideSubtree;
458
547
  const { draggableProps, dropTargetProps, isDragOver, isFileDragOver } = useNavItemDnd(item.dnd);
459
- const depthPl = getNestedNavIndent(depth);
548
+ const bodyPl = getNavDepthOffsets(depth).body;
460
549
  const isDisabled = item.disabled === true;
550
+ const hasActions = Boolean(!isDisabled && item.actions && item.actions.length > 0);
461
551
  const activeRowRef = useActiveNavRowScroll(isActive);
462
- return (_jsxs("div", { ...(isDisabled ? {} : dropTargetProps), children: [_jsxs("div", { ref: activeRowRef, "data-active-nav-row": isActive ? "true" : undefined, ...(isDisabled ? {} : draggableProps), className: cn("group relative flex items-center w-full", "rounded-xl border border-transparent transition-colors", isDisabled
552
+ return (_jsxs("div", { ...(isDisabled ? {} : dropTargetProps), children: [_jsxs("div", { ref: activeRowRef, "data-active-nav-row": isActive ? "true" : undefined, "data-nav-depth": depth, ...(isDisabled ? {} : draggableProps), className: cn("group relative flex items-center w-full", "rounded-xl border border-transparent transition-colors", isDisabled
463
553
  ? "opacity-40"
464
554
  : isActive
465
555
  ? ACTIVE_NAV_ROW_CLASS
@@ -468,7 +558,7 @@ function NestedFolderItem({ item, depth }) {
468
558
  !isDisabled &&
469
559
  "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
470
560
  !isDisabled &&
471
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsxs("button", { type: "button", disabled: isDisabled, onClick: () => {
561
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: true, isExpanded: isExpanded, onToggle: () => setManualOverride(!isExpanded), label: item.label, isActive: (isActive || isAncestorActive) && !isDisabled, depth: depth }), _jsxs("button", { type: "button", disabled: isDisabled, onClick: () => {
472
562
  if (isDisabled)
473
563
  return;
474
564
  setManualOverride(true);
@@ -482,7 +572,7 @@ function NestedFolderItem({ item, depth }) {
482
572
  .join("\n") || null,
483
573
  });
484
574
  navigate(item.to);
485
- }, className: cn("flex items-center gap-2 flex-1 min-w-0", depthPl, "pr-3 py-1.5 text-sm text-left transition-[padding] duration-150 group-hover:pr-16 group-focus-within:pr-16", isDisabled
575
+ }, className: cn("flex items-center gap-2 flex-1 min-w-0", bodyPl, "pr-3 py-1.5 text-sm text-left transition-[padding] duration-150", hasActions && NAV_ROW_ACTIONS_CLEARANCE, isDisabled
486
576
  ? "text-sidebar-foreground/45 cursor-default"
487
577
  : isActive
488
578
  ? ACTIVE_NAV_TEXT_CLASS
@@ -492,13 +582,13 @@ function NestedFolderItem({ item, depth }) {
492
582
  ? ACTIVE_NAV_ICON_CLASS
493
583
  : isAncestorActive
494
584
  ? "text-sidebar-foreground/80"
495
- : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item, hasChildren: true, isExpanded: isExpanded, onToggleExpand: () => setManualOverride(!isExpanded) })] }), isExpanded && item.children && (_jsxs("div", { children: [item.children.map((child) => {
585
+ : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }), isExpanded && item.children && (_jsxs("div", { children: [item.children.map((child) => {
496
586
  const hasNestedChildren = child.children && child.children.length > 0;
497
587
  if (hasNestedChildren) {
498
588
  return (_jsx(NestedFolderItem, { item: child, depth: depth + 1 }, child.to + child.label));
499
589
  }
500
590
  const isChildActive = currentPath === child.to;
501
- return (_jsx(NavLeafItem, { item: child, isActive: isChildActive, depthPl: getNestedNavIndent(depth + 1), onClick: () => {
591
+ return (_jsx(NavLeafItem, { item: child, isActive: isChildActive, depth: depth + 1, onClick: () => {
502
592
  setNavContext({
503
593
  ...navContext,
504
594
  label: child.label,
@@ -530,7 +620,7 @@ function CollapsibleNavItem({ item }) {
530
620
  const activeRowRef = useActiveNavRowScroll(isActive && rendersRow);
531
621
  const activeLinkRef = useActiveNavRowScroll(isActive && !rendersRow);
532
622
  if (rendersRow) {
533
- const inner = (selected, ancestor) => (_jsxs("div", { ref: activeRowRef, "data-active-nav-row": selected ? "true" : undefined, className: cn("group relative flex items-center w-full rounded-xl border border-transparent transition-colors", isDisabled
623
+ const inner = (selected, ancestor) => (_jsxs("div", { ref: activeRowRef, "data-active-nav-row": selected ? "true" : undefined, "data-nav-depth": 0, className: cn("group relative flex items-center w-full rounded-xl border border-transparent transition-colors", isDisabled
534
624
  ? "opacity-40"
535
625
  : selected
536
626
  ? ACTIVE_NAV_ROW_CLASS
@@ -539,7 +629,7 @@ function CollapsibleNavItem({ item }) {
539
629
  !isDisabled &&
540
630
  "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
541
631
  !isDisabled &&
542
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsxs("button", { type: "button", onClick: () => {
632
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: hasChildren, isExpanded: isExpanded, onToggle: hasChildren ? () => setManualOverride(!isExpanded) : undefined, label: item.label, isActive: (selected || ancestor) && !isDisabled, depth: 0 }), _jsxs("button", { type: "button", onClick: () => {
543
633
  if (isDisabled)
544
634
  return;
545
635
  setNavContext({
@@ -554,7 +644,7 @@ function CollapsibleNavItem({ item }) {
554
644
  if (hasChildren)
555
645
  setManualOverride(true);
556
646
  navigate(item.to);
557
- }, className: cn("flex items-center gap-2 flex-1 min-w-0 px-3 py-2 text-sm text-left", "pr-3 transition-[padding] duration-150 group-hover:pr-16 group-focus-within:pr-16", isDisabled
647
+ }, className: cn("flex items-center gap-2 flex-1 min-w-0 py-2 text-sm text-left", getNavDepthOffsets(0).body, "pr-3 transition-[padding] duration-150", hasActions && NAV_ROW_ACTIONS_CLEARANCE, isDisabled
558
648
  ? "text-sidebar-foreground/45 cursor-default"
559
649
  : selected
560
650
  ? ACTIVE_NAV_TEXT_CLASS
@@ -564,15 +654,23 @@ function CollapsibleNavItem({ item }) {
564
654
  ? ACTIVE_NAV_ICON_CLASS
565
655
  : ancestor
566
656
  ? "text-sidebar-foreground/80"
567
- : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", selected ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item, hasChildren: Boolean(hasChildren), isExpanded: Boolean(isExpanded), onToggleExpand: hasChildren ? () => setManualOverride(!isExpanded) : undefined })] }));
657
+ : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", selected ? ACTIVE_NAV_BADGE_CLASS : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }));
568
658
  return (_jsxs("div", { ...(isDisabled ? {} : dropTargetProps), ...(isDisabled ? {} : draggableProps), children: [inner(isActive, isAncestorActive), hasChildren && isExpanded && (_jsxs(_Fragment, { children: [_jsx(SubNavItems, { items: item.children, parentPath: item.to }), item.dnd?.canDrop() && _jsx(ContainerDropZone, { dnd: item.dnd })] }))] }));
569
659
  }
570
660
  if (isDisabled) {
571
- return (_jsxs("div", { className: cn("flex items-center gap-2 w-full opacity-40", "rounded-lg px-3 py-2 text-left text-sm text-sidebar-foreground/45"), children: [_jsx(item.icon, { className: "size-4 shrink-0 text-sidebar-foreground/45" }), _jsx("span", { className: "truncate", children: item.label })] }));
661
+ return (_jsxs("div", { "data-nav-depth": 0, className: cn("relative flex items-center gap-2 w-full opacity-40", getNavDepthOffsets(0).body,
662
+ // Transparent border for box-model parity — see the NavLink variant.
663
+ "rounded-lg border border-transparent pr-3 py-2 text-left text-sm text-sidebar-foreground/45"), children: [_jsx(NavTwisty, { hasChildren: false, label: item.label, depth: 0 }), _jsx(item.icon, { className: "size-4 shrink-0 text-sidebar-foreground/45" }), _jsx("span", { className: "truncate", children: item.label })] }));
572
664
  }
573
- return (_jsx(NavLink, { ref: activeLinkRef, "data-active-nav-row": currentPath === item.to ? "true" : undefined, to: item.to, ...draggableProps, ...dropTargetProps, className: ({ isActive }) => cn("flex items-center gap-2 w-full", "rounded-xl px-3 py-2 text-sm text-left", "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70", isActive && cn(ACTIVE_NAV_ROW_CLASS, ACTIVE_NAV_TEXT_CLASS), isDragOver &&
665
+ return (_jsx(NavLink, { ref: activeLinkRef, "data-active-nav-row": currentPath === item.to ? "true" : undefined, "data-nav-depth": 0, to: item.to, ...draggableProps, ...dropTargetProps, className: ({ isActive }) => cn("relative flex items-center gap-2 w-full", getNavDepthOffsets(0).body,
666
+ // The transparent border is NOT decoration: it is what makes this
667
+ // row's box model match every other variant. An absolutely
668
+ // positioned twisty resolves against the PADDING box, so a row
669
+ // without the border puts its disclosure cell 1px left of its
670
+ // siblings' and the column goes ragged.
671
+ "rounded-xl border border-transparent pr-3 py-2 text-sm text-left", "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70", isActive && cn(ACTIVE_NAV_ROW_CLASS, ACTIVE_NAV_TEXT_CLASS), isDragOver &&
574
672
  !isFileDragOver &&
575
- "bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50", isFileDragOver && "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && "opacity-50"), children: ({ isActive }) => (_jsxs(_Fragment, { children: [_jsx(item.icon, { className: cn("size-4 shrink-0", isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
673
+ "bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50", isFileDragOver && "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && "opacity-50"), children: ({ isActive }) => (_jsxs(_Fragment, { children: [_jsx(NavTwisty, { hasChildren: false, label: item.label, isActive: isActive, depth: 0 }), _jsx(item.icon, { className: cn("size-4 shrink-0", isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-foreground/60") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
576
674
  ? ACTIVE_NAV_BADGE_CLASS
577
675
  : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot }), isFileDragOver && (_jsx(Upload, { "aria-hidden": "true", className: "ml-auto size-3.5 shrink-0 text-primary pointer-events-none" }))] })) }));
578
676
  }
@@ -891,13 +989,13 @@ function MobileDrawer() {
891
989
  window.addEventListener("keydown", handleEscape);
892
990
  return () => window.removeEventListener("keydown", handleEscape);
893
991
  }, [isMobileOpen, isToolPanelOpen, closeMobile]);
894
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: cn("fixed inset-0 z-50 bg-black/50 transition-opacity duration-200", isMobileOpen ? "opacity-100" : "opacity-0 pointer-events-none"), onClick: closeMobile, "aria-hidden": "true" }), _jsx("aside", { className: cn("fixed top-0 left-0 z-50 h-dvh", "flex flex-col", "border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground shadow-xl", "transition-transform duration-200 ease-in-out", isMobileOpen ? "translate-x-0" : "-translate-x-full"), style: { width: drawerWidth }, role: "complementary", "aria-label": "Navigation sidebar", children: _jsx(SidebarContent, { collapsed: false }) })] }));
992
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: cn("fixed inset-0 z-50 bg-black/50 transition-opacity duration-200", isMobileOpen ? "opacity-100" : "opacity-0 pointer-events-none"), onClick: closeMobile, "aria-hidden": "true" }), _jsx("aside", { "data-sidebar-surface": "mobile", className: cn("fixed top-0 left-0 z-50 h-dvh", "flex flex-col", "border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground shadow-xl", "transition-transform duration-200 ease-in-out", isMobileOpen ? "translate-x-0" : "-translate-x-full"), style: { width: drawerWidth }, role: "complementary", "aria-label": "Navigation sidebar", children: _jsx(SidebarContent, { collapsed: false }) })] }));
895
993
  }
896
994
  /** Desktop inline sidebar (expanded or collapsed rail) */
897
995
  function DesktopSidebar() {
898
996
  const isOpen = useSidebarStore((s) => s.isOpen);
899
997
  const width = useSidebarStore((s) => s.width);
900
- return (_jsxs("aside", { className: cn("group/sidebar relative hidden md:flex flex-col shrink-0", "h-full", "border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground", "transition-[width] duration-200 ease-in-out", "overflow-hidden"), style: { width: isOpen ? width : RAIL_WIDTH }, role: "complementary", "aria-label": "Navigation sidebar", children: [_jsx(SidebarContent, { collapsed: !isOpen }), isOpen && _jsx(SidebarResizeHandle, {})] }));
998
+ return (_jsxs("aside", { "data-sidebar-surface": "desktop", className: cn("group/sidebar relative hidden md:flex flex-col shrink-0", "h-full", "border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground", "transition-[width] duration-200 ease-in-out", "overflow-hidden"), style: { width: isOpen ? width : RAIL_WIDTH }, role: "complementary", "aria-label": "Navigation sidebar", children: [_jsx(SidebarContent, { collapsed: !isOpen }), isOpen && _jsx(SidebarResizeHandle, {})] }));
901
999
  }
902
1000
  export function Sidebar() {
903
1001
  return (_jsxs(_Fragment, { children: [_jsx(MobileDrawer, {}), _jsx(DesktopSidebar, {})] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "description": "React agent UI core for Foundry UI — chat, composer, AG-UI adapter for assistant-ui, tool cards, panels, sidebar, theme runtime, and UI stores.",
6
6
  "keywords": [
@@ -70,8 +70,8 @@
70
70
  "tailwind-merge": "^3.5.0",
71
71
  "react-markdown": "^10.0.0",
72
72
  "remark-gfm": "^4.0.0",
73
- "@iloveagents/foundry-agent": "^0.12.1",
74
- "@iloveagents/foundry-web-primitives": "^0.12.1"
73
+ "@iloveagents/foundry-agent": "^0.13.0",
74
+ "@iloveagents/foundry-web-primitives": "^0.13.0"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@ag-ui/client": "^0.0.52",