Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/site-kit-flag-sprite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@devslab/site-kit": minor
---

The flag artwork leaves the browser bundle. `@devslab/site-kit/solid` was one 138 KB file, and a consumer that imported only `SiteHeader` shipped nearly all of it — not because tree-shaking failed (importing every export adds just 13 KB) but because the menu imported the fourteen vendored flag SVGs statically (~115 KB; Spain's coat of arms alone is 85 KB) and re-wrote them into the DOM on the client, hydration included. `LocaleMenu` decides `select` vs `flag` at runtime, so every header reached them.

Now each flag menu renders one `<symbol>` sprite and every flag is an `<svg><use href="#…">` of it, so a locale change on the client only swaps an `href`. The server build writes the sprite; hydration adopts it; the browser build reaches the bodies only through a dynamic `import()` — emitted as its own chunk, `dist/flag-bodies.js` — taken when a flag menu renders with no server HTML (a client-only app, a jsdom test). The generated data splits into `flag-countries.mjs` (the map the menu needs) and `flag-bodies.mjs`; `@devslab/site-kit/flags` keeps its API and gains `flagCountryFor` and `FLAG_VIEWBOX`. `pnpm check` now builds a minimal consumer (`fixtures/bundle-probe`) with Vite + vite-plugin-solid and fails if a flag body ever returns to the main chunk. No consumer code changes.

Measured, gzip in parentheses. `dist/solid.js`: 138.2 KB (32.5) → 28.7 KB (8.2). Minimal consumer importing `SiteHeader` only: 147.5 KB (38.8) → 42.8 KB (15.1). TraceLinq landing (`packages/landing`, TanStack Start, main client chunk): 291.0 KB (86.5) → 186.3 KB (62.1); the 105 KB (23.7) flag chunk is emitted beside it and is not requested by a hydrated page. Server-rendered HTML still carries the bodies, once per country per menu instead of the current locale twice.

국기 아트워크가 브라우저 번들에서 빠집니다. `@devslab/site-kit/solid`는 138 KB 파일 하나였고 `SiteHeader`만 import한 소비자도 거의 통째로 실었습니다 — tree-shaking 실패가 아니라(전부 import해도 13 KB 차이) 메뉴가 벤더링한 국기 SVG 14개(~115 KB, 스페인 문장 하나가 85 KB)를 정적으로 import하고 클라이언트에서, 하이드레이션 중에도, DOM에 다시 쓰고 있었기 때문입니다. `LocaleMenu`가 `select`/`flag`를 런타임에 고르므로 모든 헤더가 거기에 닿았습니다.

이제 국기 메뉴마다 `<symbol>` 스프라이트 하나를 렌더링하고 모든 국기는 그것을 `<svg><use href="#…">`로 참조하므로, 클라이언트에서 로케일이 바뀌어도 `href`만 바뀝니다. 스프라이트는 서버 빌드가 쓰고, 하이드레이션은 그대로 인수하며, 브라우저 빌드는 본문을 동적 `import()`(자기 청크 `dist/flag-bodies.js`)로만 닿습니다 — 서버 HTML 없이 국기 메뉴가 렌더링될 때(클라이언트 전용 앱, jsdom 테스트)에만. 생성 데이터는 `flag-countries.mjs`(메뉴가 필요한 지도)와 `flag-bodies.mjs`로 나뉘고 `@devslab/site-kit/flags`는 API를 유지한 채 `flagCountryFor`·`FLAG_VIEWBOX`를 얻습니다. `pnpm check`가 최소 소비자(`fixtures/bundle-probe`)를 Vite + vite-plugin-solid로 빌드해 국기 본문이 메인 청크로 되돌아오면 실패합니다. 소비자 코드 변경은 없습니다.

측정(괄호는 gzip). `dist/solid.js`: 138.2 KB (32.5) → 28.7 KB (8.2). `SiteHeader`만 import한 최소 소비자: 147.5 KB (38.8) → 42.8 KB (15.1). TraceLinq 랜딩(`packages/landing`, TanStack Start, 클라이언트 메인 청크): 291.0 KB (86.5) → 186.3 KB (62.1); 105 KB (23.7) 국기 청크는 옆에 생성되지만 하이드레이션된 페이지는 요청하지 않습니다. 서버 렌더 HTML에는 본문이 여전히 실립니다 — 현재 로케일 두 번 대신 메뉴당 나라마다 한 번.
8 changes: 8 additions & 0 deletions docs/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@ Geist/Geist Mono 셀프호스팅 여부, Pretendard 서브셋, RN 번들 전략.
### 12. site-kit 국기 로케일 피커 — `완료` (2026-09-02)
D-017. `LocaleMenu variant="flag"` + 국기 데이터 14개. 첫 소비자 TraceLinq 랜딩.

### 13. site-kit 브라우저 번들에서 국기 아트워크 분리 — `완료` (2026-09-08)
D-020. TraceLinq 랜딩의 클라이언트 메인 청크가 `dist/solid.js` 통째(138 KB)만큼
컸던 것 — tree-shaking이 아니라 국기 SVG 115 KB가 `SiteHeader`에 딸려 온 것.
스프라이트 + `<use>`, 서버만 본문 작성, 브라우저는 동적 청크(서버 HTML 없을
때만). `pnpm check`의 최소 소비자 게이트(`fixtures/bundle-probe`)가 회귀를 막는다.
- 남은 것: 서버 HTML의 본문 ~115 KB/페이지 → `<img src>` + 소비자 정적 서빙
(API 변경, D-020 재검토 시점).

---

## P3 — 모바일 이후
Expand Down
41 changes: 41 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,47 @@

---

## D-020 — 국기 아트워크는 서버가 쓰고 브라우저는 `<use>`한다 (2026-09-08)

**결정.** `LocaleMenu variant="flag"`는 메뉴당 `<symbol>` 스프라이트 하나(나라당
본문 하나)를 렌더링하고 모든 국기를 `<svg><use href="#…">`로 참조한다. 스프라이트
마크업은 **서버 빌드만** 쓴다(`src/solid/flag-bodies.server.ts`, 서버 Vite·Vitest
설정이 alias). 브라우저 빌드(`dist/solid.js`)는 본문을 정적으로 import하지 않고
동적 `import()`(자기 청크 `dist/flag-bodies.js`)로만 닿으며, 그마저 서버 HTML 없이
렌더링될 때(하이드레이션이 아닐 때 — `sharedConfig.context` 부재)만 실행한다.
생성기는 `flags.mjs` 하나 대신 `flag-countries.mjs`(로케일→나라, 300바이트)와
`flag-bodies.mjs`(본문 110 KB)로 나누고 `flags.mjs`는 둘을 합친 공개 서브패스로
남는다(API 무변경, `flagCountryFor`·`FLAG_VIEWBOX` 추가). `pnpm check`가 최소
소비자(`fixtures/bundle-probe`)를 실제 Vite로 빌드해 본문이 메인 청크로 돌아오면
실패한다.

**근거.** TraceLinq 랜딩(D-017 첫 소비자)의 클라이언트 메인 청크가 빈 스캐폴드
대비 ~147 KB 커졌고, 그 크기가 `dist/solid.js` 통째(138 KB)와 같아 tree-shaking
실패로 보였다. 최소 소비자로 재현하니 **tree-shaking은 되고 있었다** — 전부
import해도 헤더만 import한 것보다 13 KB 클 뿐. 남은 115 KB는 국기 SVG 14개
(스페인 문장 하나가 85 KB)로, `LocaleMenu`가 `variant`를 **런타임에** 분기하므로
`select` 변형을 쓰는 소비자도 헤더 하나로 국기 전부를 실었고, `Flag`가 effect
안에서 `innerHTML`을 다시 써서 하이드레이션에도 본문이 필요했다. 즉 패키징이
아니라 **데이터가 있는 자리**의 문제다. `preserveModules`나 컴포넌트별
서브패스는 이 바이트를 한 개도 못 뺀다 — 헤더가 정당하게 그 분기에 닿기 때문.

**측정.** 최소 소비자(Vite + vite-plugin-solid, `SiteHeader`만): 147.5 KB /
gzip 38.8 KB → 42.8 KB / gzip 15.1 KB. `dist/solid.js`: 138.2 KB / gzip 32.5 KB →
28.7 KB / gzip 8.2 KB. TraceLinq 랜딩 실측은 changeset에.

**트레이드오프.** ① 스프라이트는 `display:none`이 아니라 0×0 절대배치 — 참조된
clipPath·그라디언트가 `display:none` 트리에서는 해석되지 않는 브라우저가 있다.
② 클라이언트 전용 렌더(서버 HTML 없음)는 국기가 한 박자 늦게 찬다 — 가족 제품은
전부 SSR이라 실제 경로가 아니며, jsdom 테스트는 `await`한다. ③ 서버 HTML에는
본문이 여전히 실린다(메뉴당 나라마다 한 번 — 이전엔 현재 로케일이 두 번).
페이지 무게 ~115 KB는 그대로다; 국기를 **가져오는 파일**(`<img src>`, 브라우저
캐시)로 옮기면 그것도 사라지지만 소비자마다 정적 파일 서빙이 필요한 API 변경이라
별도 결정. ④ D-017의 "제품은 아트워크를 싣지 않는다"는 유지 — 여전히 site-kit
데이터, 자리만 바뀜.

**재검토 시점.** 서버 HTML 무게가 문제로 측정되거나 SSR 없는 소비자가 생기면
(그때 `<img src>` + 소비자 정적 서빙 헬퍼를 검토한다).

## D-019 — SSR에서 선택 상태는 `<option selected>`, `<select value>`가 아니다 (2026-09-05)

**결정.** `SelectLocaleMenu`가 `<select value={...}>` 대신 현재 로케일의
Expand Down
26 changes: 19 additions & 7 deletions packages/site-kit/README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,22 @@ MkDocs 준비 작업, SSR에서 공통으로 쓴다. 기존 graph를 script에
트리거가 현재 로케일의 국기이고 행마다 국기 + 자국어 이름 링크인 `<details>`
디스클로저를 렌더링한다 — JavaScript 없이도 동작하며, Solid는 Escape로 닫기와
`onLocaleChange(locale, href)` 콜백을 더한다. `SiteHeader`는 `localeVariant`를
그대로 전달한다. 국기 데이터(`FLAG_COUNTRY`, `LOCALE_FLAGS`, `flagFor`)는
런타임 중립 `.` entry가 아니라 전용 서브패스 `@devslab/site-kit/flags`에서
export된다 — 벤더링한 아트워크가 SVG ~110 KB라 대부분의 소비자는 국기 메뉴를
렌더링하지 않기 때문이다. 아트워크는 flag-icons에서 벤더링했다(MIT,
`flags/LICENSE-flag-icons.txt`). 국기는 `dds-icons` 항목이 아니라 site-kit
데이터다 — 아이콘 세트의 계약이 단색 `currentColor` 스트로크를 요구하기
때문이다.
그대로 전달한다. 국기 데이터(`FLAG_COUNTRY`, `LOCALE_FLAGS`, `flagFor`,
`flagCountryFor`)는 런타임 중립 `.` entry가 아니라 전용 서브패스
`@devslab/site-kit/flags`에서 export된다 — 벤더링한 아트워크가 SVG ~110 KB라
대부분의 소비자는 국기 메뉴를 렌더링하지 않기 때문이다. 아트워크는
flag-icons에서 벤더링했다(MIT, `flags/LICENSE-flag-icons.txt`). 국기는
`dds-icons` 항목이 아니라 site-kit 데이터다 — 아이콘 세트의 계약이 단색
`currentColor` 스트로크를 요구하기 때문이다.

국기 메뉴는 그 아트워크를 **브라우저 번들에서도** 뺀다. 메뉴 하나가
`<symbol>` 스프라이트 하나(나라당 본문 하나)를 렌더링하고 모든 국기는 그것을
`<svg><use href="#…">`로 참조하므로, 클라이언트에서 로케일이 바뀌어도
`href`만 바뀐다. 스프라이트 마크업은 **서버 빌드**가 쓰고 하이드레이션은 그
마크업을 그대로 인수하며, 브라우저 빌드는 본문을 동적 `import()`(자기 청크
`dist/flag-bodies.js`)로만 닿는다 — 서버 HTML 없이 국기 메뉴가 렌더링될
때(클라이언트 전용 앱, jsdom 테스트)에만 가져온다. 그래서 `SiteHeader`를
import하는 소비자는 kit 전체로 클라이언트 JS ~150 KB가 아니라 ~40 KB를 싣는다.
`pnpm check`가 최소 소비자(`fixtures/bundle-probe`)를 빌드해 국기 본문이 메인
청크로 되돌아오면 실패시킨다. 본문은 서버 렌더 HTML에는 여전히 실린다 —
가져오는 파일로 옮기는 것은 별도 결정이다.
4 changes: 3 additions & 1 deletion packages/site-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,6 @@ does not exist.

## Locale menu variants

`LocaleMenu` renders a native `<select>` by default. `variant="flag"` renders a `<details>` disclosure whose trigger is the current locale's flag and whose rows are flag + native-name links — it works without JavaScript; Solid adds Escape-to-close and the `onLocaleChange(locale, href)` callback. `SiteHeader` forwards `localeVariant`. Flag data (`FLAG_COUNTRY`, `LOCALE_FLAGS`, `flagFor`) is exported from `@devslab/site-kit/flags`, a dedicated subpath — not the runtime-neutral `.` entry — because the vendored artwork is ~110 KB of SVG and most consumers never render a flag menu. The artwork is vendored from flag-icons (MIT, `flags/LICENSE-flag-icons.txt`). Flags are site-kit data, not `dds-icons` entries, because the icon set's contract requires single-colour `currentColor` strokes.
`LocaleMenu` renders a native `<select>` by default. `variant="flag"` renders a `<details>` disclosure whose trigger is the current locale's flag and whose rows are flag + native-name links — it works without JavaScript; Solid adds Escape-to-close and the `onLocaleChange(locale, href)` callback. `SiteHeader` forwards `localeVariant`. Flag data (`FLAG_COUNTRY`, `LOCALE_FLAGS`, `flagFor`, `flagCountryFor`) is exported from `@devslab/site-kit/flags`, a dedicated subpath — not the runtime-neutral `.` entry — because the vendored artwork is ~110 KB of SVG and most consumers never render a flag menu.

The flag menu keeps that artwork out of the browser bundle too. Each menu renders one `<symbol>` sprite (one body per country) and every flag is an `<svg><use href="#…">` of it, so a locale change on the client only swaps an `href`. The sprite's markup is written by the server build; hydration adopts it, and the browser build reaches the bodies only through a dynamic `import()` — its own chunk, `dist/flag-bodies.js` — taken when a flag menu renders with no server HTML (a client-only app, a jsdom test). A consumer that imports `SiteHeader` therefore ships ~40 KB of client JS for the whole kit rather than ~150 KB; `pnpm check` builds a minimal consumer (`fixtures/bundle-probe`) and fails if a flag body ever returns to the main chunk. The bodies still sit in the server-rendered HTML — moving them to fetched files is a separate decision. The artwork is vendored from flag-icons (MIT, `flags/LICENSE-flag-icons.txt`). Flags are site-kit data, not `dds-icons` entries, because the icon set's contract requires single-colour `currentColor` strokes.
1 change: 1 addition & 0 deletions packages/site-kit/fixtures/bundle-probe/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
out/
8 changes: 8 additions & 0 deletions packages/site-kit/fixtures/bundle-probe/everything.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* The other end: every export imported and kept alive, so the gate can tell
"the unused components were dropped" from "nothing is ever dropped". */
import { render } from "solid-js/web";
import * as kit from "@devslab/site-kit/solid";

declare global { interface Window { __kit: typeof kit; __props: Parameters<typeof kit.SiteHeader>[0] } }
window.__kit = kit;
render(() => <kit.SiteHeader {...window.__props} />, document.body);
8 changes: 8 additions & 0 deletions packages/site-kit/fixtures/bundle-probe/header-only.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* The smallest real consumer: a client entry that renders SiteHeader and
nothing else. scripts/check-client-bundle.mjs builds it and reads what the
consumer's bundler kept. */
import { render } from "solid-js/web";
import { SiteHeader } from "@devslab/site-kit/solid";

declare global { interface Window { __props: Parameters<typeof SiteHeader>[0] } }
render(() => <SiteHeader {...window.__props} />, document.body);
9 changes: 9 additions & 0 deletions packages/site-kit/fixtures/bundle-probe/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["vite/client"],
"paths": { "@devslab/site-kit/solid": ["../../dist/index.d.ts"] }
},
"include": ["*.tsx"]
}
2 changes: 1 addition & 1 deletion packages/site-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
},
"scripts": {
"build": "vite build && vite build --config vite.server.config.ts && tsc -p tsconfig.build.json --emitDeclarationOnly",
"check": "node scripts/build-flags.mjs --check && tsc -p tsconfig.json --noEmit",
"check": "node scripts/build-flags.mjs --check && tsc -p tsconfig.json --noEmit && tsc -p fixtures/bundle-probe/tsconfig.json && node scripts/check-client-bundle.mjs",
"test": "vitest run --config vitest.config.ts && vitest run --config vitest.ssr.config.ts",
"test:a11y": "vitest run --config vitest.config.ts src/solid/__tests__/a11y.test.tsx",
"test:worker": "wrangler deploy --dry-run --config fixtures/worker/wrangler.jsonc --outdir dist-worker"
Expand Down
Loading