diff --git a/.changeset/site-kit-brand-icon-links.md b/.changeset/site-kit-brand-icon-links.md
new file mode 100644
index 0000000..cbc06be
--- /dev/null
+++ b/.changeset/site-kit-brand-icon-links.md
@@ -0,0 +1,7 @@
+---
+"@devslab/site-kit": minor
+---
+
+`brandIconLinks()` and `BRAND_ICON_FILES`: one place that says which `@devslab/linq-brand` icon files a page head links, and in what order — SVG first, a 48px PNG so search engines have a declared square of the size they ask for, the 16/32/48 ICO for the bare-URL convention, the 180px apple-touch icon. `toTanStackHead(metadata, { icons })` appends them (`true` for the `/brand` default, or `{ basePath }`); omitted, nothing changes. Before this, the four family sites each linked a different subset, and one linked none — its search result kept showing the icon from before the mark changed.
+
+`brandIconLinks()`와 `BRAND_ICON_FILES`: 페이지 head가 `@devslab/linq-brand`의 어떤 아이콘 파일을 어떤 순서로 링크하는지 정하는 한 자리 — SVG 먼저, 검색엔진이 요구하는 크기의 선언된 정사각형으로 48px PNG, 주소만으로 요청되는 관례용 16/32/48 ICO, 180px 애플 터치 아이콘. `toTanStackHead(metadata, { icons })`가 이를 덧붙인다(`true`면 `/brand` 기본값, 또는 `{ basePath }`). 생략하면 아무것도 바뀌지 않는다. 이전에는 가족 사이트 넷이 각자 다른 부분집합을 링크했고, 하나는 아무것도 링크하지 않아 마크가 바뀐 뒤에도 검색 결과가 옛 아이콘을 계속 보여줬다.
diff --git a/docs/decisions.md b/docs/decisions.md
index 6ba0b4b..d549a02 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -5,6 +5,39 @@
---
+## D-026 — 파비콘 `` 묶음은 site-kit이 정하고, 파일은 linq-brand가 만든다 (2026-09-14)
+
+**결정.** `@devslab/site-kit`에 `brandIconLinks({ basePath })`와 `BRAND_ICON_FILES`를
+두고, `toTanStackHead(metadata, { icons })`가 옵트인으로 덧붙인다. 묶음은 넷으로
+고정: `favicon.svg`(먼저), `mark-48.png`(`48x48`), `favicon.ico`(`16x16 32x32 48x48`),
+`apple-touch-icon.png`(`180x180`). 파일 자체는 계속 `@devslab/linq-brand`가
+`dist//`에 만든다 — site-kit은 그 이름을 부를 뿐 그리지 않는다.
+
+**계기.** AskLinq 검색 결과 파비콘이 마크 교체(D-125, 2026-09-03) 열흘 뒤에도 옛
+물음표였다. 프로덕션 파일은 이미 새 마크였고, 원인은 둘: web-next 컷오버가 레거시
+head의 ``을 옮기지 않아 크롤러가 `/favicon.ico` 폴백만 봤고, 그
+파일이 32px PNG라 구글 권장(48px 초과)에 미달했다. 그리고 확인해 보니 가족 넷이
+각자 다른 조합이었다 — VisionLinq는 svg+ico(`any`)+apple, BookLinq는
+svg+32png+ico(`48x48`)+apple+manifest, TraceLinq는 자기 head, AskLinq는 없음.
+같은 패키지 파일을 네 가지로 부르고 있었으니 공통 자리는 head를 만드는 site-kit이다.
+
+**대안.** ① linq-brand 레지스트리에 링크 목록을 싣기 — 레지스트리는 프레임워크
+중립 자산 목록이고 `` 속성(rel/sizes/type)은 head의 어휘라 반려. ② 각 제품이
+계속 자기 head에 쓰기 — 지금 네 가지로 갈라진 그 상태. ③ `toTanStackHead`가 항상
+아이콘을 내기 — 제품이 파일을 어디서 서빙하는지 어댑터가 모르고, 404 나는
+아이콘을 링크한 head는 안 링크한 head보다 나쁘므로 옵트인.
+
+**트레이드오프.** `basePath`는 같은 오리진 경로만 받는다(CDN URL은 RangeError) —
+아이콘은 제품이 직접 서빙한다는 전제를 코드로 둔다. manifest 링크는 묶음에 넣지
+않았다: PWA 매니페스트는 제품마다 내용이 달라 링크만 공통화해도 파일은 못 만든다.
+
+**재검토.** linq-brand가 파일 이름이나 크기 세트를 바꾸면 `BRAND_ICON_FILES`와
+같은 PR에서 움직여야 한다(두 레포라 자동 게이트 없음 — 소비자 테스트가 서빙
+바이트의 sha를 패키지 checksums와 대조하는 것이 유일한 그물). 구글이 요구 크기를
+바꾸면 `mark-48.png` 항목만 바뀐다.
+
+---
+
## D-025 — 테이블 안 버튼: 행 높이는 고정, 터치에서는 하한이 이긴다 (2026-09-13)
**결정.** VisionLinq(VL-058)가 DDS 0.11.0으로 옮긴 뒤에도 제품 쪽에 남겨 둔
diff --git a/packages/site-kit/README.ko.md b/packages/site-kit/README.ko.md
index 2c8564b..178b3c6 100644
--- a/packages/site-kit/README.ko.md
+++ b/packages/site-kit/README.ko.md
@@ -17,6 +17,26 @@ claim leaf가 검증된 사실 레지스트리를 참조하도록 강제한다.
기존 environment-only 출력은 유지되며, 선택적 `policies`로 검색 인덱싱,
인용 crawler, 모델 학습 crawler를 각각 제어할 수 있다.
+## 브랜드 아이콘
+
+모든 제품의 아이콘 파일은 `@devslab/linq-brand`(`dist//`)에서 온다. `brandIconLinks()`는 그중 어떤 파일을 페이지 head가 어떤 순서로 링크하는지 정하는 유일한 자리다.
+
+| 링크 | 파일 | 이유 |
+|---|---|---|
+| `icon` `image/svg+xml` | `favicon.svg` | 탭 아이콘. SVG를 아는 브라우저가 래스터를 받지 않도록 맨 앞 |
+| `icon` `48x48` | `mark-48.png` | 검색엔진은 ``로 선언된 48px 이상 정사각형을 원한다(구글: "최소 8x8px, 48x48px 초과 권장") |
+| `icon` `16x16 32x32 48x48` | `favicon.ico` | 주소만으로 요청되는 관례, 세 크기를 한 컨테이너에 |
+| `apple-touch-icon` `180x180` | `apple-touch-icon.png` | iOS 홈 화면 |
+
+`BRAND_ICON_FILES`는 같은 네 파일명을 나열하므로 제품은 한 디렉터리에서 그대로 서빙할 수 있다. 기본 `basePath`는 `/brand`이고, 다른 곳에서 서빙하는 제품은 자기 경로를 넘긴다. 같은 오리진 경로만 받는다 — 아이콘은 제품이 직접 서빙한다.
+
+```ts
+toTanStackHead(metadata, { icons: true }); // …/brand/favicon.svg, …
+toTanStackHead(metadata, { icons: { basePath: "/" } }); // /favicon.svg, /mark-48.png, …
+```
+
+옵션을 생략하면 어댑터는 아이콘 링크를 내지 않는다. 제품이 파일을 어디서 서빙하는지 어댑터가 알 수 없고, 서버가 404를 내는 아이콘을 링크한 head는 아무것도 링크하지 않은 head보다 나쁘기 때문이다.
+
## 섹션
가족 랜딩 페이지를 위한 상태 없는 원시 컴포넌트 여섯 개, VisionLinq에서 추출했다. `@devslab/site-kit/solid`에서 import한다; 스타일시트는 `styles.css` 안에 들어 있다. 이 원시 컴포넌트들은 full-bleed — 각자 자기 내부 너비를 갖는다 — 라서 이걸로 구성한 페이지는 `` 안에서 렌더해야 한다; 그렇지 않으면 셸의 기본 `` 인셋이 이중으로 안쪽 여백을 주고 `tone="band"`가 박스형 사각형이 되어 버린다.
diff --git a/packages/site-kit/README.md b/packages/site-kit/README.md
index edd2af3..8adaea7 100644
--- a/packages/site-kit/README.md
+++ b/packages/site-kit/README.md
@@ -17,6 +17,26 @@ still references the verified-fact registry. `buildRobots` keeps its legacy
environment-only output, while an optional `policies` object can independently
control search indexing, citation crawlers, and model-training crawlers.
+## Brand icons
+
+Every product's icon files come from `@devslab/linq-brand` (`dist//`); `brandIconLinks()` is the one place that says which of them a page head links, and in what order:
+
+| Link | File | Why |
+|---|---|---|
+| `icon` `image/svg+xml` | `favicon.svg` | the tab icon, first so a browser that understands SVG never fetches a raster |
+| `icon` `48x48` | `mark-48.png` | search engines want a ``-declared square of at least 48px (Google: "at least 8x8px, preferably >48x48px") |
+| `icon` `16x16 32x32 48x48` | `favicon.ico` | the bare-URL convention, three sizes in one container |
+| `apple-touch-icon` `180x180` | `apple-touch-icon.png` | iOS home screen |
+
+`BRAND_ICON_FILES` lists the same four names, so a product can serve them from one directory. The default `basePath` is `/brand`; a product that serves the files elsewhere passes its own path. Only same-origin paths are accepted — a product serves its own icons.
+
+```ts
+toTanStackHead(metadata, { icons: true }); // …/brand/favicon.svg, …
+toTanStackHead(metadata, { icons: { basePath: "/" } }); // /favicon.svg, /mark-48.png, …
+```
+
+Omitted, the adapter emits no icon links: it cannot know where a product serves the files, and a head that links icons the server 404s is worse than one that links none.
+
## Sections
Six stateless primitives for a family landing page, extracted from VisionLinq. Import from `@devslab/site-kit/solid`; the stylesheet ships inside `styles.css`. The primitives are full-bleed — each carries its own inner width — so a page built from them should render inside ``; the shell's default `` inset would otherwise double-inset them and turn `tone="band"` into a boxed rectangle.
diff --git a/packages/site-kit/src/core/index.mjs b/packages/site-kit/src/core/index.mjs
index 4d14c72..132bb3d 100644
--- a/packages/site-kit/src/core/index.mjs
+++ b/packages/site-kit/src/core/index.mjs
@@ -1,5 +1,5 @@
export { FAMILY_LOCALES, LOCALES, canonicalLocale, defineLocaleRegistry, localeAttributes, resolveLocale } from "./locales.mjs";
export { CatalogValidationError, createTranslator, validateCatalogs } from "./catalog.mjs";
-export { ROBOTS_USER_AGENTS, buildMetadata, buildRobots, buildSitemap, localizedPath, localizedUrl, renderSitemapXml } from "./seo.mjs";
+export { BRAND_ICON_FILES, ROBOTS_USER_AGENTS, brandIconLinks, buildMetadata, buildRobots, buildSitemap, localizedPath, localizedUrl, renderSitemapXml } from "./seo.mjs";
export { VerifiedFactRegistry, buildVerifiedJsonLd, renderLlmsTxt } from "./geo.mjs";
export { definePublisher, buildPublisher, serializeJsonLd, renderPublisherHtml } from "./publisher.mjs";
diff --git a/packages/site-kit/src/core/seo.d.mts b/packages/site-kit/src/core/seo.d.mts
index 462cd9d..aa72198 100644
--- a/packages/site-kit/src/core/seo.d.mts
+++ b/packages/site-kit/src/core/seo.d.mts
@@ -15,3 +15,8 @@ export declare function renderSitemapXml(entries: ReturnType` descriptors for BRAND_ICON_FILES served at `basePath` (default "/brand"; a same-origin path, never a URL). */
+export declare function brandIconLinks(options?: { basePath?: string }): BrandIconLink[];
diff --git a/packages/site-kit/src/core/seo.mjs b/packages/site-kit/src/core/seo.mjs
index 76dcc30..73e1ad5 100644
--- a/packages/site-kit/src/core/seo.mjs
+++ b/packages/site-kit/src/core/seo.mjs
@@ -92,3 +92,46 @@ export function buildRobots({ baseUrl, environment, policies }) {
if (environment !== "production") return "User-agent: *\nDisallow: /\n";
return `User-agent: *\nAllow: /\nSitemap: ${cleanBase(baseUrl)}/sitemap.xml\n`;
}
+
+/**
+ * The icon files @devslab/linq-brand ships for every product under
+ * dist//, in the order a page head should link them. A product
+ * serves that directory as-is (VisionLinq, BookLinq: /brand/*) or vendors
+ * the bytes and serves them under the same names (AskLinq); either way the
+ * head links come from here, so the four family sites cannot each pick a
+ * different subset — which is what they did before this existed.
+ *
+ * favicon.svg — the tab icon, crisp at every size, linked first so a
+ * browser that understands it never fetches a raster.
+ * mark-48.png — one raster of at least 48px: search engines want a
+ * -declared square, Google "at least 8x8px,
+ * preferably >48x48px", and a 32px-only site shows its
+ * previous icon in results long after the file changed.
+ * favicon.ico — 16/32/48 in one container, the bare-URL convention.
+ * apple-touch-icon.png — 180px, iOS home screen.
+ */
+export const BRAND_ICON_FILES = Object.freeze(["favicon.svg", "mark-48.png", "favicon.ico", "apple-touch-icon.png"]);
+
+const iconBase = (basePath) => {
+ const value = String(basePath ?? "/brand");
+ if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(value)) {
+ throw new RangeError("brandIconLinks basePath is a same-origin path, not a URL: a product serves its own icons");
+ }
+ const trimmed = value.replace(/^\/+|\/+$/g, "");
+ return trimmed ? `/${trimmed}` : "";
+};
+
+/**
+ * `` descriptors for BRAND_ICON_FILES at `basePath` (default /brand).
+ * Same shape as toTanStackHead's `links`; a non-Start renderer can print
+ * them as attributes in this order.
+ */
+export function brandIconLinks({ basePath = "/brand" } = {}) {
+ const base = iconBase(basePath);
+ return [
+ { rel: "icon", type: "image/svg+xml", href: `${base}/favicon.svg` },
+ { rel: "icon", type: "image/png", sizes: "48x48", href: `${base}/mark-48.png` },
+ { rel: "icon", sizes: "16x16 32x32 48x48", href: `${base}/favicon.ico` },
+ { rel: "apple-touch-icon", sizes: "180x180", href: `${base}/apple-touch-icon.png` },
+ ];
+}
diff --git a/packages/site-kit/src/tanstack-start.d.mts b/packages/site-kit/src/tanstack-start.d.mts
index fdbccd4..9120efc 100644
--- a/packages/site-kit/src/tanstack-start.d.mts
+++ b/packages/site-kit/src/tanstack-start.d.mts
@@ -6,5 +6,7 @@ import type { SiteLocale } from "./core/locales.mjs";
* registry (D-018) yields `SiteMetadata`; the adapter reads the
* same fields whatever the code type is, so it must not refuse that.
*/
-export declare function toTanStackHead(metadata: SiteMetadata): { meta: Array>; links: Array> };
+/** `icons: true` appends brandIconLinks() (the /brand default); an object passes its options through. Omitted, no icon links are emitted. */
+export interface TanStackHeadOptions { icons?: boolean | { basePath?: string } }
+export declare function toTanStackHead(metadata: SiteMetadata, options?: TanStackHeadOptions): { meta: Array>; links: Array> };
export declare const toHtmlAttributes: (metadata: SiteMetadata) => { lang: Code; dir: string };
diff --git a/packages/site-kit/src/tanstack-start.mjs b/packages/site-kit/src/tanstack-start.mjs
index 815365c..d477ee5 100644
--- a/packages/site-kit/src/tanstack-start.mjs
+++ b/packages/site-kit/src/tanstack-start.mjs
@@ -1,4 +1,10 @@
-export function toTanStackHead(metadata) {
+import { brandIconLinks } from "./core/seo.mjs";
+
+// `icons` is opt-in: the adapter cannot know where (or whether) a product
+// serves the linq-brand files, and a head that links icons the server 404s
+// is worse than one that links none. `true` takes the /brand default.
+export function toTanStackHead(metadata, options = {}) {
+ const icons = options.icons === true ? brandIconLinks() : options.icons ? brandIconLinks(options.icons) : [];
return {
meta: [
{ title: metadata.title },
@@ -18,6 +24,7 @@ export function toTanStackHead(metadata) {
links: [
{ rel: "canonical", href: metadata.canonical },
...metadata.alternates.map(({ hreflang, href }) => ({ rel: "alternate", hreflang, href })),
+ ...icons,
],
};
}
diff --git a/tests/site-kit-contracts.test.mjs b/tests/site-kit-contracts.test.mjs
index 09e3865..00cfc3a 100644
--- a/tests/site-kit-contracts.test.mjs
+++ b/tests/site-kit-contracts.test.mjs
@@ -195,6 +195,6 @@ test("the TanStack adapter accepts metadata built from a product registry", asyn
// family default alone made every product-registry consumer cast or
// augment the module to get its head descriptors out.
const dts = await readFile(new URL("../packages/site-kit/src/tanstack-start.d.mts", import.meta.url), "utf8");
- assert.match(dts, /toTanStackHead\(metadata: SiteMetadata\)/);
+ assert.match(dts, /toTanStackHead\(metadata: SiteMetadata, options\?: TanStackHeadOptions\)/);
assert.match(dts, /toHtmlAttributes: \(metadata: SiteMetadata\)/);
});
diff --git a/tests/site-kit-core.test.mjs b/tests/site-kit-core.test.mjs
index 18e535b..e2a8153 100644
--- a/tests/site-kit-core.test.mjs
+++ b/tests/site-kit-core.test.mjs
@@ -11,10 +11,13 @@ import {
validateCatalogs,
} from "../packages/site-kit/src/core/index.mjs";
import {
+ BRAND_ICON_FILES,
+ brandIconLinks,
buildMetadata,
buildRobots,
buildSitemap,
} from "../packages/site-kit/src/core/seo.mjs";
+import { toTanStackHead } from "../packages/site-kit/src/tanstack-start.mjs";
import {
VerifiedFactRegistry,
buildVerifiedJsonLd,
@@ -245,3 +248,50 @@ test("the family registry is untouched by the subset option", () => {
assert.equal(FAMILY_LOCALES.LOCALES.length, 14);
assert.equal(defineLocaleRegistry().LOCALES.length, 14);
});
+
+test("brand icon links name the linq-brand files a product serves, in one fixed order", () => {
+ const links = brandIconLinks();
+ assert.deepEqual(links, [
+ { rel: "icon", type: "image/svg+xml", href: "/brand/favicon.svg" },
+ { rel: "icon", type: "image/png", sizes: "48x48", href: "/brand/mark-48.png" },
+ { rel: "icon", sizes: "16x16 32x32 48x48", href: "/brand/favicon.ico" },
+ { rel: "apple-touch-icon", sizes: "180x180", href: "/brand/apple-touch-icon.png" },
+ ]);
+ // Every href is a file @devslab/linq-brand ships under dist//, so a
+ // product that serves that directory as-is at basePath serves all of them.
+ for (const { href } of links) assert.ok(BRAND_ICON_FILES.includes(href.slice("/brand/".length)), href);
+ assert.deepEqual(new Set(BRAND_ICON_FILES), new Set(links.map(({ href }) => href.slice("/brand/".length))));
+ // Search engines need one raster of at least 48px (Google: "at least 8x8px, preferably >48x48px").
+ assert.ok(links.some(({ sizes }) => /^(?:48|96|144|192)x(?:48|96|144|192)$/.test(sizes ?? "")));
+ // The SVG comes first so browsers that understand it never fetch the raster.
+ assert.equal(links[0].type, "image/svg+xml");
+});
+
+test("brand icon links follow the base path a product serves the files at", () => {
+ assert.deepEqual(brandIconLinks({ basePath: "/" }).map(({ href }) => href), ["/favicon.svg", "/mark-48.png", "/favicon.ico", "/apple-touch-icon.png"]);
+ assert.equal(brandIconLinks({ basePath: "assets/brand/" })[0].href, "/assets/brand/favicon.svg");
+ assert.equal(brandIconLinks({ basePath: "/brand" })[0].href, "/brand/favicon.svg");
+ assert.throws(() => brandIconLinks({ basePath: "https://cdn.example.com/brand" }), RangeError);
+});
+
+test("the TanStack adapter appends the icon links only when asked, after canonical and alternates", () => {
+ const metadata = buildMetadata({
+ baseUrl: "https://example.com",
+ path: "/",
+ locale: "ko",
+ defaultLocale: "ko",
+ title: "Example",
+ description: "Example site",
+ siteName: "Example",
+ image: "/og.png",
+ });
+ const bare = toTanStackHead(metadata);
+ assert.ok(!bare.links.some(({ rel }) => rel === "icon" || rel === "apple-touch-icon"));
+ const withIcons = toTanStackHead(metadata, { icons: true });
+ const rels = withIcons.links.map(({ rel }) => rel);
+ assert.equal(rels.filter((rel) => rel === "icon").length, 3);
+ assert.ok(rels.indexOf("icon") > rels.lastIndexOf("alternate"));
+ assert.deepEqual(withIcons.links.slice(0, bare.links.length), bare.links);
+ assert.equal(toTanStackHead(metadata, { icons: { basePath: "/" } }).links.at(-1).href, "/apple-touch-icon.png");
+ assert.deepEqual(toTanStackHead(metadata, { icons: false }), bare);
+});