diff --git a/.changeset/brand-icon-wordmark.md b/.changeset/brand-icon-wordmark.md new file mode 100644 index 000000000..a4e82360a --- /dev/null +++ b/.changeset/brand-icon-wordmark.md @@ -0,0 +1,6 @@ +--- +"@truefoundry/trueforge-ui": minor +"@truefoundry/trueforge": patch +--- + +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 34bddbbec..529749af4 100644 --- a/docs/ui-sdk/guides/layouts-and-theme.mdx +++ b/docs/ui-sdk/guides/layouts-and-theme.mdx @@ -58,22 +58,37 @@ 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 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" mode?: "light" | "dark" | "system"; - brand?: { - name: string; // required when `brand` is set - logo?: string | BrandLogoConfig; - }, + brand?: BrandConfig; ... }; ``` +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 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: @@ -124,24 +146,30 @@ You can also provide separate logos for light and dark modes, allowing your bran theme={{ ... brand: { - name: "TrueForge", + mode: "logo", + 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. +* 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 ac8361a97..e7225ebf4 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,8 @@ 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. | +| `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`, `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 d18f722d5..7a00849cd 100644 --- a/docs/ui-sdk/reference/trueforge-ui.mdx +++ b/docs/ui-sdk/reference/trueforge-ui.mdx @@ -132,15 +132,29 @@ 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. When set, choose `mode` first, then pass the + fields that mode requires. - - Shown beside the mark, and used as the logo's accessible label. + + Chrome look. `icon-title` shows `name` beside the square mark; `icon-only` + and `logo` keep `name` for alt only. + + + 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. Optional for + `icon-title` (default mark); required for `icon-only` and `logo`. - Image URL, or per-mode sources. Omit to pair your name with the default mark. + 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. @@ -154,14 +168,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 +250,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: { mode: "logo", 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/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 6a03ff83c..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', logo: '/logo.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 `theme.brand` and the SDK positions the mark + name in the default slots: +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 ``` -**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: +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 ``` -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 theme mode; setting only one light/dark source uses it for both. `href` wraps +configured images in a same-tab link. **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 +385,10 @@ 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. Prefer `resolveBrandChrome(useBrand())` so expanded +vs collapsed choices match the base layouts. Pair with `useBrandName()` when chrome should +show the title text (see [Custom layouts](#custom-layouts)). > _Screenshot: external brand mark rendered in the base layout header._ @@ -429,17 +458,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 (
@@ -587,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 1b24229ba..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', logo: '/brand/logo.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 7edf48f70..3c44e60e6 100644 --- a/packages/trueforge-ui/docs/server.md +++ b/packages/trueforge-ui/docs/server.md @@ -808,7 +808,9 @@ 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 7f53d33da..c5a22e703 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,24 @@ 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. + * 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 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 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?: ThemePreset; // default: "trueforge" mode?: ThemeMode; // omit = uncontrolled (useTheme().setTheme) tokens?: Partial; - brand?: BrandConfig; // logo image + 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 */ @@ -269,7 +272,9 @@ 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' }, }, className: 'my-chat', @@ -326,21 +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 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. 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. +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` → 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. -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 ``. +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. +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 @@ -354,7 +364,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. @@ -374,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 @@ -471,7 +481,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 +543,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 9cc5cbf9c..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'; @@ -17,7 +18,9 @@ export { } from './theme/ThemeProvider.js'; 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 21511cfb1..c34c6f1a2 100644 --- a/packages/trueforge-ui/src/layouts/SidebarLayout.tsx +++ b/packages/trueforge-ui/src/layouts/SidebarLayout.tsx @@ -13,18 +13,23 @@ 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'; const TruefoundrySettingsBuilder = lazy(() => import('../containers/SettingsBuilder/index.js')); // 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(); + const brand = useBrand(); const brandName = useBrandName(); + const chrome = resolveBrandChrome(brand); const BrandLogo = useSlot('BrandLogo'); const AgentsLibraryButton = useSlot('AgentsLibraryButton'); const ClearChatButton = useSlot('ClearChatButton'); @@ -93,8 +98,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 && chrome.showTitle && 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..af93c7b56 100644 --- a/packages/trueforge-ui/src/theme/brand.tsx +++ b/packages/trueforge-ui/src/theme/brand.tsx @@ -2,58 +2,101 @@ 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'; +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 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 brand is set. */ +export function useBrandName(): string | undefined { + const brand = useContext(ThemeContext)?.brand; + if (brand?.name != null) return brand.name; + return brand?.mode == 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 label = name ?? DEFAULT_BRAND_NAME; 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 = {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 8cf86c66b..5ef3c01bf 100644 --- a/packages/trueforge-ui/src/theme/types.ts +++ b/packages/trueforge-ui/src/theme/types.ts @@ -68,29 +68,56 @@ 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; + +/** Chrome look for `theme.brand`. Omit `brand` entirely for the default TrueForge marks. */ +export type BrandMode = 'icon-title' | 'icon-only' | 'logo'; + /** - * 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. Set `mode`, then pass the fields that mode requires. + * `name` always labels the mark (`alt` / `aria-label`). */ -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; -}; +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 ec27f2baa..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( - +
@@ -320,9 +320,63 @@ 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(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( - +
@@ -341,7 +395,7 @@ describe('SidebarLayout', () => { // New Chat / Agents remount ChatProvider via runtimeKey; collapse must survive. unmount(); render( - +
@@ -354,7 +408,7 @@ describe('SidebarLayout', () => { it('toggles theme from the footer and shows settings only when catalog is provided', async () => { const { rerender } = render( - + @@ -382,7 +436,7 @@ describe('SidebarLayout', () => { // Settings stays available with a locked agentName when catalog is present. rerender( - + @@ -404,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 4ee9ed938..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 8e6791f18..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(); @@ -31,18 +78,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('keeps the brand name as alt for icon-only chrome', () => { + render( + + + + , + ); + + 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', () => { const { container } = render( @@ -56,9 +115,32 @@ 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 +151,91 @@ 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 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 from BrandLogo logo variant', () => { + 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 +244,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 +253,10 @@ describe('BrandLogo', () => { } render( - + , ); @@ -139,9 +265,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( - + , );