Skip to content

Commit 3ef13b3

Browse files
feat(core): consumer-editable favicon with Tailor default
Add a `favicon` prop to AppShell that sets the document favicon (any `<link rel="icon">` href — public-path URL or data URI), updating the existing link from index.html in place. Defaults to the bundled Tailor mark (a 32x32 PNG data URI) when omitted, so apps render correct branding without an asset-copy step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8affd54 commit 3ef13b3

5 files changed

Lines changed: 94 additions & 1 deletion

File tree

.changeset/document-title-sync.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,7 @@
22
"@tailor-platform/app-shell": minor
33
---
44

5-
Sync the browser tab title (`document.title`) with the active route. AppShell now sets the tab to `"<page> · <title>"`, where `<page>` is the current breadcrumb leaf (including any `useOverrideBreadcrumb` override, so detail pages show their record name automatically) and `<title>` is the `title` prop passed to `<AppShell>`. Works for every page with no per-page wiring; when no `title` is set the tab shows just the page name, and when nothing resolves the static `index.html` title is left untouched.
5+
Manage the browser tab from AppShell — title and favicon.
6+
7+
- **Title**: AppShell now keeps `document.title` in sync with the active route as `"<page> · <title>"`, where `<page>` is the current breadcrumb leaf (including any `useOverrideBreadcrumb` override, so detail pages show their record name automatically) and `<title>` is the `title` prop passed to `<AppShell>`. Works for every page with no per-page wiring; when no `title` is set the tab shows just the page name, and when nothing resolves the static `index.html` title is left untouched.
8+
- **Favicon**: new `favicon` prop on `<AppShell>` sets the document favicon (any `<link rel="icon">` href — a public-path URL or a data URI), updating the existing link from `index.html` in place. When omitted it defaults to the bundled Tailor favicon, so apps get correct branding out of the box.

packages/core/src/components/appshell.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ThemeProvider } from "@/contexts/theme-context";
2121
import { BreadcrumbOverrideProvider } from "@/contexts/breadcrumb-context";
2222
import { CommandPaletteProvider, type SearchSource } from "@/contexts/command-palette-context";
2323
import { BuiltInCommandPalette } from "@/components/command-palette";
24+
import { Favicon } from "@/components/favicon";
2425
import { useIsClient } from "@/hooks/use-is-client";
2526
import { convertPagesToModules } from "@/fs-routes/converter";
2627
import type { PageEntry } from "@/fs-routes/types";
@@ -48,6 +49,14 @@ type SharedAppShellProps = React.PropsWithChildren<{
4849
*/
4950
icon?: React.ReactNode;
5051

52+
/**
53+
* Browser-tab favicon href. Accepts anything valid on `<link rel="icon">` —
54+
* a public-path URL (e.g. `/favicon.ico`) or a data URI. AppShell applies it
55+
* to the document, updating the `<link rel="icon">` from `index.html` in
56+
* place (or creating one). When omitted, the bundled Tailor favicon is used.
57+
*/
58+
favicon?: string;
59+
5160
/**
5261
* Base path for the app shell
5362
*/
@@ -327,6 +336,7 @@ export const AppShell = (props: AppShellProps) => {
327336
<BreadcrumbOverrideProvider>
328337
<CommandPaletteProvider searchSources={props.searchSources}>
329338
<ThemeProvider defaultTheme="system" storageKey="appshell-ui-theme">
339+
<Favicon href={props.favicon} />
330340
<RouterContainer>
331341
{props.children}
332342
<BuiltInCommandPalette />
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { render, waitFor } from "@testing-library/react";
2+
import { describe, it, expect, beforeEach } from "vitest";
3+
import { DEFAULT_FAVICON_HREF } from "@/lib/default-favicon";
4+
import { Favicon } from "./favicon";
5+
6+
const getIconLink = () => document.querySelector<HTMLLinkElement>('link[rel="icon"]');
7+
8+
describe("Favicon", () => {
9+
beforeEach(() => {
10+
document.head.querySelectorAll('link[rel="icon"]').forEach((el) => el.remove());
11+
});
12+
13+
it("applies the consumer-provided href", async () => {
14+
render(<Favicon href="/custom.ico" />);
15+
await waitFor(() => expect(getIconLink()?.getAttribute("href")).toBe("/custom.ico"));
16+
});
17+
18+
it("falls back to the bundled Tailor default when no href is given", async () => {
19+
render(<Favicon />);
20+
await waitFor(() => expect(getIconLink()?.getAttribute("href")).toBe(DEFAULT_FAVICON_HREF));
21+
});
22+
23+
it("updates the existing icon link in place rather than adding another", async () => {
24+
const existing = document.createElement("link");
25+
existing.rel = "icon";
26+
existing.href = "/old.ico";
27+
document.head.appendChild(existing);
28+
29+
render(<Favicon href="/new.ico" />);
30+
await waitFor(() => expect(getIconLink()?.getAttribute("href")).toBe("/new.ico"));
31+
expect(document.head.querySelectorAll('link[rel="icon"]').length).toBe(1);
32+
});
33+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useEffect } from "react";
2+
import { DEFAULT_FAVICON_HREF } from "@/lib/default-favicon";
3+
4+
type FaviconProps = {
5+
/**
6+
* Favicon href to apply. Any value accepted by `<link rel="icon">` works:
7+
* a public-path URL (e.g. `/favicon.ico`) or a data URI. When omitted, the
8+
* bundled Tailor default ({@link DEFAULT_FAVICON_HREF}) is used.
9+
*/
10+
href?: string;
11+
};
12+
13+
/**
14+
* Keeps the document favicon in sync with the `favicon` prop passed to
15+
* `<AppShell>`, falling back to the bundled Tailor mark when none is given.
16+
*
17+
* Updates the existing `<link rel="icon">` from `index.html` in place (or
18+
* creates one if absent), so consumers don't need to declare a favicon link
19+
* themselves. Rendered once by `<AppShell>`.
20+
*
21+
* @internal
22+
*/
23+
export const Favicon = ({ href }: FaviconProps) => {
24+
const resolved = href ?? DEFAULT_FAVICON_HREF;
25+
26+
useEffect(() => {
27+
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
28+
if (!link) {
29+
link = document.createElement("link");
30+
link.rel = "icon";
31+
document.head.appendChild(link);
32+
}
33+
link.href = resolved;
34+
}, [resolved]);
35+
36+
return null;
37+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Default favicon used when a consumer does not pass a `favicon` prop to
3+
* `<AppShell>`. It is the Tailor mark, embedded as a 32×32 PNG data URI so it
4+
* ships in the bundle and needs no asset-copy step in the consuming app.
5+
*
6+
* Consumers override it by passing `favicon` (any href: a public-path URL such
7+
* as `/favicon.ico`, or a data URI).
8+
*/
9+
export const DEFAULT_FAVICON_HREF =
10+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAAFKUlEQVRYCe1WS2xUZRQ+57+3paXEQmOKJYDRgGVDQjRGWOgCDLS6Mu2UMiWupKSF1JYAjsZoE23SaaFtQBC7QBdgSkYSNRYIVkE3BLUEJbgwEA210EGtQKowj3uP35nO0Ll0WmbK1pO5r3/OOd93Hv+DaYbS1SWFcwytEUNvkdAsN59eaGjg4VzdmVwNVP/9Hlk326YjwtTHTE8T03KJUuVMfNm5GB04IEv5Dm0CcL1tUbHjELkukW0TS4wW5eIrpZsVgX0d8oiVT1soQnXGpscUNB4nQvQKTq5DYWThXMppY6PMKS0l09rKt1JjUz3hYmrZt0/mWA5VsFCADT2lyg7AVSwL7w6NkaHjHKf2zS18budOWQBdH4htRl+EQfrltjYeGrfIfJ8yA/u7pdLEqQWRPWcsmqXp1ohNsmsQ9QnDtHvufDo1MEAmsFO2ClE9dJarDvSXxGK0DLC5EfhgrywTh7bBkd+yqUiB9VKnAgSk/2c43VtSRgdrajga2C6rS4rpdYyttgyZlD4ylBdzaG7muCdGPRnYv0dWiksfAbgcT0+d4w6NYKiHHTrUsI2H39ghKwI7JIAMVYBssfZFiqhmChn6CRlUsgnxbby+RF9Ch0ovjY+M3z3TkF1aBebl6sxFtFpnRH0LkRyG07WNTRwc+pNcpPttl+lLlGY93CTAFVT1Yfs7rraYS2uC3XzR57++oqpupFdcOes6Tn/VhqtalrviJcDECq6CdGoU55Gi9Q1NvHHwAl0OBKTFidFX+LsV18MasYoCQ8bQoD3GpXXtHfzm8PDVwuq6kT2Io9+wvYnYlCBbTwBiVUI7efOUIPVHIoVCN2Dw2itNfBJ1fp4s1NlFnQGWDoz3CEifwmzYHWzngcrKvx6q8ofrMUu3M9tLBdPBdfEFZ3qxJUUpHH1OSQA9cHleAZ1tbpZVWHhCtqG5OvdTdVZjZOgMZsKusoXU39TEkZf84TpLYo0AWgkk47oxVbsrjLks4nowPR93Ncdfxmge3S4ooMUASYDrsGYHqf4Vj4OFMXqvtYdvVNUOPVPtH2lGtD628q0EsGjUk0XY9ZTd8zFZHSNaZxRSJTEVXTqdz/RisJPf/XF0uLS6LtxLlt0P4FrQA3gUmkmDhNX0t+ky4LFM9sXf+RY1HRsYvFTlv7aVHd7BnLeYsHC4jgLnLvfPQNJncgX87p0gX3i0fFEts9WDFlqsEQsIzFSyJqAZwG9UgUDmcWTbkinqnAuZrAmoU1QWHBKC1SL7OidtMj5yIpDRwwMO/k/AkwE0WnIneMC85mDuJUCkRyjRjQhksl4jcsDDlNX5NCEeAjGLvsX2ezEWp2uO0Oc+H+li7tGZMM3tjYGrewF+d9ItPc63bOFLOBOsjVv0ZMOr3AEjwZx/4PnGOMnqaoU94pgdNSfSCUxKMw6X19IVsPu5OHgkNqH08WzesR3DDo3lxn9hYzrN7LzQkd6Sm+m2kwik/5l8/0cPKXl5OKI5WZYDoIYtDTqMkh5F6J2ffjz/twy+s2g0iy6gLF9HY1SA7f1DdYKjN+OMkEEAjHRjiY4KOZ+J43R80lc2iOpPWUZPD2TwSMEgXxm9SRUFhfRsZzd76peuj80p8Yk6fw9+9Rz5ZsPRvgU/TAeuBtmUgHp72XO0ERxrUntBorNRa+yKV5D5XRyVvlCo9I90ctO9Z0XgXgfCMkQ45qLJmCQ+6kjkC4uc7tDhhefv1b3f94wI5BcV9UfH/j2O/o6Qbbqs26fPhEI1MzoU/AeEofRp9F8pTwAAAABJRU5ErkJggg==";

0 commit comments

Comments
 (0)