From fedff38d4a4536d8353b33c2e46c03274b852eef Mon Sep 17 00:00:00 2001 From: Govinda Vashishtha <57435703+govindavashishtha@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:16:07 +0530 Subject: [PATCH 1/3] feat(branding): introduce support for icon-only branding and flexible logo configurations - Added new type to support square icons and optional wide logos. - Updated theme documentation to reflect changes in branding options. - Implemented logic for rendering icons and logos based on the theme mode. - Introduced fallback mechanisms for compact and expanded surfaces. - Updated examples and tests to demonstrate new branding capabilities. --- .changeset/brand-icon-wordmark.md | 6 ++ docs/ui-sdk/guides/layouts-and-theme.mdx | 52 ++++++---- docs/ui-sdk/reference/theme.mdx | 6 +- docs/ui-sdk/reference/trueforge-ui.mdx | 32 +++--- packages/frontend/src/App.tsx | 5 - packages/trueforge-ui/README.md | 28 +++--- packages/trueforge-ui/docs/customization.md | 2 +- packages/trueforge-ui/docs/server.md | 1 + packages/trueforge-ui/docs/theming.md | 71 +++++++------ .../trueforge-ui/src/atoms/WelcomeScreen.tsx | 2 +- .../src/icons/trueforge-logo-dark.svg | 13 +++ .../trueforge-ui/src/icons/trueforge-logo.svg | 13 +++ packages/trueforge-ui/src/index.ts | 1 + .../src/layouts/SidebarLayout.tsx | 18 +++- .../trueforge-ui/src/layouts/WidgetLayout.tsx | 2 +- packages/trueforge-ui/src/theme/brand.tsx | 63 +++++++----- packages/trueforge-ui/src/theme/types.ts | 39 +++++--- .../test/containers/TrueForgeUI.test.tsx | 57 ++++++++++- .../test/theme/ThemeProvider.test.tsx | 2 +- .../trueforge-ui/test/theme/brand.test.tsx | 99 +++++++++++++++---- 20 files changed, 367 insertions(+), 145 deletions(-) create mode 100644 .changeset/brand-icon-wordmark.md create mode 100644 packages/trueforge-ui/src/icons/trueforge-logo-dark.svg create mode 100644 packages/trueforge-ui/src/icons/trueforge-logo.svg diff --git a/.changeset/brand-icon-wordmark.md b/.changeset/brand-icon-wordmark.md new file mode 100644 index 000000000..b35cea3b2 --- /dev/null +++ b/.changeset/brand-icon-wordmark.md @@ -0,0 +1,6 @@ +--- +"@truefoundry/trueforge-ui": minor +"@truefoundry/trueforge": patch +--- + +Support icon-only branding, square icons with display names, and optional wide logos that fall back to the square icon in compact chrome. The default expanded mark is the TrueForge wordmark. diff --git a/docs/ui-sdk/guides/layouts-and-theme.mdx b/docs/ui-sdk/guides/layouts-and-theme.mdx index 34bddbbec..cba838f45 100644 --- a/docs/ui-sdk/guides/layouts-and-theme.mdx +++ b/docs/ui-sdk/guides/layouts-and-theme.mdx @@ -58,18 +58,25 @@ Built-in layouts are code-split, so only the layout you use is loaded into your ## Preset Themes -Choose from four built-in themes - Claude, ChatGPT, Gemini, and TrueFoundry, or create a [custom theme](../setup-custom-ui/custom-theme) to match your brand. +Choose from four built-in themes - Claude, ChatGPT, Gemini, and TrueForge, or create a [custom theme](../setup-custom-ui/custom-theme) to match your brand. ## The theme object ```ts +type BrandImage = string | { src?: string; light?: string; dark?: string }; + +type BrandConfig = { + name?: string; + href?: string; +} & ( + | { icon?: BrandImage; logo?: never } + | { icon: BrandImage; logo: BrandImage } +); + type ThemeConfig = { preset?: "trueforge" | "claude" | "chatgpt" | "gemini"; // default "trueforge" mode?: "light" | "dark" | "system"; - brand?: { - name: string; // required when `brand` is set - logo?: string | BrandLogoConfig; - }, + brand?: BrandConfig; ... }; ``` @@ -81,13 +88,19 @@ type ThemeConfig = { preset: "trueforge", mode: "system", brand: { - name: "TrueForge", - logo: "/logo.svg", + name: "Acme", + icon: "/icon.svg", + logo: "/wordmark.svg", } }} /> ``` +`icon` is the square asset used in collapsed and compact surfaces. `logo` is an optional +wider asset used in expanded chrome and requires `icon` as its compact fallback. Omit +`name` and `logo` for icon-only branding. When `brand` is omitted, expanded chrome uses +the built-in TrueForge wordmark and compact surfaces use the built-in square mark. + The `chatgpt`, `claude`, and `gemini` themes are shown below: @@ -124,24 +137,29 @@ You can also provide separate logos for light and dark modes, allowing your bran theme={{ ... brand: { - name: "TrueForge", + name: "Acme", + icon: { + light: "/icon/light.svg", + dark: "/icon/dark.svg", + }, logo: { - light: "/logo/light.svg", - dark: "/logo/dark.svg", - href: "https://trueforge.dev", // URL to redirect on logo click + light: "/wordmark/light.svg", + dark: "/wordmark/dark.svg", }, + href: "https://example.com", } }} /> ``` -The logo source is selected based on the resolved theme mode. When mode is omitted, the logo automatically follows the system theme. +The icon and logo sources are selected based on the resolved theme mode. When mode is omitted, they automatically follow the system theme. -* Provide both light and dark to use a different logo for each mode. -* Provide only one of light or dark to use the same logo in both modes. -* Use src for a mode-independent logo. -* `name` sets the brand name in the topbar right next to the logo. -* Set `href` to make the logo clickable. +* Provide both light and dark to use a different image for each mode. +* Provide only one of light or dark to use the same image in both modes. +* Use src for a mode-independent image. +* `name` labels configured images and appears beside `icon` when no wide `logo` is set. +* Omit `name` for icon-only branding. +* Set the brand-level `href` to make configured images clickable. For more advanced use cases, such as rendering an inline SVG or an animated logo, override the BrandLogo slot instead of passing a React node through the theme object. diff --git a/docs/ui-sdk/reference/theme.mdx b/docs/ui-sdk/reference/theme.mdx index ac8361a97..f70b9e254 100644 --- a/docs/ui-sdk/reference/theme.mdx +++ b/docs/ui-sdk/reference/theme.mdx @@ -9,7 +9,7 @@ description: "Theme provider, hooks, presets, brand, and slots." | --- | --- | | `ThemeProvider` | Owns light/dark/system state and injects token CSS variables on a wrapper `div.aui-theme-root`. | | `SlotsProvider` | Slot override registry. Must sit outside `TrueFoundryChatProvider`. | -| `BrandLogo` | The product mark: renders `theme.brand.logo` for the active mode as an image labelled with `theme.brand.name`, linked when the config sets `href`. Falls back to the default mark. Overridable slot. Props: `{ className? }`. | +| `BrandLogo` | Product mark. `variant="icon"` renders the compact square asset; `variant="logo"` renders the wider asset and falls back to the icon. With no brand override, the logo variant uses the built-in TrueForge wordmark. Overridable slot. Props: `{ className?, variant?: "icon" \| "logo" }`. | | `Icon` | Renders a built-in icon by name, or your replacement when `theme.icons` maps that name. Props: `{ name: string \| readonly string[] } & IconProps`. An array resolves to its **last** element, accommodating Font Awesome-style `["far", "clone"]` tuples; it is not a fallback chain. An unresolved name renders nothing. | ## Hooks @@ -19,7 +19,7 @@ description: "Theme provider, hooks, presets, brand, and slots." | `useTheme()` | `{ preset, mode, preference, isDark, setTheme }`. `setTheme` is a no-op when `theme.mode` is controlled. | | `useThemeMode()` | Resolved `"light" \| "dark"`. Reads the mode published by `SlotsProvider`, so it returns `"light"` when no `SlotsProvider` is above it — even in dark mode. | | `useBrand()` | The active `BrandConfig`. | -| `useBrandName()` | `theme.brand.name`, or `"TrueForge"` when unset. Safe outside a provider. | +| `useBrandName()` | The configured name; `undefined` for unnamed custom branding; or `"TrueForge"` when no custom image is configured. Safe outside a provider. | | `useThemeIcons()` | The `IconMap` from `theme.icons`. | | `useContentClassNames()` | `theme.classNames`; throws outside a provider. | | `useOptionalContentClassNames()` | Same, but returns `{}` outside a provider instead of throwing — no null check needed. | @@ -35,7 +35,7 @@ description: "Theme provider, hooks, presets, brand, and slots." ## Types -`ThemeConfig`, `ThemeMode`, `ThemePreset`, `SemanticTokens`, `BrandConfig`, `BrandLogoConfig`, +`ThemeConfig`, `ThemeMode`, `ThemePreset`, `SemanticTokens`, `BrandConfig`, `BrandImage`, `BrandLogoConfig`, `ContentClassNames`, `IconMap`, `ThemeIconProps`, `IconProps`, `LayoutProp`, `AtomSlots`, `SlotOverrides`. diff --git a/docs/ui-sdk/reference/trueforge-ui.mdx b/docs/ui-sdk/reference/trueforge-ui.mdx index d18f722d5..1e58f9735 100644 --- a/docs/ui-sdk/reference/trueforge-ui.mdx +++ b/docs/ui-sdk/reference/trueforge-ui.mdx @@ -132,15 +132,24 @@ import { TrueForgeUI } from "@truefoundry/trueforge-ui"; - Optional. Omit it to keep the default mark and the `"TrueForge"` name; setting it - requires a `name`. + Optional. Omit it to use the built-in TrueForge wordmark in expanded chrome and + square mark in compact surfaces. - - Shown beside the mark, and used as the logo's accessible label. + + Optional display name and accessible image label. It appears beside the square + icon in expanded chrome when no wide logo is configured. Omit it for icon-only + branding. + + + Square image used in collapsed and compact surfaces. A logo configuration + requires this compact fallback. - Image URL, or per-mode sources. Omit to pair your name with the default mark. + Optional wider image used in expanded chrome. Falls back to `icon`. + + + Wraps configured brand images in a same-tab link. @@ -154,14 +163,12 @@ import { TrueForgeUI } from "@truefoundry/trueforge-ui"; Mode-agnostic source, used when neither `light` nor `dark` is set. - - Wraps the logo in a same-tab link, labelled with `name`. - A single configured mode is used for both modes, so `{ light }` alone never renders a - missing image. To render a component rather than an image, override the `BrandLogo` slot - through `overrides`. + missing image. Use `BrandLogo` with `variant="icon"` or `variant="logo"` in custom + layouts. To render a component rather than an image, override its slot through + `overrides`. Replace registry icons by name. Values are Lucide components, React nodes, render @@ -238,7 +245,10 @@ Persisted sessions remain available through the thread list. server={{ type: "truefoundry", apiKey, controlPlaneURL }} layout="sidebar" agentConfig={{ mode: "AgentComposer", defaultAgentSpec: { model: { name: "openai-main/gpt-4.1" } } }} - theme={{ preset: "claude", brand: { name: "Acme", logo: "/logo.svg" } }} + theme={{ + preset: "claude", + brand: { name: "Acme", icon: "/icon.svg", logo: "/wordmark.svg" }, + }} overrides={{ ClearChatButton: MyClearButton }} className="h-full min-h-0" onError={(e) => reportError(e)} diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 8c1b348c4..ad298f723 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -162,11 +162,6 @@ export function App() {
``` -**Light / dark marks** — pass `light` / `dark` sources instead and the SDK picks the one matching -the resolved mode. `href` wraps the logo in a same-tab link: +Add a wider `logo` for expanded chrome. A square `icon` is required with it because collapsed +chrome, the welcome screen, and the widget button continue to use the square asset: ```tsx ``` -Set only one mode and it is used for both. `name` labels the image, so no `alt` is needed. +Both `icon` and `logo` accept `{ src, light, dark }`. The SDK picks the source matching the +resolved mode; setting only one mode uses it for both. `href` wraps configured images in a +same-tab link. When `name` is omitted, images are decorative. **Component marks** — `theme.brand` takes image URLs only. To render an inline SVG or a custom component, override the `BrandLogo` slot, the same way you replace any other atom: @@ -359,7 +363,9 @@ component, override the `BrandLogo` slot, the same way you replace any other ato ``` -**Custom layouts** — import `BrandLogo` and place it anywhere; pair it with `useBrandName()` when you also want the name as text (see [Custom layouts](#custom-layouts)). +**Custom layouts** — import `BrandLogo` and use `variant="icon"` for compact surfaces or +`variant="logo"` for expanded chrome. The logo variant falls back to the square icon. Pair it +with `useBrandName()` when you also want the optional name as text (see [Custom layouts](#custom-layouts)). > _Screenshot: external brand mark rendered in the base layout header._ @@ -429,17 +435,15 @@ Built-in `layout` values: For full control, pass a React component as `layout`. The SDK still wires server, shell mode, slots, and runtime behind it. ```tsx -import { Thread, ThreadListContainer, BrandLogo, useBrandName, useTheme } from '@truefoundry/trueforge-ui'; +import { Thread, ThreadListContainer, BrandLogo, useTheme } from '@truefoundry/trueforge-ui'; function Layout({ className }: { className?: string }) { const { mode, setTheme } = useTheme(); - const brandName = useBrandName(); return (
diff --git a/packages/trueforge-ui/docs/customization.md b/packages/trueforge-ui/docs/customization.md index 1b24229ba..1a942ea66 100644 --- a/packages/trueforge-ui/docs/customization.md +++ b/packages/trueforge-ui/docs/customization.md @@ -29,7 +29,7 @@ over hacking third-party CSS: preset: 'claude', mode: 'dark', tokens: { primary: '#e11d48' }, - brand: { name: 'Acme', logo: '/brand/logo.svg' }, + brand: { name: 'Acme', icon: '/brand/icon.svg', logo: '/brand/wordmark.svg' }, icons: { send: MySendSvg }, classNames: { markdown: 'prose max-w-none', diff --git a/packages/trueforge-ui/docs/server.md b/packages/trueforge-ui/docs/server.md index 392688a92..e3aa3ba0e 100644 --- a/packages/trueforge-ui/docs/server.md +++ b/packages/trueforge-ui/docs/server.md @@ -804,6 +804,7 @@ export function App() { preset: 'chatgpt', brand: { name: 'MyCo', + icon: { src: '/myco-icon.svg' }, logo: { src: '/myco-wordmark.svg' }, }, }} diff --git a/packages/trueforge-ui/docs/theming.md b/packages/trueforge-ui/docs/theming.md index 7f53d33da..83aca746b 100644 --- a/packages/trueforge-ui/docs/theming.md +++ b/packages/trueforge-ui/docs/theming.md @@ -16,7 +16,7 @@ the SDK. Layers (each independently overridable): | ------------------ | -------------------------------------------- | --------------------------------------------- | | Preset | Baseline look | `theme.preset` | | Tokens | Colors, radius, fonts, bubble colors | `theme.tokens` + CSS vars | -| Brand | Logo image + display name | `theme.brand` (marks via slots) | +| Brand | Square icon, wide logo, and display name | `theme.brand` (marks via slots) | | Icons | Action / UI icon set | `theme.icons` (Lucide + SVG transforms) | | Content classNames | Markdown, syntax-highlighter, OpenUI, Monaco | `theme.classNames` | | Root class | Arbitrary host utilities | `theme.className` | @@ -41,7 +41,7 @@ track (orthogonal to [`docs/server.md`](./server.md)). | Coverage | Everything currently pulled from tfy | | Slot API | **Breaking** — drop `Button.Primary` / icon-string contracts; new shadcn-aligned props | | Icons | Lucide defaults; host can replace map + supply SVG transforms | -| Brand | Logo URL (per-mode) via `theme.brand`; component marks via the `BrandLogo` slot | +| Brand | Icon/logo URLs (per-mode) via `theme.brand`; component marks via the `BrandLogo` slot | | Theme API | Object (not string-only); every look aspect customizable | | Presets | Inspired-by packs: `trueforge` (default), `claude`, `chatgpt`, `gemini` | | Custom styles | CSS tokens + `className`; host may also import CSS (documented). No “load CSS file” prop | @@ -84,12 +84,12 @@ props): ### Theme / icons / brand infra -| Remove | Replace with | -| -------------------------------- | ---------------------------------------------------------------------------- | -| `tfy-web-components/theme.css` | Own palette + semantic tokens in `styles.css` | -| tfy `ThemeProvider` / `useTheme` | SDK `ThemeProvider` owned here | -| `IconProvider` + `registerIcons` | `IconRegistry` + `theme.icons` / `setIcons` | -| Hard-coded TFY marks | `theme.brand.logo` consumed by header, welcome, widget FAB, avatar fallbacks | +| Remove | Replace with | +| -------------------------------- | ----------------------------------------------------------------------- | +| `tfy-web-components/theme.css` | Own palette + semantic tokens in `styles.css` | +| tfy `ThemeProvider` / `useTheme` | SDK `ThemeProvider` owned here | +| `IconProvider` + `registerIcons` | `IconRegistry` + `theme.icons` / `setIcons` | +| Hard-coded TFY marks | `theme.brand.icon` / `logo` consumed by header, welcome, and widget FAB | ### Code surfaces (Monaco + syntax highlighter) @@ -171,17 +171,17 @@ type IconMap = Record< >; /** - * Logo sources. `light` / `dark` pick per resolved theme mode and fall back to - * each other, then to `src`. `href` wraps the logo in a same-tab link labelled - * with the brand name. + * Brand image sources. `light` / `dark` pick per resolved theme mode and fall + * back to each other, then to `src`. */ type BrandLogoConfig = { src?: string; light?: string; dark?: string; - href?: string; }; +type BrandImage = string | BrandLogoConfig; + /** * Product branding — distinct from `icons` (UI chrome). * Consumed wherever a product mark appears (header, welcome, widget FAB, @@ -190,21 +190,27 @@ type BrandLogoConfig = { * Images only: to render a component, override the `BrandLogo` slot instead, so * brand marks follow the same replacement path as every other atom. * - * `theme.brand` is optional; setting it requires a `name` (it labels the logo). - * `logo` is optional — omit it to pair host text with the stock mark. + * `icon` is the square mark for compact surfaces. `logo` is the optional wider + * mark for expanded chrome and falls back to `icon`. */ -type BrandConfig = { - /** Display name beside the mark, and the logo's accessible label. */ - name: string; - /** Image URL, or per-mode sources. Omit to keep the default mark. */ - logo?: string | BrandLogoConfig; +type BrandMetadata = { + /** Optional display name and accessible label. Omit for icon-only branding. */ + name?: string; + /** Wraps configured brand images in a same-tab link. */ + href?: string; }; +type BrandConfig = BrandMetadata & + ( + | { icon?: BrandImage; logo?: never } + | { icon: BrandImage; logo: BrandImage } + ); + type ThemeConfig = { preset?: ThemePreset; // default: "trueforge" mode?: ThemeMode; // omit = uncontrolled (useTheme().setTheme) tokens?: Partial; - brand?: BrandConfig; // logo image + display name + brand?: BrandConfig; // square icon + optional wide logo and display name className?: string; // applied on .aui-root (or theme root) icons?: IconMap; // full/partial UI icon replace + SVG transforms /** Per-surface className hooks for content renderers */ @@ -270,6 +276,7 @@ function MyLayout({ className }: { className?: string }) { tokens: { primaryButtonBg: '#…', fontFamily: '"My Font", system-ui' }, brand: { name: 'Acme Agent', + icon: { light: '/acme-icon.svg', dark: '/acme-icon-dark.svg' }, logo: { light: '/acme-wordmark.svg', dark: '/acme-wordmark-dark.svg' }, }, className: 'my-chat', @@ -326,17 +333,18 @@ as `children`. 1. All product marks render through `` — one component, never hard-coded TFY assets in layouts or atoms. Layouts resolve it via `useSlot`, so a host override reaches every call site. -2. `BrandLogo` renders the mark only. Callers that also want the name as text pair - it with `useBrandName()`, so each layout owns its own arrangement (the sidebar - sets mark + name; the widget FAB and welcome screen show the mark alone). A - second "wordmark" component would only re-encode one caller's arrangement. -3. `theme.brand` carries **image sources only** (URL or `{ src, light, dark, href }`). +2. `BrandLogo` renders `icon` for compact surfaces. With `variant="logo"`, it + renders the optional wider logo and falls back to the square icon. +3. `theme.brand` carries **image sources only** (URL or `{ src, light, dark }`). Component-valued marks go through the `BrandLogo` slot, so brand replacement uses the same mechanism as every other atom instead of a second node-shaped escape hatch in the theme config. -4. `theme.brand.name` → the accessible label for a configured logo (and its link, - when `href` is set), plus whatever text a caller renders alongside. -5. Omitting `brand` keeps the default `TrueForge` name and robot mark. +4. `theme.brand.name` is optional. When present, it labels configured images and + appears beside the square icon in expanded chrome when no wide logo is set. + Omitting it enables icon-only branding. +5. Omitting `brand` keeps the default TrueForge wordmark in expanded chrome and + the square mark when collapsed. `href` wraps configured images in a same-tab + link. 6. `{ light, dark }` sources resolve against the provider's mode, so a host mark tracks light/dark without a custom component. A single configured mode covers both, so `{ light }` alone never renders a missing image; a config with no @@ -354,7 +362,7 @@ as `children`. identity stays separate from action icons. For named UI icons the order is `theme.icons[name]` → registry default. The -robot is also the final brand fallback, so `theme.brand.logo` still takes +robot is also the final brand fallback, so `theme.brand.icon` still takes precedence over `theme.icons.robot`. Preset-specific icons such as `welcome-sparkle` and OAuth state icons (`oauth-loading`, `oauth-success`, `oauth-error`) use the same lookup order. @@ -471,7 +479,7 @@ TrueForgeUI({ layout, theme, overrides, … }) ``) even if defaults still point at TFY assets. **Done when:** app runs with owned tokens (tfy components may still be present); -host can override `--primary-button-bg` and swap `theme.brand.logo`. +host can override `--primary-button-bg` and swap `theme.brand.icon` / `logo`. ### Phase 2 — shadcn primitives + icon + brand registry @@ -533,7 +541,8 @@ no longer depends on tfy. - Prefer `theme={{ preset, tokens, brand, icons, className, classNames }}` over hacking a third-party theme. -- Replace product marks with `theme.brand.logo` (URL or per-mode sources), or +- Replace product marks with `theme.brand.icon` and optional wide `logo` (URLs + or per-mode sources), or override the `BrandLogo` slot for component marks. - Pass `layout={MyLayout}` to own chrome; compose `Thread` / `ThreadListContainer` / etc. diff --git a/packages/trueforge-ui/src/atoms/WelcomeScreen.tsx b/packages/trueforge-ui/src/atoms/WelcomeScreen.tsx index 7eee42d97..35579ba74 100644 --- a/packages/trueforge-ui/src/atoms/WelcomeScreen.tsx +++ b/packages/trueforge-ui/src/atoms/WelcomeScreen.tsx @@ -20,7 +20,7 @@ export function WelcomeScreen({ heading = 'How can I help you today?', icon, cla ) : preset === 'chatgpt' ? null : preset === 'claude' ? ( ) : ( - + ); return ( diff --git a/packages/trueforge-ui/src/icons/trueforge-logo-dark.svg b/packages/trueforge-ui/src/icons/trueforge-logo-dark.svg new file mode 100644 index 000000000..a46d05feb --- /dev/null +++ b/packages/trueforge-ui/src/icons/trueforge-logo-dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/trueforge-ui/src/icons/trueforge-logo.svg b/packages/trueforge-ui/src/icons/trueforge-logo.svg new file mode 100644 index 000000000..08d5127d7 --- /dev/null +++ b/packages/trueforge-ui/src/icons/trueforge-logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/trueforge-ui/src/index.ts b/packages/trueforge-ui/src/index.ts index 42f16c6b2..4a3e23586 100644 --- a/packages/trueforge-ui/src/index.ts +++ b/packages/trueforge-ui/src/index.ts @@ -17,6 +17,7 @@ export { } from './theme/ThemeProvider.js'; export type { BrandConfig, + BrandImage, BrandLogoConfig, ContentClassNames, IconMap, diff --git a/packages/trueforge-ui/src/layouts/SidebarLayout.tsx b/packages/trueforge-ui/src/layouts/SidebarLayout.tsx index 21511cfb1..075c4d5d1 100644 --- a/packages/trueforge-ui/src/layouts/SidebarLayout.tsx +++ b/packages/trueforge-ui/src/layouts/SidebarLayout.tsx @@ -15,6 +15,7 @@ import { Icon } from '../icons/Icon.js'; import { useOptionalShellMode } from '../server/ShellModeContext.js'; import { useBrandName } from '../theme/brand.js'; import { useSlot } from '../theme/SlotsProvider.js'; +import { useBrand } from '../theme/ThemeProvider.js'; const TruefoundrySettingsBuilder = lazy(() => import('../containers/SettingsBuilder/index.js')); @@ -24,7 +25,9 @@ let desktopCollapsed = false; export function SidebarLayout({ className }: { className?: string }) { const aui = useAui(); const shell = useOptionalShellMode(); + const brand = useBrand(); const brandName = useBrandName(); + const hasWideLogo = brand.logo != null || (brand.icon == null && brand.name == null); const BrandLogo = useSlot('BrandLogo'); const AgentsLibraryButton = useSlot('AgentsLibraryButton'); const ClearChatButton = useSlot('ClearChatButton'); @@ -93,8 +96,13 @@ export function SidebarLayout({ className }: { className?: string }) { className={cn('flex shrink-0 items-center px-3 py-3', collapsed ? 'flex-col gap-3' : 'justify-between gap-2')} >
- - {!collapsed ? {brandName} : null} + + {!collapsed && !hasWideLogo && brandName != null ? ( + {brandName} + ) : null}
diff --git a/packages/trueforge-ui/src/layouts/WidgetLayout.tsx b/packages/trueforge-ui/src/layouts/WidgetLayout.tsx index 0c762f9ce..c915b5dd1 100644 --- a/packages/trueforge-ui/src/layouts/WidgetLayout.tsx +++ b/packages/trueforge-ui/src/layouts/WidgetLayout.tsx @@ -87,7 +87,7 @@ export function WidgetLayout({ className }: { className?: string }) { × ) : ( - + )}
diff --git a/packages/trueforge-ui/src/theme/brand.tsx b/packages/trueforge-ui/src/theme/brand.tsx index 9b4ebb5a0..e8b342a04 100644 --- a/packages/trueforge-ui/src/theme/brand.tsx +++ b/packages/trueforge-ui/src/theme/brand.tsx @@ -2,54 +2,71 @@ import { useContext } from 'react'; +import { cn } from '../atoms/lib/cn.js'; import { Icon } from '../icons/Icon.js'; +import TrueForgeLogoDark from '../icons/trueforge-logo-dark.svg'; +import TrueForgeLogoLight from '../icons/trueforge-logo.svg'; import { ThemeContext } from './ThemeProvider.js'; import type { BrandLogoConfig } from './types.js'; const DEFAULT_BRAND_NAME = 'TrueForge'; /** Mode-matched source, falling back to the other mode then the mode-agnostic `src`. */ -function resolveLogoSrc({ - logo, +function resolveImageSrc({ + image, mode, }: { - logo: string | BrandLogoConfig; + image: string | BrandLogoConfig; mode: 'light' | 'dark'; }): string | undefined { - if (typeof logo === 'string') return logo; - const preferred = mode === 'dark' ? logo.dark : logo.light; - return preferred ?? logo.light ?? logo.dark ?? logo.src; + if (typeof image === 'string') return image; + const preferred = mode === 'dark' ? image.dark : image.light; + return preferred ?? image.light ?? image.dark ?? image.src; } -/** Configured brand name, or the SDK default. Safe outside a `ThemeProvider`. */ -export function useBrandName(): string { - return useContext(ThemeContext)?.brand.name ?? DEFAULT_BRAND_NAME; +/** Configured display name, or the SDK default when no custom images are set. */ +export function useBrandName(): string | undefined { + const brand = useContext(ThemeContext)?.brand; + if (brand?.name != null) return brand.name; + return brand?.icon == null && brand?.logo == null ? DEFAULT_BRAND_NAME : undefined; } /** - * The product mark: `theme.brand.logo` resolved against the active theme mode, - * labelled with the brand name, and linked when the config carries an `href`. - * Falls back to the default mark when no logo is configured. - * - * Callers that also want the name as text render it themselves — the SDK layouts - * pair this with `useBrandName()` so each one controls its own arrangement. + * The product mark resolved against the active theme mode. Compact surfaces use + * `icon`; expanded surfaces use `logo` and fall back to `icon`. */ -export function BrandLogo({ className }: { className?: string }) { +export function BrandLogo({ className, variant = 'icon' }: { className?: string; variant?: 'icon' | 'logo' }) { const theme = useContext(ThemeContext); - const logo = theme?.brand.logo; - const name = theme?.brand.name ?? DEFAULT_BRAND_NAME; - + const brand = theme?.brand; + const name = useBrandName(); const mode = theme?.mode ?? 'light'; - const src = logo == null ? undefined : resolveLogoSrc({ logo, mode }); + const preferredImage = variant === 'logo' ? brand?.logo : brand?.icon; + const preferredSrc = preferredImage == null ? undefined : resolveImageSrc({ image: preferredImage, mode }); + const icon = brand?.icon; + const src = preferredSrc ?? (variant === 'logo' && icon != null ? resolveImageSrc({ image: icon, mode }) : undefined); + if (src == null) { + if (variant === 'logo') { + const Wordmark = mode === 'dark' ? TrueForgeLogoDark : TrueForgeLogoLight; + // svgr pins width/height to 1em; clearing both lets the viewBox aspect + // ratio widen the wordmark to match the caller's height. + return ( + + ); + } return ( ); } - // `name` is the accessible label: the image itself carries no text alternative. - const image = {name}; - const href = typeof logo === 'string' ? undefined : logo?.href; + const image = {name; + const href = brand?.href; if (href == null) return image; return ( diff --git a/packages/trueforge-ui/src/theme/types.ts b/packages/trueforge-ui/src/theme/types.ts index 8cf86c66b..9ac87f630 100644 --- a/packages/trueforge-ui/src/theme/types.ts +++ b/packages/trueforge-ui/src/theme/types.ts @@ -68,30 +68,43 @@ export type IconEntry = IconComponent | ReactNode; export type IconMap = Record; /** - * Logo sources. `light` / `dark` pick per resolved theme mode and fall back to - * each other, then to `src`. `href` wraps the logo in a same-tab link. The logo - * is labelled with `name`; replace the mark itself through the slot table when - * an image URL is not enough. + * Brand image sources. `light` / `dark` pick per resolved theme mode and fall + * back to each other, then to `src`. */ export type BrandLogoConfig = { src?: string; light?: string; dark?: string; - href?: string; }; +export type BrandImage = string | BrandLogoConfig; + /** - * Setting `brand` requires a `name`: it labels the logo, so a logo without one has - * no accessible name. `logo` is optional — omit it to pair host text with the stock - * mark. + * Product branding. `icon` is the square mark used in compact surfaces. `logo` + * is an optional wider mark used in expanded chrome and falls back to `icon`. */ -export type BrandConfig = { - /** Display name, and the logo's accessible label. */ - name: string; - /** Image URL, or per-mode sources. Omit to keep the default mark. */ - logo?: string | BrandLogoConfig; +type BrandMetadata = { + /** Optional display name and accessible label. Omit for icon-only branding. */ + name?: string; + /** Wraps configured brand images in a same-tab link. */ + href?: string; }; +export type BrandConfig = BrandMetadata & + ( + | { + /** Square image URL, or per-mode sources. Omit to keep the default mark. */ + icon?: BrandImage; + logo?: never; + } + | { + /** Square image URL, or per-mode sources, used when compact. */ + icon: BrandImage; + /** Wider image URL, or per-mode sources, used when expanded. */ + logo: BrandImage; + } + ); + export type ContentClassNames = { markdown?: string; inlineCode?: string; diff --git a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx index ec27f2baa..28cc50ec8 100644 --- a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx +++ b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx @@ -304,7 +304,7 @@ describe('StackChatPanel', () => { describe('SidebarLayout', () => { it('shows the app brand in the mobile sessions drawer', () => { render( - +
@@ -320,9 +320,60 @@ describe('SidebarLayout', () => { expect(within(drawer).getByAltText('Acme')).toHaveAttribute('src', '/acme.svg'); }); + it('shows the default wordmark without a name in expanded chrome', () => { + const { container } = render( + + +
+ +
+
+
, + ); + + const mark = container.querySelector('aside svg'); + expect(mark).toHaveAttribute('viewBox', '0 0 614 100'); + expect(screen.queryByText('TrueForge')).not.toBeInTheDocument(); + }); + + it('shows a wide logo when expanded and its square icon when collapsed', () => { + const { container } = render( + + +
+ +
+
+
, + ); + + expect(container.querySelector('aside img')).toHaveAttribute('src', '/acme-wordmark.svg'); + expect(screen.queryByText('Acme')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })); + expect(container.querySelector('aside img')).toHaveAttribute('src', '/acme-icon.svg'); + + fireEvent.click(screen.getByRole('button', { name: 'Expand sidebar' })); + }); + + it('supports icon-only branding in expanded chrome', () => { + const { container } = render( + + +
+ +
+
+
, + ); + + expect(container.querySelector('aside img')).toHaveAttribute('src', '/acme-icon.svg'); + expect(screen.queryByText('TrueForge')).not.toBeInTheDocument(); + }); + it('shows the brand and toggles the desktop sidebar rail', () => { const { unmount } = render( - +
@@ -341,7 +392,7 @@ describe('SidebarLayout', () => { // New Chat / Agents remount ChatProvider via runtimeKey; collapse must survive. unmount(); render( - +
diff --git a/packages/trueforge-ui/test/theme/ThemeProvider.test.tsx b/packages/trueforge-ui/test/theme/ThemeProvider.test.tsx index 4ee9ed938..b59018167 100644 --- a/packages/trueforge-ui/test/theme/ThemeProvider.test.tsx +++ b/packages/trueforge-ui/test/theme/ThemeProvider.test.tsx @@ -138,7 +138,7 @@ describe('ThemeProvider', () => { it('exposes brand config and labels BrandLogo with the brand name', () => { render( - + , diff --git a/packages/trueforge-ui/test/theme/brand.test.tsx b/packages/trueforge-ui/test/theme/brand.test.tsx index 8e6791f18..0319e6e4d 100644 --- a/packages/trueforge-ui/test/theme/brand.test.tsx +++ b/packages/trueforge-ui/test/theme/brand.test.tsx @@ -31,18 +31,30 @@ describe('BrandLogo', () => { expect(icon).toHaveAttribute('viewBox', '0 0 120 120'); }); - it('labels the configured logo with the brand name', () => { + it('labels the configured icon with the brand name', () => { render( - + , ); const logo = screen.getByRole('img', { name: 'Acme' }); - expect(logo).toHaveAttribute('src', '/acme-logo.svg'); + expect(logo).toHaveAttribute('src', '/acme-icon.svg'); expect(logo).toHaveClass('logo-image'); }); + it('renders a configured icon without a visible or accessible name when name is omitted', () => { + render( + + + + , + ); + + expect(screen.getByTestId('brand-name')).toBeEmptyDOMElement(); + expect(screen.getByRole('presentation')).toHaveAttribute('src', '/acme-icon.svg'); + }); + it('renders the default mark and name when brand is omitted', () => { const { container } = render( @@ -56,7 +68,30 @@ describe('BrandLogo', () => { expect(screen.getByTestId('brand-name')).toHaveTextContent('TrueForge'); }); - it('pairs a host name with the default mark when logo is omitted', () => { + it('renders the default wordmark for the logo variant', () => { + const { container, rerender } = render( + + + , + ); + + const light = screen.getByRole('img', { name: 'TrueForge' }); + expect(light).toHaveClass('host-logo', 'w-auto'); + expect(light).toHaveAttribute('viewBox', '0 0 614 100'); + // Sizing must come from the viewBox, not svgr's 1em width/height. + expect(light).not.toHaveAttribute('width'); + expect(light).not.toHaveAttribute('height'); + expect(container.querySelector('svg[aria-hidden="true"]')).toBeNull(); + + rerender( + + + , + ); + expect(screen.getByRole('img', { name: 'TrueForge' })).toHaveAttribute('viewBox', '0 0 737 120'); + }); + + it('pairs a host name with the default mark when icon is omitted', () => { const { container } = render( @@ -69,50 +104,76 @@ describe('BrandLogo', () => { expect(screen.getByTestId('brand-name')).toHaveTextContent('Acme'); }); - it('treats a bare string as an image source', () => { + it('treats a bare string as an icon source', () => { render( - + , ); const img = screen.getByRole('img', { name: 'Acme' }); - expect(img).toHaveAttribute('src', '/acme-logo.svg'); + expect(img).toHaveAttribute('src', '/acme-icon.svg'); expect(img).toHaveClass('logo-image'); }); it('picks the light source in light mode and the dark source in dark mode', () => { - const logo = { light: '/logo/light.svg', dark: '/logo/dark.svg' }; + const icon = { light: '/icon/light.svg', dark: '/icon/dark.svg' }; const { rerender } = render( - + , ); - expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute('src', '/logo/light.svg'); + expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute('src', '/icon/light.svg'); rerender( - + , ); - expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute('src', '/logo/dark.svg'); + expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute('src', '/icon/dark.svg'); }); it('falls back to the other mode when only one source is configured', () => { render( - + , ); - expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute('src', '/logo/light.svg'); + expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute('src', '/icon/light.svg'); + }); + + it('uses the wide logo only for the logo variant', () => { + const { rerender } = render( + + + , + ); + expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute('src', '/acme-icon.svg'); + + rerender( + + + , + ); + expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute('src', '/acme-wordmark.svg'); + }); + + it('falls back to the square icon when the wide logo is omitted', () => { + render( + + + , + ); + + expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute('src', '/acme-icon.svg'); }); it('wraps the logo in a same-tab link when href is set', () => { render( , @@ -121,7 +182,7 @@ describe('BrandLogo', () => { const link = screen.getByRole('link', { name: 'Acme' }); expect(link).toHaveAttribute('href', 'https://trueforge.dev'); expect(link).not.toHaveAttribute('target'); - expect(link.querySelector('img')).toHaveAttribute('src', '/logo/light.svg'); + expect(link.querySelector('img')).toHaveAttribute('src', '/icon/light.svg'); }); it('lets a slot override replace the mark with a component', () => { @@ -130,7 +191,7 @@ describe('BrandLogo', () => { } render( - + , ); @@ -139,9 +200,9 @@ describe('BrandLogo', () => { expect(screen.queryByRole('img', { name: 'Acme' })).toBeNull(); }); - it('falls back to the default mark when a logo config resolves to no source', () => { + it('falls back to the default mark when an icon config resolves to no source', () => { const { container } = render( - + , ); From a470455008e6d822f080452b511434db7f19fb6c Mon Sep 17 00:00:00 2001 From: Govinda Vashishtha <57435703+govindavashishtha@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:18:58 +0530 Subject: [PATCH 2/3] feat(branding): add brand.mode for explicit chrome looks Hosts pick icon-title, icon-only, or logo first; name always labels the mark, and resolveBrandChrome drives sidebar chrome. Co-authored-by: Cursor --- .changeset/brand-icon-wordmark.md | 2 +- docs/ui-sdk/guides/layouts-and-theme.mdx | 36 ++++--- docs/ui-sdk/reference/theme.mdx | 5 +- docs/ui-sdk/reference/trueforge-ui.mdx | 21 ++-- packages/trueforge-ui/CHANGELOG.md | 19 ++++ packages/trueforge-ui/README.md | 40 +++++-- packages/trueforge-ui/docs/customization.md | 2 +- packages/trueforge-ui/docs/server.md | 1 + packages/trueforge-ui/docs/theming.md | 52 ++++----- packages/trueforge-ui/src/index.ts | 4 +- .../src/layouts/SidebarLayout.tsx | 20 ++-- packages/trueforge-ui/src/theme/brand.tsx | 38 +++++-- packages/trueforge-ui/src/theme/types.ts | 60 +++++++---- .../test/containers/TrueForgeUI.test.tsx | 23 ++-- .../trueforge-ui/test/publicUiExports.test.ts | 1 + .../test/theme/ThemeProvider.test.tsx | 2 +- .../trueforge-ui/test/theme/brand.test.tsx | 101 ++++++++++++++---- 17 files changed, 303 insertions(+), 124 deletions(-) diff --git a/.changeset/brand-icon-wordmark.md b/.changeset/brand-icon-wordmark.md index b35cea3b2..a4e82360a 100644 --- a/.changeset/brand-icon-wordmark.md +++ b/.changeset/brand-icon-wordmark.md @@ -3,4 +3,4 @@ "@truefoundry/trueforge": patch --- -Support icon-only branding, square icons with display names, and optional wide logos that fall back to the square icon in compact chrome. The default expanded mark is the TrueForge wordmark. +Add `brand.mode` (`icon-title` | `icon-only` | `logo`) so hosts pick chrome look first; `name` always labels the mark, and `resolveBrandChrome` maps mode to layout chrome. diff --git a/docs/ui-sdk/guides/layouts-and-theme.mdx b/docs/ui-sdk/guides/layouts-and-theme.mdx index cba838f45..529749af4 100644 --- a/docs/ui-sdk/guides/layouts-and-theme.mdx +++ b/docs/ui-sdk/guides/layouts-and-theme.mdx @@ -65,13 +65,12 @@ Choose from four built-in themes - Claude, ChatGPT, Gemini, and TrueForge, or cr ```ts type BrandImage = string | { src?: string; light?: string; dark?: string }; -type BrandConfig = { - name?: string; - href?: string; -} & ( - | { icon?: BrandImage; logo?: never } - | { icon: BrandImage; logo: BrandImage } -); +type BrandMode = "icon-title" | "icon-only" | "logo"; + +type BrandConfig = + | { mode: "icon-title"; name: string; icon?: BrandImage; logo?: never; href?: string } + | { mode: "icon-only"; name: string; icon: BrandImage; logo?: never; href?: string } + | { mode: "logo"; name: string; icon: BrandImage; logo: BrandImage; href?: string }; type ThemeConfig = { preset?: "trueforge" | "claude" | "chatgpt" | "gemini"; // default "trueforge" @@ -81,6 +80,15 @@ type ThemeConfig = { }; ``` +Set `brand.mode`, then pass the fields that mode requires. `name` always labels the mark. + +| Look | `mode` | Required | Expanded | Collapsed | +| --- | --- | --- | --- | --- | +| Default | omit `brand` | — | TrueForge wordmark | TrueForge square | +| Icon + title | `"icon-title"` | `name` (+ optional `icon`) | square + title | square | +| Icon only | `"icon-only"` | `name`, `icon` | square | square | +| Wide logo | `"logo"` | `name`, `icon`, `logo` | wide logo | square | + ```tsx App.tsx ``` -`icon` is the square asset used in collapsed and compact surfaces. `logo` is an optional -wider asset used in expanded chrome and requires `icon` as its compact fallback. Omit -`name` and `logo` for icon-only branding. When `brand` is omitted, expanded chrome uses -the built-in TrueForge wordmark and compact surfaces use the built-in square mark. +`icon` is the square asset used in collapsed and compact surfaces. `logo` is the wider +asset used when `mode` is `"logo"` and requires `icon` as its compact fallback. When +`brand` is omitted, expanded chrome uses the built-in TrueForge wordmark and compact +surfaces use the built-in square mark. The `chatgpt`, `claude`, and `gemini` themes are shown below: @@ -137,6 +146,7 @@ You can also provide separate logos for light and dark modes, allowing your bran theme={{ ... brand: { + mode: "logo", name: "Acme", icon: { light: "/icon/light.svg", @@ -157,8 +167,8 @@ The icon and logo sources are selected based on the resolved theme mode. When mo * Provide both light and dark to use a different image for each mode. * Provide only one of light or dark to use the same image in both modes. * Use src for a mode-independent image. -* `name` labels configured images and appears beside `icon` when no wide `logo` is set. -* Omit `name` for icon-only branding. +* Set `brand.mode` first (`icon-title` | `icon-only` | `logo`); `name` is required and always labels images. +* Visible title text only appears for `mode: "icon-title"`. * Set the brand-level `href` to make configured images clickable. For more advanced use cases, such as rendering an inline SVG or an animated logo, override the BrandLogo slot instead of passing a React node through the theme object. diff --git a/docs/ui-sdk/reference/theme.mdx b/docs/ui-sdk/reference/theme.mdx index f70b9e254..e7225ebf4 100644 --- a/docs/ui-sdk/reference/theme.mdx +++ b/docs/ui-sdk/reference/theme.mdx @@ -20,6 +20,7 @@ description: "Theme provider, hooks, presets, brand, and slots." | `useThemeMode()` | Resolved `"light" \| "dark"`. Reads the mode published by `SlotsProvider`, so it returns `"light"` when no `SlotsProvider` is above it — even in dark mode. | | `useBrand()` | The active `BrandConfig`. | | `useBrandName()` | The configured name; `undefined` for unnamed custom branding; or `"TrueForge"` when no custom image is configured. Safe outside a provider. | +| `resolveBrandChrome(brand)` | `{ expandedVariant, collapsedVariant, showTitle }` from `brand.mode`. Prefer this over re-deriving field combinations. | | `useThemeIcons()` | The `IconMap` from `theme.icons`. | | `useContentClassNames()` | `theme.classNames`; throws outside a provider. | | `useOptionalContentClassNames()` | Same, but returns `{}` outside a provider instead of throwing — no null check needed. | @@ -35,8 +36,8 @@ description: "Theme provider, hooks, presets, brand, and slots." ## Types -`ThemeConfig`, `ThemeMode`, `ThemePreset`, `SemanticTokens`, `BrandConfig`, `BrandImage`, `BrandLogoConfig`, -`ContentClassNames`, `IconMap`, `ThemeIconProps`, `IconProps`, `LayoutProp`, `AtomSlots`, +`ThemeConfig`, `ThemeMode`, `ThemePreset`, `SemanticTokens`, `BrandConfig`, `BrandMode`, `BrandImage`, `BrandLogoConfig`, +`BrandChrome`, `ContentClassNames`, `IconMap`, `ThemeIconProps`, `IconProps`, `LayoutProp`, `AtomSlots`, `SlotOverrides`. ```ts diff --git a/docs/ui-sdk/reference/trueforge-ui.mdx b/docs/ui-sdk/reference/trueforge-ui.mdx index 1e58f9735..7a00849cd 100644 --- a/docs/ui-sdk/reference/trueforge-ui.mdx +++ b/docs/ui-sdk/reference/trueforge-ui.mdx @@ -133,20 +133,25 @@ import { TrueForgeUI } from "@truefoundry/trueforge-ui"; Optional. Omit it to use the built-in TrueForge wordmark in expanded chrome and - square mark in compact surfaces. + square mark in compact surfaces. When set, choose `mode` first, then pass the + fields that mode requires. + + Chrome look. `icon-title` shows `name` beside the square mark; `icon-only` + and `logo` keep `name` for alt only. + - Optional display name and accessible image label. It appears beside the square - icon in expanded chrome when no wide logo is configured. Omit it for icon-only - branding. + Required. Accessible image label (`alt` / `aria-label`). Also shown as title + text beside the square icon when `mode` is `icon-title`. - Square image used in collapsed and compact surfaces. A logo configuration - requires this compact fallback. + Square image used in collapsed and compact surfaces. Optional for + `icon-title` (default mark); required for `icon-only` and `logo`. - Optional wider image used in expanded chrome. Falls back to `icon`. + Wider image used when `mode` is `logo`. Falls back to `icon` in + `BrandLogo` if needed. Required for `mode: "logo"`. Wraps configured brand images in a same-tab link. @@ -247,7 +252,7 @@ Persisted sessions remain available through the thread list. agentConfig={{ mode: "AgentComposer", defaultAgentSpec: { model: { name: "openai-main/gpt-4.1" } } }} theme={{ preset: "claude", - brand: { name: "Acme", icon: "/icon.svg", logo: "/wordmark.svg" }, + brand: { mode: "logo", name: "Acme", icon: "/icon.svg", logo: "/wordmark.svg" }, }} overrides={{ ClearChatButton: MyClearButton }} className="h-full min-h-0" diff --git a/packages/trueforge-ui/CHANGELOG.md b/packages/trueforge-ui/CHANGELOG.md index 7f008bf30..d6c69d0e2 100644 --- a/packages/trueforge-ui/CHANGELOG.md +++ b/packages/trueforge-ui/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased + +### Minor Changes + +- **`brand.mode`** — explicit chrome look. Set `mode`, then pass the fields it requires. + `name` always labels the mark (`alt` / `aria-label`): + - **`'icon-title'`** — `name` + optional `icon` (title shown in expanded chrome) + - **`'icon-only'`** — `name` + `icon` (alt kept, no title text) + - **`'logo'`** — `name` + `icon` + `logo` (wordmark in expanded chrome; `name` is alt only) + - **Default** — omit `brand` for the TrueForge wordmark / square mark +- **`resolveBrandChrome()`** — maps `brand.mode` to + `{ expandedVariant, collapsedVariant, showTitle }`. `SidebarLayout` uses it; custom + layouts should too. + +### Changed + +- **`BrandConfig`** — discriminated on `mode` (`BrandMode`). Removed `showTitle` from + config; visible title follows the mode. + ## 0.2.4 ### Patch Changes diff --git a/packages/trueforge-ui/README.md b/packages/trueforge-ui/README.md index 184c5293b..838d6e169 100644 --- a/packages/trueforge-ui/README.md +++ b/packages/trueforge-ui/README.md @@ -207,7 +207,7 @@ export default function App() { }} theme={{ preset: 'claude', - brand: { name: 'Acme', icon: '/icon.svg' }, + brand: { mode: 'icon-title', name: 'Acme', icon: '/icon.svg' }, }} overrides={{/* slot overrides */}} className="h-full" @@ -319,7 +319,15 @@ package). Host CSS on `.aui-markdown` / `.aui-syntax-highlighter` / `.aui-openui ## Brand / logo -**Base layouts** — pass a square `icon` and optional display `name`. Omit `name` for icon-only branding: +Set `brand.mode`, then pass the fields that mode requires. `name` always labels the +mark (`alt` / `aria-label`). + +| Look | `mode` | Required | Expanded chrome | Collapsed / compact | +| ------------ | -------------- | -------------------------- | ------------------------- | ------------------- | +| Default | omit `brand` | — | TrueForge wordmark | TrueForge square | +| Icon + title | `'icon-title'` | `name` (+ optional `icon`) | square + title text | square | +| Icon only | `'icon-only'` | `name`, `icon` | square (no title text) | square | +| Wide logo | `'logo'` | `name`, `icon`, `logo` | wide logo (no title text) | square | ```tsx ``` -Add a wider `logo` for expanded chrome. A square `icon` is required with it because collapsed -chrome, the welcome screen, and the widget button continue to use the square asset: +Icon-only chrome (`name` kept for alt): + +```tsx +theme={{ + brand: { + mode: 'icon-only', + name: 'Acme', + icon: '/brand/icon.svg', + }, +}} +``` + +Wide logo for expanded chrome. A square `icon` is required because collapsed chrome, +the welcome screen, and the widget button continue to use the square asset: ```tsx _Screenshot: external brand mark rendered in the base layout header._ @@ -591,6 +614,7 @@ See [docs/server.md](./docs/server.md) for the full method list and BYO guidance | `TrueForgeServerConfig` | Type | `server` prop: `truefoundry` / `trueforge` / `AgentUIServer` | | `createTrueFoundryServer` | Function | Compose chat + builder into `AgentUIServer` | | `Thread`, `ThreadListContainer`, `BrandLogo` | Components | Layout primitives for custom layouts | +| `resolveBrandChrome`, `useBrandName`, `useBrand` | Helpers | Brand chrome look + name for custom layouts | | Composer / message / tool atoms | Components | Overridable, themeable building blocks | | `SlotsProvider`, `useSlot`, `useTheme` | API | Overrides + theme mode | | `AgentUIServer`, `AgentChatServer`, `AgentBuilderServer` | Types | Resolved server contract | diff --git a/packages/trueforge-ui/docs/customization.md b/packages/trueforge-ui/docs/customization.md index 1a942ea66..85392bb6b 100644 --- a/packages/trueforge-ui/docs/customization.md +++ b/packages/trueforge-ui/docs/customization.md @@ -29,7 +29,7 @@ over hacking third-party CSS: preset: 'claude', mode: 'dark', tokens: { primary: '#e11d48' }, - brand: { name: 'Acme', icon: '/brand/icon.svg', logo: '/brand/wordmark.svg' }, + brand: { mode: 'logo', name: 'Acme', icon: '/brand/icon.svg', logo: '/brand/wordmark.svg' }, icons: { send: MySendSvg }, classNames: { markdown: 'prose max-w-none', diff --git a/packages/trueforge-ui/docs/server.md b/packages/trueforge-ui/docs/server.md index e3aa3ba0e..db627d587 100644 --- a/packages/trueforge-ui/docs/server.md +++ b/packages/trueforge-ui/docs/server.md @@ -803,6 +803,7 @@ export function App() { theme={{ preset: 'chatgpt', brand: { + mode: 'logo', name: 'MyCo', icon: { src: '/myco-icon.svg' }, logo: { src: '/myco-wordmark.svg' }, diff --git a/packages/trueforge-ui/docs/theming.md b/packages/trueforge-ui/docs/theming.md index 83aca746b..c5a22e703 100644 --- a/packages/trueforge-ui/docs/theming.md +++ b/packages/trueforge-ui/docs/theming.md @@ -190,27 +190,24 @@ type BrandImage = string | BrandLogoConfig; * Images only: to render a component, override the `BrandLogo` slot instead, so * brand marks follow the same replacement path as every other atom. * - * `icon` is the square mark for compact surfaces. `logo` is the optional wider - * mark for expanded chrome and falls back to `icon`. + * Set `mode`, then pass the fields that mode requires: + * - omit `brand`: TrueForge wordmark / square mark + * - `icon-title`: `name` + optional `icon` + * - `icon-only`: `name` + `icon` + * - `logo`: `name` + `icon` + `logo` */ -type BrandMetadata = { - /** Optional display name and accessible label. Omit for icon-only branding. */ - name?: string; - /** Wraps configured brand images in a same-tab link. */ - href?: string; -}; +type BrandMode = "icon-title" | "icon-only" | "logo"; -type BrandConfig = BrandMetadata & - ( - | { icon?: BrandImage; logo?: never } - | { icon: BrandImage; logo: BrandImage } - ); +type BrandConfig = + | { mode: "icon-title"; name: string; icon?: BrandImage; logo?: never; href?: string } + | { mode: "icon-only"; name: string; icon: BrandImage; logo?: never; href?: string } + | { mode: "logo"; name: string; icon: BrandImage; logo: BrandImage; href?: string }; type ThemeConfig = { preset?: ThemePreset; // default: "trueforge" mode?: ThemeMode; // omit = uncontrolled (useTheme().setTheme) tokens?: Partial; - brand?: BrandConfig; // square icon + optional wide logo and display name + brand?: BrandConfig; // set brand.mode, then required fields className?: string; // applied on .aui-root (or theme root) icons?: IconMap; // full/partial UI icon replace + SVG transforms /** Per-surface className hooks for content renderers */ @@ -275,6 +272,7 @@ function MyLayout({ className }: { className?: string }) { mode: 'dark', tokens: { primaryButtonBg: '#…', fontFamily: '"My Font", system-ui' }, brand: { + mode: 'logo', name: 'Acme Agent', icon: { light: '/acme-icon.svg', dark: '/acme-icon-dark.svg' }, logo: { light: '/acme-wordmark.svg', dark: '/acme-wordmark-dark.svg' }, @@ -333,22 +331,26 @@ as `children`. 1. All product marks render through `` — one component, never hard-coded TFY assets in layouts or atoms. Layouts resolve it via `useSlot`, so a host override reaches every call site. -2. `BrandLogo` renders `icon` for compact surfaces. With `variant="logo"`, it +2. Hosts pick chrome with `brand.mode` (`icon-title` | `icon-only` | `logo`). + Layout chrome uses `resolveBrandChrome(brand)` for expanded/collapsed mark + variant and whether to show the text title. Custom layouts should call the + same helper instead of re-deriving fields. +3. `BrandLogo` renders `icon` for compact surfaces. With `variant="logo"`, it renders the optional wider logo and falls back to the square icon. -3. `theme.brand` carries **image sources only** (URL or `{ src, light, dark }`). +4. `theme.brand` carries **image sources only** (URL or `{ src, light, dark }`). Component-valued marks go through the `BrandLogo` slot, so brand replacement uses the same mechanism as every other atom instead of a second node-shaped escape hatch in the theme config. -4. `theme.brand.name` is optional. When present, it labels configured images and - appears beside the square icon in expanded chrome when no wide logo is set. - Omitting it enables icon-only branding. -5. Omitting `brand` keeps the default TrueForge wordmark in expanded chrome and +5. `theme.brand.name` is required with every mode and always labels configured + images. Visible title text only appears for `mode: 'icon-title'`. For + `icon-only` and `logo`, `name` is alt-only. +6. Omitting `brand` keeps the default TrueForge wordmark in expanded chrome and the square mark when collapsed. `href` wraps configured images in a same-tab link. -6. `{ light, dark }` sources resolve against the provider's mode, so a host mark - tracks light/dark without a custom component. A single configured mode covers - both, so `{ light }` alone never renders a missing image; a config with no - usable source falls back to the default mark rather than an empty ``. +7. `{ light, dark }` sources resolve against the provider's theme mode, so a host + mark tracks light/dark without a custom component. A single configured source + covers both, so `{ light }` alone never renders a missing image; a config with + no usable source falls back to the default mark rather than an empty ``. ## Icon system @@ -382,7 +384,7 @@ compose path), for example: - `Thread` / `ThreadContainer` - `ThreadListContainer` - `Composer` pieces / slot-backed atoms as needed -- Brand helpers (`BrandLogo`, `useBrandName`) and `useTheme` +- Brand helpers (`BrandLogo`, `resolveBrandChrome`, `useBrandName`) and `useTheme` The shell still wraps the custom layout with theme + slots + chat provider; only the chrome tree is replaced. Equivalent to skipping built-in layouts diff --git a/packages/trueforge-ui/src/index.ts b/packages/trueforge-ui/src/index.ts index 4bf02eed0..ca2f6ccbb 100644 --- a/packages/trueforge-ui/src/index.ts +++ b/packages/trueforge-ui/src/index.ts @@ -2,7 +2,8 @@ import './icons/registerAgentIcons.js'; -export { BrandLogo, useBrandName } from './theme/brand.js'; +export { BrandLogo, resolveBrandChrome, useBrandName } from './theme/brand.js'; +export type { BrandChrome } from './theme/brand.js'; export { defaultSlots } from './theme/defaultSlots.js'; export { PRESETS, resolvePresetTokens } from './theme/presets/index.js'; export type { PublicAtomSlots as AtomSlots, SlotOverrides } from './theme/publicSlots.js'; @@ -19,6 +20,7 @@ export type { BrandConfig, BrandImage, BrandLogoConfig, + BrandMode, ContentClassNames, IconMap, LayoutProp, diff --git a/packages/trueforge-ui/src/layouts/SidebarLayout.tsx b/packages/trueforge-ui/src/layouts/SidebarLayout.tsx index 075c4d5d1..c20c31270 100644 --- a/packages/trueforge-ui/src/layouts/SidebarLayout.tsx +++ b/packages/trueforge-ui/src/layouts/SidebarLayout.tsx @@ -13,7 +13,7 @@ import { ThreadListContainer } from '../containers/ThreadListContainer.js'; import { useChatHeaderContentVisible } from '../hooks/useChatChromeActionsVisible.js'; import { Icon } from '../icons/Icon.js'; import { useOptionalShellMode } from '../server/ShellModeContext.js'; -import { useBrandName } from '../theme/brand.js'; +import { resolveBrandChrome, useBrandName } from '../theme/brand.js'; import { useSlot } from '../theme/SlotsProvider.js'; import { useBrand } from '../theme/ThemeProvider.js'; @@ -27,7 +27,7 @@ export function SidebarLayout({ className }: { className?: string }) { const shell = useOptionalShellMode(); const brand = useBrand(); const brandName = useBrandName(); - const hasWideLogo = brand.logo != null || (brand.icon == null && brand.name == null); + const chrome = resolveBrandChrome(brand); const BrandLogo = useSlot('BrandLogo'); const AgentsLibraryButton = useSlot('AgentsLibraryButton'); const ClearChatButton = useSlot('ClearChatButton'); @@ -97,10 +97,13 @@ export function SidebarLayout({ className }: { className?: string }) { >
- {!collapsed && !hasWideLogo && brandName != null ? ( + {!collapsed && chrome.showTitle && brandName != null ? ( {brandName} ) : null}
@@ -223,8 +226,11 @@ export function SidebarLayout({ className }: { className?: string }) { tabIndex={-1} >
- - {!hasWideLogo && brandName != null ? ( + + {chrome.showTitle && brandName != null ? ( {brandName} ) : null}
diff --git a/packages/trueforge-ui/src/theme/brand.tsx b/packages/trueforge-ui/src/theme/brand.tsx index e8b342a04..af93c7b56 100644 --- a/packages/trueforge-ui/src/theme/brand.tsx +++ b/packages/trueforge-ui/src/theme/brand.tsx @@ -7,10 +7,35 @@ import { Icon } from '../icons/Icon.js'; import TrueForgeLogoDark from '../icons/trueforge-logo-dark.svg'; import TrueForgeLogoLight from '../icons/trueforge-logo.svg'; import { ThemeContext } from './ThemeProvider.js'; -import type { BrandLogoConfig } from './types.js'; +import type { BrandConfig, BrandLogoConfig } from './types.js'; const DEFAULT_BRAND_NAME = 'TrueForge'; +/** Expanded / collapsed chrome choices derived from `theme.brand.mode`. */ +export type BrandChrome = { + expandedVariant: 'icon' | 'logo'; + collapsedVariant: 'icon'; + /** Whether to render `name` as visible text beside the mark in expanded chrome. */ + showTitle: boolean; +}; + +/** + * Maps `brand.mode` to layout chrome. Layouts should not re-derive field combinations. + */ +export function resolveBrandChrome(brand: Partial | undefined): BrandChrome { + switch (brand?.mode) { + case 'logo': + return { expandedVariant: 'logo', collapsedVariant: 'icon', showTitle: false }; + case 'icon-only': + return { expandedVariant: 'icon', collapsedVariant: 'icon', showTitle: false }; + case 'icon-title': + return { expandedVariant: 'icon', collapsedVariant: 'icon', showTitle: true }; + default: + // Omit `brand` (or incomplete Partial) → default TrueForge wordmark. + return { expandedVariant: 'logo', collapsedVariant: 'icon', showTitle: false }; + } +} + /** Mode-matched source, falling back to the other mode then the mode-agnostic `src`. */ function resolveImageSrc({ image, @@ -24,11 +49,11 @@ function resolveImageSrc({ return preferred ?? image.light ?? image.dark ?? image.src; } -/** Configured display name, or the SDK default when no custom images are set. */ +/** Configured display name, or the SDK default when no custom brand is set. */ export function useBrandName(): string | undefined { const brand = useContext(ThemeContext)?.brand; if (brand?.name != null) return brand.name; - return brand?.icon == null && brand?.logo == null ? DEFAULT_BRAND_NAME : undefined; + return brand?.mode == null ? DEFAULT_BRAND_NAME : undefined; } /** @@ -39,6 +64,7 @@ export function BrandLogo({ className, variant = 'icon' }: { className?: string; const theme = useContext(ThemeContext); const brand = theme?.brand; const name = useBrandName(); + const label = name ?? DEFAULT_BRAND_NAME; const mode = theme?.mode ?? 'light'; const preferredImage = variant === 'logo' ? brand?.logo : brand?.icon; const preferredSrc = preferredImage == null ? undefined : resolveImageSrc({ image: preferredImage, mode }); @@ -56,7 +82,7 @@ export function BrandLogo({ className, variant = 'icon' }: { className?: string; width={undefined} height={undefined} role="img" - aria-label={name ?? DEFAULT_BRAND_NAME} + aria-label={label} /> ); } @@ -65,12 +91,12 @@ export function BrandLogo({ className, variant = 'icon' }: { className?: string; ); } - const image = {name; + const image = {label}; const href = brand?.href; if (href == null) return image; return ( - + {image} ); diff --git a/packages/trueforge-ui/src/theme/types.ts b/packages/trueforge-ui/src/theme/types.ts index 9ac87f630..5ef3c01bf 100644 --- a/packages/trueforge-ui/src/theme/types.ts +++ b/packages/trueforge-ui/src/theme/types.ts @@ -79,31 +79,45 @@ export type BrandLogoConfig = { export type BrandImage = string | BrandLogoConfig; +/** Chrome look for `theme.brand`. Omit `brand` entirely for the default TrueForge marks. */ +export type BrandMode = 'icon-title' | 'icon-only' | 'logo'; + /** - * Product branding. `icon` is the square mark used in compact surfaces. `logo` - * is an optional wider mark used in expanded chrome and falls back to `icon`. + * Product branding. Set `mode`, then pass the fields that mode requires. + * `name` always labels the mark (`alt` / `aria-label`). */ -type BrandMetadata = { - /** Optional display name and accessible label. Omit for icon-only branding. */ - name?: string; - /** Wraps configured brand images in a same-tab link. */ - href?: string; -}; - -export type BrandConfig = BrandMetadata & - ( - | { - /** Square image URL, or per-mode sources. Omit to keep the default mark. */ - icon?: BrandImage; - logo?: never; - } - | { - /** Square image URL, or per-mode sources, used when compact. */ - icon: BrandImage; - /** Wider image URL, or per-mode sources, used when expanded. */ - logo: BrandImage; - } - ); +export type BrandConfig = + | { + mode: 'icon-title'; + /** Accessible label and visible title beside the square mark in expanded chrome. */ + name: string; + /** Square image, or per-mode sources. Omit to keep the default mark. */ + icon?: BrandImage; + logo?: never; + /** Wraps configured brand images in a same-tab link. */ + href?: string; + } + | { + mode: 'icon-only'; + /** Accessible label only — no title text in expanded chrome. */ + name: string; + /** Square image, or per-mode sources. */ + icon: BrandImage; + logo?: never; + /** Wraps configured brand images in a same-tab link. */ + href?: string; + } + | { + mode: 'logo'; + /** Accessible label only — wide logo replaces the text title in expanded chrome. */ + name: string; + /** Square image, or per-mode sources, used when compact. */ + icon: BrandImage; + /** Wider image, or per-mode sources, used when expanded. */ + logo: BrandImage; + /** Wraps configured brand images in a same-tab link. */ + href?: string; + }; export type ContentClassNames = { markdown?: string; diff --git a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx index 28cc50ec8..a020840e6 100644 --- a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx +++ b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx @@ -304,7 +304,7 @@ describe('StackChatPanel', () => { describe('SidebarLayout', () => { it('shows the app brand in the mobile sessions drawer', () => { render( - +
@@ -338,7 +338,9 @@ describe('SidebarLayout', () => { it('shows a wide logo when expanded and its square icon when collapsed', () => { const { container } = render( - +
@@ -358,7 +360,7 @@ describe('SidebarLayout', () => { it('supports icon-only branding in expanded chrome', () => { const { container } = render( - +
@@ -368,12 +370,13 @@ describe('SidebarLayout', () => { ); expect(container.querySelector('aside img')).toHaveAttribute('src', '/acme-icon.svg'); - expect(screen.queryByText('TrueForge')).not.toBeInTheDocument(); + expect(container.querySelector('aside img')).toHaveAttribute('alt', 'Acme'); + expect(screen.queryByText('Acme')).not.toBeInTheDocument(); }); it('shows the brand and toggles the desktop sidebar rail', () => { const { unmount } = render( - +
@@ -392,7 +395,7 @@ describe('SidebarLayout', () => { // New Chat / Agents remount ChatProvider via runtimeKey; collapse must survive. unmount(); render( - +
@@ -405,7 +408,7 @@ describe('SidebarLayout', () => { it('toggles theme from the footer and shows settings only when catalog is provided', async () => { const { rerender } = render( - + @@ -433,7 +436,7 @@ describe('SidebarLayout', () => { // Settings stays available with a locked agentName when catalog is present. rerender( - + @@ -455,7 +458,7 @@ describe('SidebarLayout', () => { expect(await screen.findByRole('heading', { name: 'Settings' })).toBeInTheDocument(); rerender( - + { // No catalog → no Settings button. rerender( - + diff --git a/packages/trueforge-ui/test/publicUiExports.test.ts b/packages/trueforge-ui/test/publicUiExports.test.ts index 249a0e2c7..aeb910515 100644 --- a/packages/trueforge-ui/test/publicUiExports.test.ts +++ b/packages/trueforge-ui/test/publicUiExports.test.ts @@ -110,6 +110,7 @@ const expectedRuntimeExports: Array = [ 'libraryAgentId', 'mergeAgentSpec', 'preloadMarkdownOpenUI', + 'resolveBrandChrome', 'resolvePresetTokens', 'shellIsMutable', 'threadHasPendingMcpAuth', diff --git a/packages/trueforge-ui/test/theme/ThemeProvider.test.tsx b/packages/trueforge-ui/test/theme/ThemeProvider.test.tsx index b59018167..089a83cc5 100644 --- a/packages/trueforge-ui/test/theme/ThemeProvider.test.tsx +++ b/packages/trueforge-ui/test/theme/ThemeProvider.test.tsx @@ -138,7 +138,7 @@ describe('ThemeProvider', () => { it('exposes brand config and labels BrandLogo with the brand name', () => { render( - + , diff --git a/packages/trueforge-ui/test/theme/brand.test.tsx b/packages/trueforge-ui/test/theme/brand.test.tsx index 0319e6e4d..d00d800e7 100644 --- a/packages/trueforge-ui/test/theme/brand.test.tsx +++ b/packages/trueforge-ui/test/theme/brand.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { WelcomeScreen } from '@/atoms/WelcomeScreen.js'; -import { BrandLogo, useBrandName } from '@/theme/brand.js'; +import { BrandLogo, resolveBrandChrome, useBrandName } from '@/theme/brand.js'; import { SlotsProvider } from '@/theme/SlotsProvider.js'; import { ThemeProvider } from '@/theme/ThemeProvider.js'; @@ -10,6 +10,53 @@ function BrandNameProbe() { return {useBrandName()}; } +describe('resolveBrandChrome', () => { + it('uses the default wordmark when brand.mode is omitted', () => { + expect(resolveBrandChrome(undefined)).toEqual({ + expandedVariant: 'logo', + collapsedVariant: 'icon', + showTitle: false, + }); + expect(resolveBrandChrome({})).toEqual({ + expandedVariant: 'logo', + collapsedVariant: 'icon', + showTitle: false, + }); + }); + + it('shows icon + title for mode icon-title', () => { + expect(resolveBrandChrome({ mode: 'icon-title', name: 'Acme', icon: '/icon.svg' })).toEqual({ + expandedVariant: 'icon', + collapsedVariant: 'icon', + showTitle: true, + }); + }); + + it('hides the text title for mode icon-only', () => { + expect(resolveBrandChrome({ mode: 'icon-only', name: 'Acme', icon: '/icon.svg' })).toEqual({ + expandedVariant: 'icon', + collapsedVariant: 'icon', + showTitle: false, + }); + }); + + it('uses the wide logo for mode logo', () => { + expect(resolveBrandChrome({ mode: 'logo', name: 'Acme', icon: '/icon.svg', logo: '/wordmark.svg' })).toEqual({ + expandedVariant: 'logo', + collapsedVariant: 'icon', + showTitle: false, + }); + }); + + it('pairs a host name with the default mark in icon-title without icon', () => { + expect(resolveBrandChrome({ mode: 'icon-title', name: 'Acme' })).toEqual({ + expandedVariant: 'icon', + collapsedVariant: 'icon', + showTitle: true, + }); + }); +}); + describe('BrandLogo', () => { it('renders the default mark without a provider', () => { const { container } = render(); @@ -33,7 +80,7 @@ describe('BrandLogo', () => { it('labels the configured icon with the brand name', () => { render( - + , ); @@ -43,16 +90,16 @@ describe('BrandLogo', () => { expect(logo).toHaveClass('logo-image'); }); - it('renders a configured icon without a visible or accessible name when name is omitted', () => { + it('keeps the brand name as alt for icon-only chrome', () => { render( - + , ); - expect(screen.getByTestId('brand-name')).toBeEmptyDOMElement(); - expect(screen.getByRole('presentation')).toHaveAttribute('src', '/acme-icon.svg'); + expect(screen.getByTestId('brand-name')).toHaveTextContent('Acme'); + expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute('src', '/acme-icon.svg'); }); it('renders the default mark and name when brand is omitted', () => { @@ -93,7 +140,7 @@ describe('BrandLogo', () => { it('pairs a host name with the default mark when icon is omitted', () => { const { container } = render( - + , @@ -106,7 +153,7 @@ describe('BrandLogo', () => { it('treats a bare string as an icon source', () => { render( - + , ); @@ -120,14 +167,14 @@ describe('BrandLogo', () => { const icon = { light: '/icon/light.svg', dark: '/icon/dark.svg' }; const { rerender } = render( - + , ); expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute('src', '/icon/light.svg'); rerender( - + , ); @@ -136,7 +183,9 @@ describe('BrandLogo', () => { it('falls back to the other mode when only one source is configured', () => { render( - + , ); @@ -145,24 +194,30 @@ describe('BrandLogo', () => { }); it('uses the wide logo only for the logo variant', () => { + const brand = { + mode: 'logo' as const, + name: 'Acme', + icon: '/acme-icon.svg', + logo: '/acme-wordmark.svg', + }; const { rerender } = render( - + , ); expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute('src', '/acme-icon.svg'); rerender( - + , ); expect(screen.getByRole('img', { name: 'Acme' })).toHaveAttribute('src', '/acme-wordmark.svg'); }); - it('falls back to the square icon when the wide logo is omitted', () => { + it('falls back to the square icon when the wide logo is omitted from BrandLogo logo variant', () => { render( - + , ); @@ -173,7 +228,14 @@ describe('BrandLogo', () => { it('wraps the logo in a same-tab link when href is set', () => { render( , @@ -191,7 +253,10 @@ describe('BrandLogo', () => { } render( - + , ); @@ -202,7 +267,7 @@ describe('BrandLogo', () => { it('falls back to the default mark when an icon config resolves to no source', () => { const { container } = render( - + , ); From a072f27b4077be4733297fe66e1b256fcf247fc2 Mon Sep 17 00:00:00 2001 From: Govinda Vashishtha <57435703+govindavashishtha@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:17:34 +0530 Subject: [PATCH 3/3] refactor(ui): dedupe BrandLogo class names in SidebarLayout Extract shared logo sizing classes into a module constant used by both desktop and mobile sidebar headers. Co-authored-by: Cursor --- packages/trueforge-ui/src/layouts/SidebarLayout.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/trueforge-ui/src/layouts/SidebarLayout.tsx b/packages/trueforge-ui/src/layouts/SidebarLayout.tsx index c20c31270..c34c6f1a2 100644 --- a/packages/trueforge-ui/src/layouts/SidebarLayout.tsx +++ b/packages/trueforge-ui/src/layouts/SidebarLayout.tsx @@ -22,6 +22,8 @@ const TruefoundrySettingsBuilder = lazy(() => import('../containers/SettingsBuil // Survives ChatProvider remounts when openDraft / selectAgent bumps runtimeKey. let desktopCollapsed = false; +const brandLogoClassName = 'h-5 max-w-40 shrink-0 object-contain'; + export function SidebarLayout({ className }: { className?: string }) { const aui = useAui(); const shell = useOptionalShellMode(); @@ -98,10 +100,7 @@ export function SidebarLayout({ className }: { className?: string }) {
{!collapsed && chrome.showTitle && brandName != null ? ( {brandName} @@ -228,7 +227,7 @@ export function SidebarLayout({ className }: { className?: string }) {
{chrome.showTitle && brandName != null ? ( {brandName}