@iloveagents/foundry-web-ui 0.21.1 → 0.22.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.
@@ -6,20 +6,10 @@ export interface BrandLockup {
6
6
  isIcon: boolean;
7
7
  name: string;
8
8
  showName: boolean;
9
+ /** Validated CSS height for the mark, or undefined to use the default. */
10
+ height?: string;
9
11
  }
10
- /**
11
- * Decide what the brand lockup renders. Exported (underscore-prefixed) so the
12
- * branching is unit-testable without a DOM renderer — same approach as
13
- * ``tool-fallback.tsx``.
14
- *
15
- * The rule worth stating: whether the name is printed depends on the *shape*
16
- * of the brand image. A wordmark already spells the brand out, so pairing it
17
- * with `appName` stutters ("Acme Acme"). An icon says nothing on its
18
- * own, so it needs the name to complete the lockup. Hence an icon wins over a
19
- * wordmark when both are set — supplying an icon is an explicit request for
20
- * "<mark> Product Name", which is what a tenant whose product name differs
21
- * from their company name is asking for.
22
- */
12
+ export declare function _safeLogoHeight(value: string | undefined): string | undefined;
23
13
  export declare function _resolveBrandLockup(branding: ThemeBranding, mode: "light" | "dark", showName: boolean): BrandLockup;
24
14
  export declare function AppBrand({ showName, className, iconClassName, labelClassName, }: {
25
15
  showName?: boolean;
@@ -15,6 +15,37 @@ import { useThemeRuntime } from "./theme-runtime-provider.js";
15
15
  * "<mark> Product Name", which is what a tenant whose product name differs
16
16
  * from their company name is asking for.
17
17
  */
18
+ /**
19
+ * A tenant-supplied length, or undefined.
20
+ *
21
+ * The value lands in an inline `style`, and a theme is content a tenant
22
+ * authors — so this allowlists a shape rather than trying to spot bad ones.
23
+ * Anything else (a `calc()`, a `var()`, a url, a second declaration smuggled
24
+ * in behind a `;`) fails the pattern and the caller falls back to the default
25
+ * height, which is always a safe thing to render.
26
+ */
27
+ //: The tallest mark the header will render. A syntactically valid length is
28
+ //: not a SAFE one: `logoHeight: "320px"` is a plausible typo, and because the
29
+ //: mark is `shrink-0` it would push the sidebar and mobile-header actions off
30
+ //: screen rather than shrinking. Marks above this are clamped, not rejected —
31
+ //: a too-large value should look wrong, not vanish.
32
+ const MAX_LOGO_HEIGHT_REM = 4;
33
+ const REM_PER_UNIT = { rem: 1, em: 1, px: 1 / 16 };
34
+ export function _safeLogoHeight(value) {
35
+ if (!value)
36
+ return undefined;
37
+ // The integer part is optional: `.5rem` is a valid CSS length and a common
38
+ // spelling, and silently falling back to the default for it would make the
39
+ // contract a lie.
40
+ const match = /^(\d*\.?\d+)(px|rem|em)$/.exec(value.trim());
41
+ if (!match)
42
+ return undefined;
43
+ const [, rawNumber, unit] = match;
44
+ const asRem = Number(rawNumber) * REM_PER_UNIT[unit];
45
+ if (!Number.isFinite(asRem) || asRem <= 0)
46
+ return undefined;
47
+ return asRem > MAX_LOGO_HEIGHT_REM ? `${MAX_LOGO_HEIGHT_REM}rem` : value.trim();
48
+ }
18
49
  export function _resolveBrandLockup(branding, mode, showName) {
19
50
  const isDark = mode === "dark";
20
51
  const pick = (dark, light) => (isDark ? dark || light : light || dark);
@@ -26,12 +57,18 @@ export function _resolveBrandLockup(branding, mode, showName) {
26
57
  isIcon: Boolean(iconUrl),
27
58
  name: branding.appName || "Foundry UI",
28
59
  showName: showName && (Boolean(iconUrl) || !markUrl),
60
+ height: _safeLogoHeight(branding.logoHeight),
29
61
  };
30
62
  }
31
63
  export function AppBrand({ showName = true, className, iconClassName, labelClassName, }) {
32
64
  const runtime = useThemeRuntime();
33
65
  const lockup = _resolveBrandLockup(runtime.branding, runtime.mode, showName);
34
- return (_jsxs("span", { className: cn("flex items-center gap-2", className), children: [lockup.markUrl ? (_jsx("img", { src: lockup.markUrl, alt: lockup.name, className: cn("shrink-0 object-contain",
66
+ return (_jsxs("span", { className: cn("flex items-center gap-2", className), children: [lockup.markUrl ? (_jsx("img", { src: lockup.markUrl, alt: lockup.name, style: lockup.height ? { height: lockup.height } : undefined, className: cn("object-contain",
67
+ // A custom mark must be able to SHRINK. `shrink-0` is right for the
68
+ // 20px default, whose max-widths already fit every header; for a
69
+ // taller one it is the thing that pushes the collapse button out
70
+ // of the sidebar instead of letting the image narrow.
71
+ lockup.height ? "min-w-0 shrink" : "shrink-0",
35
72
  // Both lock HEIGHT and let width follow the aspect ratio. Sizing an
36
73
  // icon into a square box (`size-5`) silently shrinks any mark that
37
74
  // is not 1:1 — `object-contain` letterboxes a 4:3 glyph to 20x15,
@@ -39,5 +76,16 @@ export function AppBrand({ showName = true, className, iconClassName, labelClass
39
76
  // replaced. Marks are rarely square, so height is the only
40
77
  // dimension worth fixing. The max-width is just a runaway guard:
41
78
  // tighter for an icon, which has to leave room for the name.
42
- lockup.isIcon ? "h-5 w-auto max-w-8" : "h-5 w-auto max-w-[7rem]", iconClassName) })) : (_jsx(Layers3, { className: cn("size-5 shrink-0 text-primary", iconClassName) })), lockup.showName && _jsx("span", { className: cn("truncate", labelClassName), children: lockup.name })] }));
79
+ // `branding.logoHeight` overrides the HEIGHT, and the width is
80
+ // bounded by the CONTAINER rather than by a number picked here.
81
+ // The 7rem cap is sized for a 20px mark and would letterbox a
82
+ // taller one; a bigger fixed cap is just a different wrong number
83
+ // — 20rem is 320px while an expanded sidebar is 248px, so a 4:1
84
+ // wordmark at the 4rem ceiling (256px) still overflowed it.
85
+ // `max-w-full` is whatever the header actually has.
86
+ lockup.height
87
+ ? "w-auto max-w-full"
88
+ : lockup.isIcon
89
+ ? "h-5 w-auto max-w-8"
90
+ : "h-5 w-auto max-w-[7rem]", iconClassName) })) : (_jsx(Layers3, { className: cn("size-5 shrink-0 text-primary", iconClassName) })), lockup.showName && _jsx("span", { className: cn("truncate", labelClassName), children: lockup.name })] }));
43
91
  }
@@ -211,7 +211,7 @@ function statusIconClass(tone) {
211
211
  return "text-primary";
212
212
  case "neutral":
213
213
  default:
214
- return "text-sidebar-foreground/60";
214
+ return "text-sidebar-icon";
215
215
  }
216
216
  }
217
217
  function NavStatusIcon({ statusIcon }) {
@@ -481,7 +481,7 @@ function NavLeafItem({ item, isActive, onClick, depth = 0, }) {
481
481
  ? "text-sidebar-foreground/45 cursor-default"
482
482
  : isActive
483
483
  ? ACTIVE_NAV_TEXT_CLASS
484
- : "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
484
+ : "text-sidebar-foreground/80"), children: [_jsx(item.icon, { className: cn("size-3.5 shrink-0", isActive && !isDisabled ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-icon") }), _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
485
485
  ? ACTIVE_NAV_BADGE_CLASS
486
486
  : "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 })] }));
487
487
  }
@@ -492,7 +492,7 @@ function NavLeafItem({ item, isActive, onClick, depth = 0, }) {
492
492
  !isDisabled &&
493
493
  "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
494
494
  !isDisabled &&
495
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, 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" }))] }));
495
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, 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-icon") }), _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" }))] }));
496
496
  }
497
497
  function SubNavItems({ items, parentPath, depth = 1, }) {
498
498
  const navigate = useNavigate();
@@ -586,7 +586,7 @@ function NestedFolderItem({ item, depth }) {
586
586
  ? ACTIVE_NAV_ICON_CLASS
587
587
  : isAncestorActive
588
588
  ? "text-sidebar-foreground/80"
589
- : "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
589
+ : "text-sidebar-icon") }), _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
590
590
  ? ACTIVE_NAV_BADGE_CLASS
591
591
  : "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) => {
592
592
  const hasNestedChildren = child.children && child.children.length > 0;
@@ -660,7 +660,7 @@ function CollapsibleNavItem({ item, inTree = true }) {
660
660
  ? ACTIVE_NAV_ICON_CLASS
661
661
  : ancestor
662
662
  ? "text-sidebar-foreground/80"
663
- : "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
663
+ : "text-sidebar-icon") }), _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
664
664
  ? ACTIVE_NAV_BADGE_CLASS
665
665
  : "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 })] }));
666
666
  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 })] }))] }));
@@ -679,7 +679,7 @@ function CollapsibleNavItem({ item, inTree = true }) {
679
679
  "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 &&
680
680
  !isFileDragOver &&
681
681
  "bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50", isFileDragOver &&
682
- "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, childrenUnloaded: item.childrenUnloaded, inTree: inTree, 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
682
+ "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, childrenUnloaded: item.childrenUnloaded, inTree: inTree, label: item.label, isActive: isActive, depth: 0 }), _jsx(item.icon, { className: cn("size-4 shrink-0", isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-icon") }), _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
683
683
  ? ACTIVE_NAV_BADGE_CLASS
684
684
  : "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" }))] })) }));
685
685
  }
@@ -743,7 +743,7 @@ function SidebarResizeHandle() {
743
743
  // group is now the source of truth for "you're in a chat right now".
744
744
  // Kept as a comment so future spelunkers know it was intentional.
745
745
  function CollapsedNavShortcut({ to, label, icon: Icon, isActive, }) {
746
- return (_jsx(NavLink, { to: to, children: _jsx(TooltipIconButton, { tooltip: label, side: "right", className: cn(isActive && "border border-transparent bg-sidebar-selected text-sidebar-foreground"), children: _jsx(Icon, { className: cn("size-4", isActive && "text-primary") }) }) }, to));
746
+ return (_jsx(NavLink, { to: to, children: _jsx(TooltipIconButton, { tooltip: label, side: "right", className: cn(isActive && "border border-transparent bg-sidebar-selected text-sidebar-foreground"), children: _jsx(Icon, { className: cn("size-4", isActive ? "text-primary" : "text-sidebar-icon") }) }) }, to));
747
747
  }
748
748
  // ---------------------------------------------------------------------------
749
749
  // Collapsible nav-group state — persisted per-label in localStorage so the
@@ -946,7 +946,7 @@ function SidebarContent({ collapsed }) {
946
946
  e.preventDefault();
947
947
  }, []);
948
948
  if (collapsed) {
949
- return (_jsxs("div", { className: "flex flex-col items-center gap-1 py-3 flex-1", children: [_jsx(TooltipIconButton, { tooltip: "Expand sidebar", side: "right", onClick: toggle, children: _jsx(PanelLeftOpen, { className: "size-5" }) }), _jsx(TooltipIconButton, { tooltip: "New Thread", side: "right", onClick: handleNewConversation, children: _jsx(SquarePen, { className: "size-4" }) }), _jsx("div", { className: "mt-4 flex w-full flex-col items-stretch gap-1", children: navConfig
949
+ return (_jsxs("div", { className: "flex flex-col items-center gap-1 py-3 flex-1", children: [_jsx(TooltipIconButton, { tooltip: "Expand sidebar", side: "right", onClick: toggle, children: _jsx(PanelLeftOpen, { className: "size-5" }) }), _jsx(TooltipIconButton, { tooltip: "New Thread", side: "right", onClick: handleNewConversation, children: _jsx(SquarePen, { className: "size-4 text-sidebar-icon" }) }), _jsx("div", { className: "mt-4 flex w-full flex-col items-stretch gap-1", children: navConfig
950
950
  .map((group, groupIndex) => {
951
951
  // Modules with a non-flat collapsed rail (e.g. Spaces'
952
952
  // "header + active workspace") supply `collapsedRail`; the
@@ -980,7 +980,7 @@ function SidebarContent({ collapsed }) {
980
980
  // column so icons stay aligned with the rail.
981
981
  _jsx("div", { className: cn("flex flex-col items-center gap-1", visibleIndex > 0 && "mt-2 pt-2 border-t border-sidebar-border/40"), children: items.map(({ to, label, icon, isActive }) => (_jsx(CollapsedNavShortcut, { to: to, label: label, icon: icon, isActive: isActive }, to))) }, `${group.label || ""}-${groupIndex}`))) })] }));
982
982
  }
983
- return (_jsxs("div", { className: "flex flex-col flex-1 min-h-0", children: [_jsxs("div", { className: "flex items-center justify-between px-3 py-3", children: [_jsx(NavLink, { to: "/", className: "flex items-center gap-2 hover:opacity-80 transition-opacity", children: _jsx(AppBrand, { iconClassName: "text-primary", labelClassName: "truncate text-sm font-semibold text-sidebar-foreground" }) }), _jsx(TooltipIconButton, { tooltip: "Collapse sidebar", side: "right", onClick: toggle, children: _jsx(PanelLeft, { className: "size-5" }) })] }), _jsx("div", { className: "px-1 mb-0.5", children: _jsxs("button", { type: "button", onClick: handleNewConversation, className: 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"), children: [_jsx(SquarePen, { className: "size-4 shrink-0 text-sidebar-foreground/60" }), "New Thread"] }) }), _jsxs("nav", { className: "flex-1 overflow-y-auto px-1 pb-16", onDragOverCapture: preventNativeFileDrop, onDropCapture: preventNativeFileDrop, children: [navConfig.map((group) => (_jsx(NavGroupSection, { group: group }, group.label))), showNavSkeleton && (_jsx("div", { className: "space-y-1 px-1 pt-2", "aria-hidden": "true", children: Array.from({ length: 6 }).map((_, index) => (_jsx("div", { className: "h-7 mx-1 rounded-lg animate-pulse bg-sidebar-accent/40" }, index))) }))] }), _jsx(SidebarFooterActions, {})] }));
983
+ return (_jsxs("div", { className: "flex flex-col flex-1 min-h-0", children: [_jsxs("div", { className: "flex items-center justify-between px-3 py-3", children: [_jsx(NavLink, { to: "/", className: "flex items-center gap-2 hover:opacity-80 transition-opacity", children: _jsx(AppBrand, { iconClassName: "text-primary", labelClassName: "truncate text-sm font-semibold text-sidebar-foreground" }) }), _jsx(TooltipIconButton, { tooltip: "Collapse sidebar", side: "right", onClick: toggle, children: _jsx(PanelLeft, { className: "size-5" }) })] }), _jsx("div", { className: "px-1 mb-0.5", children: _jsxs("button", { type: "button", onClick: handleNewConversation, className: 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"), children: [_jsx(SquarePen, { className: "size-4 shrink-0 text-sidebar-icon" }), "New Thread"] }) }), _jsxs("nav", { className: "flex-1 overflow-y-auto px-1 pb-16", onDragOverCapture: preventNativeFileDrop, onDropCapture: preventNativeFileDrop, children: [navConfig.map((group) => (_jsx(NavGroupSection, { group: group }, group.label))), showNavSkeleton && (_jsx("div", { className: "space-y-1 px-1 pt-2", "aria-hidden": "true", children: Array.from({ length: 6 }).map((_, index) => (_jsx("div", { className: "h-7 mx-1 rounded-lg animate-pulse bg-sidebar-accent/40" }, index))) }))] }), _jsx(SidebarFooterActions, {})] }));
984
984
  }
985
985
  function SidebarFooterActions() {
986
986
  const footerActions = useNavStore((s) => s.footerActions);
@@ -30,6 +30,16 @@ export interface ThemeSidebarSlots {
30
30
  sidebarAccentForeground?: string;
31
31
  sidebarSelected?: string;
32
32
  sidebarSelectedForeground?: string;
33
+ /**
34
+ * Colour of the icons in the sidebar's idle rows.
35
+ *
36
+ * Separate from `sidebarForeground` because a brand whose mark is a colour
37
+ * usually wants the icons in that colour and the LABELS still readable —
38
+ * tinting `sidebarForeground` to reach the icons drags every label with it.
39
+ * Unset resolves to the previous behaviour (the label colour at 60%), so no
40
+ * existing theme moves.
41
+ */
42
+ sidebarIcon?: string;
33
43
  sidebarBorder?: string;
34
44
  sidebarRing?: string;
35
45
  }
@@ -99,6 +109,21 @@ export interface ThemeBranding {
99
109
  */
100
110
  iconUrl?: string;
101
111
  darkIconUrl?: string;
112
+ /**
113
+ * Rendered height of the mark, as a CSS length (`"2rem"`, `"32px"`).
114
+ * Defaults to the 20px lockup height when unset.
115
+ *
116
+ * A wordmark is not interchangeable with an icon at one fixed height. Marks
117
+ * that carry a tagline under the name — a common wordmark shape — turn the
118
+ * tagline into an unreadable smear at 20px, and the whole lockup reads as
119
+ * undersized next to the product name. The tenant supplying the asset is the
120
+ * only one who knows how much height it needs, so this is theirs to set.
121
+ *
122
+ * Validated, not interpolated blindly: the value reaches an inline style and
123
+ * themes are tenant-authored content, so anything that is not a plain
124
+ * non-negative CSS length in px/rem/em is ignored in favour of the default.
125
+ */
126
+ logoHeight?: string;
102
127
  }
103
128
  export interface ThemeDefinition {
104
129
  light?: ThemeColorSlots;
@@ -28,6 +28,7 @@ const SIDEBAR_KEYS = [
28
28
  "sidebarAccentForeground",
29
29
  "sidebarSelected",
30
30
  "sidebarSelectedForeground",
31
+ "sidebarIcon",
31
32
  "sidebarBorder",
32
33
  "sidebarRing",
33
34
  ];
@@ -84,6 +85,7 @@ const BRANDING_KEYS = [
84
85
  "darkLogoUrl",
85
86
  "iconUrl",
86
87
  "darkIconUrl",
88
+ "logoHeight",
87
89
  ];
88
90
  function cleanObject(value, keys) {
89
91
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -188,6 +190,7 @@ function toCssVars(colors, sidebar, status, charts, typography, radius, app) {
188
190
  ["--sidebar-accent-foreground"]: sidebar.sidebarAccentForeground,
189
191
  ["--sidebar-selected"]: sidebar.sidebarSelected,
190
192
  ["--sidebar-selected-foreground"]: sidebar.sidebarSelectedForeground,
193
+ ["--sidebar-icon"]: sidebar.sidebarIcon,
191
194
  ["--sidebar-border"]: sidebar.sidebarBorder,
192
195
  ["--sidebar-ring"]: sidebar.sidebarRing,
193
196
  ["--success"]: status.success,
@@ -248,6 +251,7 @@ function defaultSidebar(colors) {
248
251
  sidebarAccentForeground: colors.accentForeground,
249
252
  sidebarSelected: colors.muted,
250
253
  sidebarSelectedForeground: colors.foreground,
254
+ sidebarIcon: `color-mix(in srgb, ${colors.foreground} 60%, transparent)`,
251
255
  sidebarBorder: colors.border,
252
256
  sidebarRing: colors.ring,
253
257
  };
@@ -376,6 +380,7 @@ export const FOUNDRY_VIOLET_THEME = {
376
380
  sidebarAccentForeground: "color-mix(in srgb, var(--foreground) 92%, var(--primary) 8%)",
377
381
  sidebarSelected: "var(--muted)",
378
382
  sidebarSelectedForeground: "var(--foreground)",
383
+ sidebarIcon: "color-mix(in srgb, var(--sidebar-foreground) 60%, transparent)",
379
384
  sidebarBorder: "color-mix(in srgb, var(--border) 96%, var(--foreground) 4%)",
380
385
  sidebarRing: "oklch(43% 0.24 286)",
381
386
  },
@@ -388,6 +393,7 @@ export const FOUNDRY_VIOLET_THEME = {
388
393
  sidebarAccentForeground: "color-mix(in srgb, var(--foreground) 90%, white 10%)",
389
394
  sidebarSelected: "color-mix(in srgb, var(--muted) 78%, var(--background) 22%)",
390
395
  sidebarSelectedForeground: "var(--foreground)",
396
+ sidebarIcon: "color-mix(in srgb, var(--sidebar-foreground) 60%, transparent)",
391
397
  sidebarBorder: "color-mix(in srgb, var(--border) 90%, white 10%)",
392
398
  sidebarRing: "oklch(0.72 0.18 286)",
393
399
  },
package/dist/styles.css CHANGED
@@ -33,6 +33,7 @@
33
33
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
34
34
  --color-sidebar-selected: var(--sidebar-selected);
35
35
  --color-sidebar-selected-foreground: var(--sidebar-selected-foreground);
36
+ --color-sidebar-icon: var(--sidebar-icon);
36
37
  --color-sidebar-border: var(--sidebar-border);
37
38
  --color-sidebar-ring: var(--sidebar-ring);
38
39
  --color-success: var(--success);
@@ -77,6 +78,7 @@
77
78
  --sidebar-primary-foreground: oklch(0.985 0 0);
78
79
  --sidebar-accent: oklch(0.967 0.001 286.375);
79
80
  --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
81
+ --sidebar-icon: color-mix(in srgb, var(--sidebar-foreground) 60%, transparent);
80
82
  --sidebar-selected: var(--muted);
81
83
  --sidebar-selected-foreground: var(--foreground);
82
84
  --sidebar-border: oklch(0.92 0.004 286.32);
@@ -122,6 +124,7 @@
122
124
  --sidebar-primary-foreground: oklch(0.985 0 0);
123
125
  --sidebar-accent: oklch(0.274 0.006 286.033);
124
126
  --sidebar-accent-foreground: oklch(0.985 0 0);
127
+ --sidebar-icon: color-mix(in srgb, var(--sidebar-foreground) 60%, transparent);
125
128
  --sidebar-selected: color-mix(in srgb, var(--muted) 78%, var(--background) 22%);
126
129
  --sidebar-selected-foreground: var(--foreground);
127
130
  --sidebar-border: oklch(1 0 0 / 10%);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.21.1",
3
+ "version": "0.22.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": [
@@ -71,8 +71,8 @@
71
71
  "react-markdown": "^10.0.0",
72
72
  "remark-gfm": "^4.0.0",
73
73
  "tailwind-merge": "^3.5.0",
74
- "@iloveagents/foundry-agent": "^0.21.1",
75
- "@iloveagents/foundry-web-primitives": "^0.21.1"
74
+ "@iloveagents/foundry-agent": "^0.22.0",
75
+ "@iloveagents/foundry-web-primitives": "^0.22.0"
76
76
  },
77
77
  "devDependencies": {
78
78
  "@ag-ui/client": "^0.0.52",