@devslab/site-kit 0.14.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.ko.md +266 -3
  2. package/README.md +266 -3
  3. package/dist/chrome.d.ts +10 -0
  4. package/dist/chrome.d.ts.map +1 -1
  5. package/dist/consent.d.ts +36 -0
  6. package/dist/consent.d.ts.map +1 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/solid.js +336 -135
  10. package/dist/solid.js.map +1 -1
  11. package/dist/solid.server.js +282 -25
  12. package/dist/solid.server.js.map +1 -1
  13. package/fonts/geist/LICENSE.txt +93 -0
  14. package/fonts/geist/geist-cyrillic-ext-wght-normal.woff2 +0 -0
  15. package/fonts/geist/geist-cyrillic-wght-normal.woff2 +0 -0
  16. package/fonts/geist/geist-latin-ext-wght-normal.woff2 +0 -0
  17. package/fonts/geist/geist-latin-wght-normal.woff2 +0 -0
  18. package/fonts/geist/geist-vietnamese-wght-normal.woff2 +0 -0
  19. package/fonts/geist-mono/LICENSE.txt +93 -0
  20. package/fonts/geist-mono/geist-mono-cyrillic-ext-wght-normal.woff2 +0 -0
  21. package/fonts/geist-mono/geist-mono-cyrillic-wght-normal.woff2 +0 -0
  22. package/fonts/geist-mono/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  23. package/fonts/geist-mono/geist-mono-latin-wght-normal.woff2 +0 -0
  24. package/fonts/geist-mono/geist-mono-symbols2-wght-normal.woff2 +0 -0
  25. package/fonts/geist-mono/geist-mono-vietnamese-wght-normal.woff2 +0 -0
  26. package/fonts/manifest.json +674 -0
  27. package/fonts/pretendard/LICENSE.txt +94 -0
  28. package/fonts/pretendard/PretendardVariable.subset.0.woff2 +0 -0
  29. package/fonts/pretendard/PretendardVariable.subset.1.woff2 +0 -0
  30. package/fonts/pretendard/PretendardVariable.subset.10.woff2 +0 -0
  31. package/fonts/pretendard/PretendardVariable.subset.11.woff2 +0 -0
  32. package/fonts/pretendard/PretendardVariable.subset.12.woff2 +0 -0
  33. package/fonts/pretendard/PretendardVariable.subset.13.woff2 +0 -0
  34. package/fonts/pretendard/PretendardVariable.subset.14.woff2 +0 -0
  35. package/fonts/pretendard/PretendardVariable.subset.15.woff2 +0 -0
  36. package/fonts/pretendard/PretendardVariable.subset.16.woff2 +0 -0
  37. package/fonts/pretendard/PretendardVariable.subset.17.woff2 +0 -0
  38. package/fonts/pretendard/PretendardVariable.subset.18.woff2 +0 -0
  39. package/fonts/pretendard/PretendardVariable.subset.19.woff2 +0 -0
  40. package/fonts/pretendard/PretendardVariable.subset.2.woff2 +0 -0
  41. package/fonts/pretendard/PretendardVariable.subset.20.woff2 +0 -0
  42. package/fonts/pretendard/PretendardVariable.subset.21.woff2 +0 -0
  43. package/fonts/pretendard/PretendardVariable.subset.22.woff2 +0 -0
  44. package/fonts/pretendard/PretendardVariable.subset.23.woff2 +0 -0
  45. package/fonts/pretendard/PretendardVariable.subset.24.woff2 +0 -0
  46. package/fonts/pretendard/PretendardVariable.subset.25.woff2 +0 -0
  47. package/fonts/pretendard/PretendardVariable.subset.26.woff2 +0 -0
  48. package/fonts/pretendard/PretendardVariable.subset.27.woff2 +0 -0
  49. package/fonts/pretendard/PretendardVariable.subset.28.woff2 +0 -0
  50. package/fonts/pretendard/PretendardVariable.subset.29.woff2 +0 -0
  51. package/fonts/pretendard/PretendardVariable.subset.3.woff2 +0 -0
  52. package/fonts/pretendard/PretendardVariable.subset.30.woff2 +0 -0
  53. package/fonts/pretendard/PretendardVariable.subset.31.woff2 +0 -0
  54. package/fonts/pretendard/PretendardVariable.subset.32.woff2 +0 -0
  55. package/fonts/pretendard/PretendardVariable.subset.33.woff2 +0 -0
  56. package/fonts/pretendard/PretendardVariable.subset.34.woff2 +0 -0
  57. package/fonts/pretendard/PretendardVariable.subset.35.woff2 +0 -0
  58. package/fonts/pretendard/PretendardVariable.subset.36.woff2 +0 -0
  59. package/fonts/pretendard/PretendardVariable.subset.37.woff2 +0 -0
  60. package/fonts/pretendard/PretendardVariable.subset.38.woff2 +0 -0
  61. package/fonts/pretendard/PretendardVariable.subset.39.woff2 +0 -0
  62. package/fonts/pretendard/PretendardVariable.subset.4.woff2 +0 -0
  63. package/fonts/pretendard/PretendardVariable.subset.40.woff2 +0 -0
  64. package/fonts/pretendard/PretendardVariable.subset.41.woff2 +0 -0
  65. package/fonts/pretendard/PretendardVariable.subset.42.woff2 +0 -0
  66. package/fonts/pretendard/PretendardVariable.subset.43.woff2 +0 -0
  67. package/fonts/pretendard/PretendardVariable.subset.44.woff2 +0 -0
  68. package/fonts/pretendard/PretendardVariable.subset.45.woff2 +0 -0
  69. package/fonts/pretendard/PretendardVariable.subset.46.woff2 +0 -0
  70. package/fonts/pretendard/PretendardVariable.subset.47.woff2 +0 -0
  71. package/fonts/pretendard/PretendardVariable.subset.48.woff2 +0 -0
  72. package/fonts/pretendard/PretendardVariable.subset.49.woff2 +0 -0
  73. package/fonts/pretendard/PretendardVariable.subset.5.woff2 +0 -0
  74. package/fonts/pretendard/PretendardVariable.subset.50.woff2 +0 -0
  75. package/fonts/pretendard/PretendardVariable.subset.51.woff2 +0 -0
  76. package/fonts/pretendard/PretendardVariable.subset.52.woff2 +0 -0
  77. package/fonts/pretendard/PretendardVariable.subset.53.woff2 +0 -0
  78. package/fonts/pretendard/PretendardVariable.subset.54.woff2 +0 -0
  79. package/fonts/pretendard/PretendardVariable.subset.55.woff2 +0 -0
  80. package/fonts/pretendard/PretendardVariable.subset.56.woff2 +0 -0
  81. package/fonts/pretendard/PretendardVariable.subset.57.woff2 +0 -0
  82. package/fonts/pretendard/PretendardVariable.subset.58.woff2 +0 -0
  83. package/fonts/pretendard/PretendardVariable.subset.59.woff2 +0 -0
  84. package/fonts/pretendard/PretendardVariable.subset.6.woff2 +0 -0
  85. package/fonts/pretendard/PretendardVariable.subset.60.woff2 +0 -0
  86. package/fonts/pretendard/PretendardVariable.subset.61.woff2 +0 -0
  87. package/fonts/pretendard/PretendardVariable.subset.62.woff2 +0 -0
  88. package/fonts/pretendard/PretendardVariable.subset.63.woff2 +0 -0
  89. package/fonts/pretendard/PretendardVariable.subset.64.woff2 +0 -0
  90. package/fonts/pretendard/PretendardVariable.subset.65.woff2 +0 -0
  91. package/fonts/pretendard/PretendardVariable.subset.66.woff2 +0 -0
  92. package/fonts/pretendard/PretendardVariable.subset.67.woff2 +0 -0
  93. package/fonts/pretendard/PretendardVariable.subset.68.woff2 +0 -0
  94. package/fonts/pretendard/PretendardVariable.subset.69.woff2 +0 -0
  95. package/fonts/pretendard/PretendardVariable.subset.7.woff2 +0 -0
  96. package/fonts/pretendard/PretendardVariable.subset.70.woff2 +0 -0
  97. package/fonts/pretendard/PretendardVariable.subset.71.woff2 +0 -0
  98. package/fonts/pretendard/PretendardVariable.subset.72.woff2 +0 -0
  99. package/fonts/pretendard/PretendardVariable.subset.73.woff2 +0 -0
  100. package/fonts/pretendard/PretendardVariable.subset.74.woff2 +0 -0
  101. package/fonts/pretendard/PretendardVariable.subset.75.woff2 +0 -0
  102. package/fonts/pretendard/PretendardVariable.subset.76.woff2 +0 -0
  103. package/fonts/pretendard/PretendardVariable.subset.77.woff2 +0 -0
  104. package/fonts/pretendard/PretendardVariable.subset.78.woff2 +0 -0
  105. package/fonts/pretendard/PretendardVariable.subset.79.woff2 +0 -0
  106. package/fonts/pretendard/PretendardVariable.subset.8.woff2 +0 -0
  107. package/fonts/pretendard/PretendardVariable.subset.80.woff2 +0 -0
  108. package/fonts/pretendard/PretendardVariable.subset.81.woff2 +0 -0
  109. package/fonts/pretendard/PretendardVariable.subset.82.woff2 +0 -0
  110. package/fonts/pretendard/PretendardVariable.subset.83.woff2 +0 -0
  111. package/fonts/pretendard/PretendardVariable.subset.84.woff2 +0 -0
  112. package/fonts/pretendard/PretendardVariable.subset.85.woff2 +0 -0
  113. package/fonts/pretendard/PretendardVariable.subset.86.woff2 +0 -0
  114. package/fonts/pretendard/PretendardVariable.subset.87.woff2 +0 -0
  115. package/fonts/pretendard/PretendardVariable.subset.88.woff2 +0 -0
  116. package/fonts/pretendard/PretendardVariable.subset.89.woff2 +0 -0
  117. package/fonts/pretendard/PretendardVariable.subset.9.woff2 +0 -0
  118. package/fonts/pretendard/PretendardVariable.subset.90.woff2 +0 -0
  119. package/fonts/pretendard/PretendardVariable.subset.91.woff2 +0 -0
  120. package/fonts.css +937 -0
  121. package/package.json +10 -5
  122. package/src/core/consent.d.mts +160 -0
  123. package/src/core/consent.mjs +567 -0
  124. package/src/core/fonts.d.mts +5 -0
  125. package/src/core/fonts.mjs +39 -0
  126. package/src/core/index.d.mts +2 -0
  127. package/src/core/index.mjs +5 -0
  128. package/src/tanstack-start.d.mts +14 -2
  129. package/src/tanstack-start.mjs +39 -1
  130. package/styles.css +45 -2
package/README.ko.md CHANGED
@@ -4,10 +4,11 @@ DevsLab 제품의 공개 웹사이트를 위한 공개 인프라 패키지다.
4
4
 
5
5
  ## 진입점
6
6
 
7
- - `@devslab/site-kit` — 런타임 중립 로케일·카탈로그·SEO·사이트맵·robots·검증된 사실·Google Tag Manager 유틸리티
8
- - `@devslab/site-kit/solid` — 헤더·푸터·언어/테마 컨트롤·마케팅/법률/상태/오류 레이아웃·접근 요청 폼
9
- - `@devslab/site-kit/tanstack-start` — 중립 메타데이터를 TanStack Start head descriptor로 변환(브랜드 아이콘·Tag Manager 로더는 옵트인)
7
+ - `@devslab/site-kit` — 런타임 중립 로케일·카탈로그·SEO·사이트맵·robots·검증된 사실·Google Tag Manager·분석 동의 유틸리티
8
+ - `@devslab/site-kit/solid` — 헤더·푸터·언어/테마 컨트롤·마케팅/법률/상태/오류 레이아웃·접근 요청 폼·동의 바
9
+ - `@devslab/site-kit/tanstack-start` — 중립 메타데이터를 TanStack Start head descriptor로 변환(브랜드 아이콘과 동의 게이트를 거친 Tag Manager 로더는 옵트인)
10
10
  - `@devslab/site-kit/styles.css` — 논리 속성과 RTL을 지원하는 공통 사이트 스타일. 페이지 뿌리에 가족 서체(`:where(html) { font-family: var(--dds-font-family-sans) }`)도 주어 맨 제목·문단이 브라우저 세리프로 남지 않는다. 명시도가 0이라 제품이 `html`·`:root`·`body`·`:lang()`에 둔 규칙이 이긴다.
11
+ - `@devslab/site-kit/fonts.css` — 가족 서체(Geist·Geist Mono·Pretendard)를 토큰의 패밀리 이름으로 등록한 자체 호스팅 woff2. [가족 서체](#가족-서체) 참고
11
12
 
12
13
  카탈로그 생성은 의도적으로 엄격하다. 레지스트리의 모든 로케일이 동일한 키와 이름 기반 placeholder를 가져야 하며 런타임 문구 폴백은 없다.
13
14
 
@@ -55,6 +56,7 @@ claim leaf가 검증된 사실 레지스트리를 참조하도록 강제한다.
55
56
  - 터치(`pointer: coarse`): 브랜드 링크·내비게이션·푸터·푸터 언어 링크가 버튼처럼 44px 누르는 면이다. 720px 이하에서 열린 메뉴의 링크는 44px 줄이고, 닫힌 헤더의 첫 줄은 64px 그대로다.
56
57
  - `SiteFooter`의 `details`: 브랜드 줄 아래 블록(사업자 정보, `<address>`), 줄 간격 4px·문단 여백 없음. `linksLabel`은 링크를 `<nav aria-label>`로 감싼다 — 헤더 내비게이션과 다른 이름으로. `logo`로 넘긴 푸터 워드마크(`name: ""`)는 자기 크기를 지킨다 — 16px은 이름 옆 아이콘 마크용 — 그리고 접근 가능한 이름을 스스로 가진다(`<img alt>`나 글자). `label`은 헤더 링크의 이름일 뿐이다.
57
58
  - `SiteLink.emphasis`는 링크를 굵게 그린다(헤더 내비게이션·푸터 링크·패밀리 링크). 법이 눈에 띄게 하라는 개인정보처리방침용.
59
+ - `SiteFooter` `consentSettings: { controller, label }`는 링크 뒤, 저작권 앞에 동의 설정을 다시 여는 "쿠키 설정" 버튼을 둔다(D-034). 다른 링크와 같아 보이고 터치에서는 44px 대상이다. [동의](#동의-옵트인-분석) 참고.
58
60
  - 섹션(그리고 히어로)은 같은 페이지 링크로 이동하면 붙어 있는 헤더 아래에 멈춘다: `scroll-margin-block-start` = 헤더 높이 + 8px. 헤더 높이는 `--site-header-block-size`(기본 64px) — `:root`나 `.site-shell`(헤더와 `<main>`의 공통 조상)에 정한다. `.site-header`에 정하면 섹션은 64px 간격 그대로다.
59
61
  - `--site-hero-eyebrow-tracking`(기본 `.18em`)과 `--site-hero-eyebrow-weight`(기본 `normal`)로 히어로 키커를 조정한다. 넓은 모노 자간은 라틴 대문자에 맞고, 키커가 한국어인 제품은 랜딩 뿌리에서 자간을 `0`으로 둔다.
60
62
 
@@ -62,6 +64,73 @@ claim leaf가 검증된 사실 레지스트리를 참조하도록 강제한다.
62
64
 
63
65
  타입 메모: `SiteHeaderProps["locale"]`은 이제 `LocaleState | undefined`다. `SiteHeaderProps` 값에서 읽는 코드(`header.locale.locale`)는 좁혀야 한다 — 예를 들어 제품이 늘 넘긴다면 그 값을 `SiteHeaderProps & { locale: LocaleState }`로 타입한다.
64
66
 
67
+ ## 가족 서체
68
+
69
+ 가족 서체 — 라틴·숫자는 Geist, 코드·라벨은 Geist Mono, 한글은 Pretendard — 를 woff2 파일과 스타일시트 하나로 여기서 한 번만 싣는다(D-033). 모든 제품이 같은 파일을 자기 도메인에서 서빙하므로 `font-src 'self'` CSP가 그대로 유지되고, 글꼴 요청이 제품 도메인 밖으로 나가지 않는다.
70
+
71
+ | 패밀리 | 파일 | 원본 | 라이선스 |
72
+ |---|---|---|---|
73
+ | `Geist` | 문자권별 5개(latin 29 KB, latin-ext, vietnamese, cyrillic, cyrillic-ext), 가변 100–900 | `@fontsource-variable/geist` 5.3.0 | SIL OFL 1.1 — `fonts/geist/LICENSE.txt` |
74
+ | `Geist Mono` | 6개(latin 23 KB, …, symbols2), 가변 100–900 | `@fontsource-variable/geist-mono` 5.3.0 | SIL OFL 1.1 — `fonts/geist-mono/LICENSE.txt` |
75
+ | `Pretendard` | 동적 서브셋 92개(각 8–44 KB, 전체 2.9 MB), 가변 45–920 | `pretendard` 1.3.9 | SIL OFL 1.1 — `fonts/pretendard/LICENSE.txt` |
76
+
77
+ **패밀리 이름이 곧 토큰의 이름이다.** `--dds-font-family-sans`는 `Geist, Pretendard, …`, `--dds-font-family-mono`는 `'Geist Mono', …`이고, `fonts.css`는 정확히 `"Geist"`·`"Pretendard"`·`"Geist Mono"`를 등록한다. 스타일시트를 불러오기만 하면 토큰(과 `styles.css`의 페이지 뿌리 규칙)이 이름을 바꾸지 않고 이 파일들로 이어진다. `"Geist Variable"`·`"Pretendard Variable"`이 아니다: 그것은 원본 패키지가 자기 사본에 붙인 이름이고, 그 이름을 적은 스택은 이 파일들을 집지 않는다.
78
+
79
+ **페이지는 그리는 글자만큼만 받는다.** 모든 face에 `unicode-range`가 있어, 페이지에 그 범위의 글자가 있을 때만 파일을 받는다. 영어 페이지는 Geist latin(29 KB)만 받는다. 한국어 랜딩은 Pretendard 서브셋 12–16개를 더 받는다 — getasklinq.app 305 KB, gettracelinq.app/ko 347 KB, getbooklinq.app 한국어 424 KB(Chromium 실측) — 2 MB짜리 단일 `PretendardVariable.woff2` 대신. 모든 face가 `font-display: swap`이라 글자는 폴백 서체로 바로 그려지고 face가 도착하면 바뀐다.
80
+
81
+ ### 적용 (Vite, Workers 위의 TanStack Start)
82
+
83
+ 모든 페이지가 불러오는 CSS(또는 엔트리)에서 스타일시트를 한 번 import한다.
84
+
85
+ ```css
86
+ /* src/styles/app.css */
87
+ @import "@devslab/site-kit/fonts.css";
88
+ ```
89
+
90
+ Vite가 상대 `url()`을 따라 `node_modules/@devslab/site-kit/fonts/`의 face를 빌드의 `assets/`로 해시 이름을 붙여 복사하고, 스타일시트를 `/assets/…woff2`로 고쳐 쓴다. Worker가 그것을 제품 자기 도메인의 정적 자산으로 서빙한다. 모든 face가 Vite의 인라인 한도(4 KB)보다 커서 `data:` URI(`font-src 'self'`가 막는다)가 되는 것은 없다 — 제품이 `build.assetsInlineLimit`을 올렸다면 글꼴은 빼 둔다: `assetsInlineLimit: (file) => (file.endsWith(".woff2") ? false : undefined)`.
91
+
92
+ 모든 페이지가 쓰는 라틴 face는 미리 받아 둘 수 있다. `?url`로 import하면 스타일시트가 쓰게 되는 바로 그 해시 URL이 나오고, 그것을 head에 넘긴다.
93
+
94
+ ```ts
95
+ import geistLatin from "@devslab/site-kit/fonts/geist/geist-latin-wght-normal.woff2?url";
96
+
97
+ head: () => toTanStackHead(metadata, { icons: true, fontPreload: geistLatin }),
98
+ ```
99
+
100
+ `fontPreload`는 `fontPreloadLinks(geistLatin)` — `{ rel: "preload", as: "font", type: "font/woff2", crossorigin: "anonymous" }` — 를 덧붙인다. 같은 도메인이어도 `crossorigin`은 필요하다: 글꼴은 CORS 모드로 받기 때문에, 이것이 없는 preload는 한 번 더 내려받힌다. `.woff2`로 끝나는 같은 도메인 경로만 받는다. `FAMILY_FONT_PRELOAD_FILE`이 패키지 안의 파일 경로다. 다른 것은 preload하지 않는다: 어느 Pretendard 서브셋이 필요한지는 페이지 글자에 달렸다.
101
+
102
+ ### 번들러 없이
103
+
104
+ `fonts.css`와 `fonts/`를 구조를 유지한 채 나란히, 사이트가 서빙하는 디렉터리에 복사하고 스타일시트를 링크한다.
105
+
106
+ ```js
107
+ // scripts/copy-fonts.mjs
108
+ import { cpSync } from "node:fs";
109
+ import { dirname, join } from "node:path";
110
+ import { createRequire } from "node:module";
111
+
112
+ const kit = dirname(createRequire(import.meta.url).resolve("@devslab/site-kit/fonts.css"));
113
+ cpSync(join(kit, "fonts.css"), "public/site-kit/fonts.css");
114
+ cpSync(join(kit, "fonts"), "public/site-kit/fonts", { recursive: true });
115
+ ```
116
+
117
+ ```html
118
+ <link rel="stylesheet" href="/site-kit/fonts.css">
119
+ ```
120
+
121
+ `url()`은 `fonts.css` 기준 상대 경로라, 둘을 어디에 복사하든 `/site-kit/fonts/…`로 풀린다. 함께 복사되는 `fonts/manifest.json`에 파일마다 원본 패키지·버전·크기·sha256이 적혀 있다.
122
+
123
+ ### 제품 자체 사본에서 옮겨 오기
124
+
125
+ 1. 제품의 Geist·Geist Mono·Pretendard `@font-face` 규칙, woff2를 `public/fonts/`로 복사하는 스크립트(와 `.gitignore` 줄), `@fontsource-variable/*` import, 그리고 `@fontsource-variable/geist`·`@fontsource-variable/geist-mono`·`pretendard` 의존성을 지운다.
126
+ 2. 위처럼 `@devslab/site-kit/fonts.css`를 import한다.
127
+ 3. 제품 자체 스택에서 `"Geist Variable"` → `Geist`, `"Geist Mono Variable"` → `"Geist Mono"`, `"Pretendard Variable"` → `Pretendard`로 바꾼다 — 또는 토큰 스택을 되풀이할 뿐인 뿌리 규칙은 지운다: `styles.css`가 이미 `:where(html) { font-family: var(--dds-font-family-sans) }`를 준다(D-032). `:lang(ko) { font-family: Pretendard, Geist, … }` 같은 한글 우선 순서는 제품의 선택으로 남고, 이 face들로 그대로 동작한다.
128
+ 4. 손으로 쓴 `<link rel="preload" href="/fonts/geist.woff2">`는 `fontPreload`로 바꾸고, 옛 이름으로 글꼴을 불러오거나 확인하는 테스트(`document.fonts.check('1rem "Pretendard Variable"')` → `"Pretendard"`)를 고친다.
129
+
130
+ ### 라이선스
131
+
132
+ 세 패밀리 모두 SIL Open Font License 1.1이다. `fonts/` 아래 디렉터리마다 라이선스 원문이 있고, 패키지는 파일을 수정 없이 재배포한다. Pretendard는 글꼴 이름을 예약(Reserved Font Name)했으므로 수정본(예: 다른 사람이 잘라 낸 서브셋)은 Pretendard라고 부를 수 없다: 이 파일들은 저작자 자신의 서브셋이고, `scripts/build-fonts.mjs`는 배포된 패키지에서 바이트 그대로 복사할 뿐 다시 자르지 않는다. `node scripts/build-fonts.mjs --check`(`check`에 포함)는 파일이 매니페스트와 다르면 실패하고, `--vendor`는 버전을 올릴 때 고정된 원본 tarball을 npm integrity를 확인하며 다시 받는다.
133
+
65
134
  ## 브랜드 아이콘
66
135
 
67
136
  모든 제품의 아이콘 파일은 `@devslab/linq-brand`(`dist/<product>/`)에서 온다. `brandIconLinks()`는 그중 어떤 파일을 페이지 head가 어떤 순서로 링크하는지 정하는 유일한 자리다.
@@ -86,6 +155,8 @@ toTanStackHead(metadata, { icons: { basePath: "/" } }); // /favicon.svg, /mark-4
86
155
 
87
156
  제품 사이트마다 자기 컨테이너 ID로 공개 마케팅·법적 페이지에 Tag Manager를 싣는다. 콘솔·대시보드·채팅 위젯은 제외한다. 스니펫은 여기 한 곳에 둔다(D-031).
88
157
 
158
+ **분석은 옵트인이다(D-034).** 아래의 `toTanStackHead(…, { gtm })`와 `gtmHeadEntry`는 묻지 않고 페이지를 열 때마다 Tag Manager를 로드한다. 호환을 위해 남겨 두며, 가족 제품은 [동의](#동의-옵트인-분석)의 동의 게이트 경로로 옮긴다. 같은 로더를 허용 뒤에만 낸다.
159
+
89
160
  | 내보내기 | 진입점 | 무엇인가 |
90
161
  |---|---|---|
91
162
  | `gtmHeadScript(id)` | `@devslab/site-kit` | head 로더의 스크립트 본문. `<script>` 태그도 nonce도 없음 |
@@ -133,6 +204,198 @@ head: () => toTanStackHead(metadata, { icons: true, gtm: GTM_ID }),
133
204
 
134
205
  Tag Manager 컨테이너와 "Ads 기능 없는 Google Analytics"에 대한 구글의 목록에, noscript iframe용 `frame-src`를 더한 것이다. 구글은 `script-src-elem`으로 적지만 그 지시어가 없는 정책은 `script-src`로 넘어간다. `*.google.com`은 `www.google.com`과 GA4의 `*.analytics.google.com` 호스트도 덮는다. 포함하지 않은 것(컨테이너가 필요로 하면 같은 가이드에서 더한다): 미리보기 모드(`tagmanager.google.com`, `gstatic`, Google Fonts), 맞춤 자바스크립트 변수(`'unsafe-eval'`), Ads·Google 신호 호스트(`*.g.doubleclick.net`, `pagead2.googlesyndication.com`, `www.googleadservices.com`, `*.google.<TLD>`).
135
206
 
207
+ ## 동의 (옵트인 분석)
208
+
209
+ 가족 사이트의 분석은 옵트인이다(D-034). 방문자가 현재 정책 버전에 대해 분석을 허용하기 전에는 구글에 닿는 것이 하나도 돌지 않는다. Tag Manager 로더도, gtag도, GA 쿠키도 없다. 페이지는 Consent Mode v2 기본값(신호 넷 모두 거부)을 자기 `dataLayer`에 넣을 뿐이다. 결정 없음, "거부", 닫아 버린 바, 예전 정책 버전에서 한 결정은 모두 같은 뜻이다. 아무것도 로드하지 않는다. 동의하지 않아도 모든 기능이 동작한다.
210
+
211
+ | 내보내기 | 진입점 | 무엇인가 |
212
+ |---|---|---|
213
+ | `createConsentManager(options)` | `@devslab/site-kit` | 브라우저 쪽: 동의 쿠키 읽기·쓰기, Consent Mode, 허용 시 Tag Manager 로드, 철회 |
214
+ | `readConsentCookie(cookie, { policyVersion })` / `consentCookieGrantsAnalytics(…)` | `@devslab/site-kit` | Cookie 헤더나 `document.cookie`에서 현재 버전의 유효한 결정(서버·브라우저 모두) |
215
+ | `consentHeadScript({ granted, gtm })` | `@devslab/site-kit` | head 스크립트 본문: 기본값만, 또는 기본값 + 허용 + 구글 로더 |
216
+ | `toTanStackHead(metadata, { consent })` / `consentHeadEntry(…)` | `@devslab/site-kit/tanstack-start` | 같은 스크립트를 라우트 `scripts` 항목으로. 요청의 쿠키로 정한다 |
217
+ | `CONSENT_RESPONSE_HEADERS` | 둘 다 | 쿠키로 head를 정한 모든 응답에 보낼 `Cache-Control: private, no-store`와 `Vary: Cookie` |
218
+ | `ConsentBanner`, `ConsentSettingsButton` | `@devslab/site-kit/solid` | 바와 설정 대화상자, 그리고 어디에나 둘 수 있는 "쿠키 설정" 버튼 |
219
+ | `SiteFooter consentSettings` | `@devslab/site-kit/solid` | 같은 버튼을 바닥글 링크 끝에 |
220
+ | `postConsentRecord(path)`, `parseConsentRecord(body, …)`, `isSameOriginRequest(…)` | `@devslab/site-kit` | 기록: 보내기, 서버에서 검증하기, 다른 사이트의 쓰기 거부하기 |
221
+ | `CONSENT_MESSAGES_KO`, `CONSENT_MESSAGES_EN` | `@devslab/site-kit` | 기본 문구. 같은 키로 자기 문구를 넘겨도 된다 |
222
+
223
+ **약속.**
224
+
225
+ - 항목: *필수*(항상 켜짐, 정보로만 보여 주고 조작 요소가 아님)와 *분석*(Tag Manager를 거친 Google Analytics 4. 켜기 전까지 꺼짐, 미리 체크하지 않음). 광고 항목은 없다. `ad_storage`·`ad_user_data`·`ad_personalization`은 늘 거부.
226
+ - 쿠키: `site_consent=v=<정책>&a=<0|1>&t=<유닉스 초>&id=<16진 32자>`. 퍼스트파티, `Path=/`, `SameSite=Lax`, `Secure`, 12개월, `HttpOnly` 아님(배너가 읽는다). `id`는 무작위이고 방문자에게서 끌어낸 값이 아니다. 파서는 이 네 키만 받는다.
227
+ - 정책 버전은 제품마다 문자열 하나(`"2026-10-05"`). 개인정보처리방침이 분석에 대해 하는 말이 바뀌면 올린다. 모든 방문자에게 다시 묻고, 답하기 전에는 아무것도 로드하지 않는다. 익명 id는 이어지므로 기록끼리 연결된다.
228
+ - 허용하면 `gtag('consent','update',{analytics_storage:'granted'})`를 넣고 Tag Manager를 한 번, 페이지의 nonce로 로드한다(`csp-nonce` meta, 없으면 첫 `[nonce]` 요소, 없으면 `options.nonce`).
229
+ - 철회하면 업데이트를 `denied`로 되돌리고, `measurementIds`의 모든 id에 `ga-disable-<id>`를 켜고(`gtm`을 주면 필수 — 이미 초기화된 GA4 태그의 자체 리스너(히스토리 페이지뷰·스크롤·외부 링크 클릭)를 멈추는 유일한 스위치라, 없으면 새로고침 전까지 쿠키 없는 핑이 계속 나간다. 없으면 `createConsentManager`가 던진다), 호스트와 모든 상위 도메인에서 `_ga`·`_ga_*`·`_gid`·`_gat*`를 지우고, `dataLayer`를 닫는다. 분석이 허용되지 않은 동안 페이지의 `dataLayer`는 동의 명령만 받고 나머지는 버린다. 그래서 동의 전에 넣은 이벤트가 나중에 로드될 Tag Manager를 기다리며 쌓이지 않고, 철회 뒤의 이벤트는 이미 로드된 Tag Manager에 닿지 않는다. Tag Manager 자체는 다음 페이지 로드까지 메모리에 남는다.
230
+ - 기록의 action: `grant`(이 버전에서 분석이 허용됨), `deny`(첫 결정이 거부), `withdraw`(허용 → 거부), `update`(같은 선택을 다시 저장).
231
+
232
+ **TanStack Start.** 매니저는 모듈 하나가 갖는다.
233
+
234
+ ```ts
235
+ // src/consent.ts
236
+ import { createConsentManager, postConsentRecord } from "@devslab/site-kit";
237
+
238
+ export const CONSENT_POLICY_VERSION = "2026-10-05"; // 개인정보처리방침의 분석 조항이 바뀌면 올린다
239
+ export const GTM_ID = "GTM-XXXXXXX";
240
+ export const consent = createConsentManager({
241
+ policyVersion: CONSENT_POLICY_VERSION,
242
+ gtm: GTM_ID,
243
+ measurementIds: ["G-XXXXXXXXXX"], // 컨테이너가 보내는 GA4 스트림 전부. gtm을 주면 필수
244
+ onChange: postConsentRecord("/api/consent"),
245
+ });
246
+ ```
247
+
248
+ 공개 라우트마다 요청의 쿠키로 head를 정한다. 하이드레이션 양쪽이 같은 쿠키를 읽으므로 같은 스크립트를 렌더한다.
249
+
250
+ ```ts
251
+ import { createIsomorphicFn } from "@tanstack/solid-start";
252
+ import { getRequestHeader } from "@tanstack/solid-start/server";
253
+
254
+ export const requestCookie = createIsomorphicFn()
255
+ .server(() => getRequestHeader("cookie"))
256
+ .client(() => document.cookie);
257
+
258
+ head: () => toTanStackHead(metadata, {
259
+ icons: true,
260
+ consent: { policyVersion: CONSENT_POLICY_VERSION, gtm: GTM_ID, cookie: requestCookie() },
261
+ }),
262
+ ```
263
+
264
+ 현재 버전의 허용이 없으면 항목은 기본값뿐이고 head 어디에도 구글 호스트가 나오지 않는다. 허용이 있으면 구글의 nonce 대응 로더를 더하되, `gtm.js`가 이미 페이지에 있으면 건너뛴다. 라우터는 맨 로더와 똑같이 `ssr.nonce`를 찍는다([Google Tag Manager](#google-tag-manager)의 nonce 요구 사항이 그대로 적용된다). `gtm`과 `consent`를 같이 넘기면 던진다. `gtm`만 쓰면 묻지 않고 Tag Manager를 로드하기 때문이다. noscript iframe은 `consentCookieGrantsAnalytics(requestCookie(), { policyVersion })`가 참일 때만 렌더한다. 자바스크립트가 없는 방문자는 허용할 방법이 없으니 받지 않는다.
265
+
266
+ **이 페이지는 공유 캐시에 절대 들어가면 안 된다.** head가 방문자의 쿠키에 따라 달라진다. 허용한 방문자의 HTML에는 Tag Manager 로더가 들어 있어서, CDN이나 엣지 캐시가 그것을 저장하면 동의하지 않은 방문자도 로더를 받는다 — 허용 전에 구글에 닿는 것이다. `consent`를 쓰는 라우트는 모두 `CONSENT_RESPONSE_HEADERS`(`Cache-Control: private, no-store`와 `Vary: Cookie`)를 보낸다.
267
+
268
+ ```ts
269
+ import { CONSENT_RESPONSE_HEADERS } from "@devslab/site-kit/tanstack-start";
270
+
271
+ export const Route = createRootRoute({
272
+ headers: () => ({ ...CONSENT_RESPONSE_HEADERS }),
273
+ head: () => toTanStackHead(metadata, { consent: { … } }),
274
+ });
275
+ ```
276
+
277
+ 캐시되어야 하는 라우트는 모두에게 거부 쪽 head(허용 없는 `consentHeadScript()`)를 렌더하고, 아래 정적 내보내기처럼 하이드레이션 뒤에 매니저가 Tag Manager를 로드하게 한다.
278
+
279
+ 배너는 `<body>`의 첫 요소로 한 번만 붙여 키보드 사용자가 가장 먼저 닿게 하고, 바닥글에 트리거를 둔다.
280
+
281
+ ```tsx
282
+ <body>
283
+ <ConsentBanner controller={consent} messages={lang() === "ko" ? CONSENT_MESSAGES_KO : CONSENT_MESSAGES_EN} privacyHref={`/${lang()}/privacy`} />
284
+ <MarketingShell
285
+ footer={{ …, consentSettings: { controller: consent, label: t("cookieSettings") } }}
286
+ …
287
+ />
288
+ </body>
289
+ ```
290
+
291
+ 배너는 서버에서도, 하이드레이션 중에도 아무것도 렌더하지 않는다. 바는 마운트 뒤에 브라우저 자신의 쿠키를 보고 나온다(`tests/site-kit-consent-hydration.test.mjs`가 바로 이 구성을 개발 빌드로 하이드레이션한다). 세 선택 — 모두 허용, 거부, 설정 — 은 같은 버튼, 같은 크기다. ✕와 Esc는 결정 없이 닫는다. 설정 대화상자는 포커스를 가두고, Esc로 저장 없이 닫히고, *필수*는 글로, *분석*은 꺼진 스위치로 보여 준다. 바닥글 버튼(또는 `ConsentSettingsButton`, 또는 `consent.openSettings()`)으로 언제든 다시 열어 바꾸거나 철회한다.
292
+
293
+ 비밀을 담은 경로는 가려서 기록한다: `createConsentManager({ …, recordPath: (path) => path.replace(/\/k\/[^/]+/, "/k/:key") })`.
294
+
295
+ **동의 기록(서버).** 결정마다 제품 자기 백엔드로 기록을 POST한다.
296
+
297
+ ```json
298
+ { "policyVersion": "2026-10-05", "analytics": true, "action": "grant", "anonymousId": "<16진 32자>", "decidedAt": 1759650000, "source": "web", "path": "/ko/pricing" }
299
+ ```
300
+
301
+ 이 엔드포인트가 추적 통로가 되면 안 된다. 같은 출처만, 정확히 이 페이로드만, 요청 수 제한.
302
+
303
+ ```ts
304
+ import { CONSENT_RECORD_MAX_BYTES, isSameOriginRequest, parseConsentRecord } from "@devslab/site-kit";
305
+
306
+ if (request.method !== "POST") return new Response(null, { status: 405 });
307
+ if (!isSameOriginRequest(request, "https://getasklinq.app")) return new Response(null, { status: 403 });
308
+ if (Number(request.headers.get("content-length") ?? 0) > CONSENT_RECORD_MAX_BYTES) return new Response(null, { status: 413 });
309
+ const ip = request.headers.get("cf-connecting-ip") ?? "";
310
+ if (!(await env.CONSENT_RATE_LIMIT.limit({ key: ip })).success) return new Response(null, { status: 429 });
311
+ const record = parseConsentRecord(await request.text(), { policyVersion: CONSENT_POLICY_VERSION });
312
+ if (!record) return new Response(null, { status: 400 });
313
+ await insertConsentRecord(env, { ...record, receivedAt: new Date().toISOString(), ip, userAgent: (request.headers.get("user-agent") ?? "").slice(0, 256) });
314
+ return new Response(null, { status: 204 });
315
+ ```
316
+
317
+ `parseConsentRecord`는 정확히 일곱 키, 아는 정책 버전(이전 버전에서 만든 기록이 아직 올 수 있는 동안에는 목록으로 넘긴다), `analytics`와 맞는 action, 쿼리 없는 경로, 미래도 아니고 31일보다 오래되지도 않은 `decidedAt`만 받는다. 실패한 POST(네트워크·429·5xx)는 `localStorage`에 남았다가 다음 페이지에서 다시 보낸다. 4xx는 버린다. 참고용 테이블(애플리케이션에는 추가만 허용):
318
+
319
+ ```sql
320
+ CREATE TABLE consent_records (
321
+ id TEXT PRIMARY KEY, -- 서버가 만든 값
322
+ received_at TEXT NOT NULL, -- 서버 시각, UTC
323
+ decided_at INTEGER NOT NULL, -- 기록의 decidedAt (쿠키의 t)
324
+ subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'anonymous')),
325
+ subject_id TEXT NOT NULL, -- 로그인했으면 내부 사용자 id, 아니면 익명 id
326
+ login_id TEXT, -- 로그인했을 때의 로그인 id 사본
327
+ anonymous_id TEXT NOT NULL,
328
+ policy_version TEXT NOT NULL,
329
+ analytics INTEGER NOT NULL CHECK (analytics IN (0, 1)),
330
+ action TEXT NOT NULL CHECK (action IN ('grant', 'deny', 'withdraw', 'update')),
331
+ source TEXT NOT NULL CHECK (source IN ('web', 'app')),
332
+ path TEXT NOT NULL,
333
+ ip TEXT,
334
+ user_agent TEXT -- 앞 256자
335
+ );
336
+ CREATE INDEX consent_records_by_visitor ON consent_records (anonymous_id, decided_at);
337
+ CREATE TRIGGER consent_records_append_only BEFORE UPDATE ON consent_records
338
+ BEGIN SELECT RAISE(ABORT, 'consent records are append-only'); END;
339
+ ```
340
+
341
+ 기록은 그 동의가 유효한 동안과, 끝난 뒤 보관 기간(기본 3년 — 설정으로 두고 개인정보처리방침에 적는다) 동안 둔다. 동의는 같은 방문자가 다시 결정하거나 `decided_at`에서 12개월이 지나 만료될 때 중 먼저 오는 때에 끝난다. 보관 기간 정리 작업만 `DELETE`를 한다.
342
+
343
+ ```sql
344
+ DELETE FROM consent_records AS r
345
+ WHERE MIN(
346
+ COALESCE((SELECT MIN(n.decided_at) FROM consent_records AS n
347
+ WHERE n.anonymous_id = r.anonymous_id AND n.decided_at > r.decided_at), r.decided_at + 31536000),
348
+ r.decided_at + 31536000
349
+ ) + :retention_seconds < :now_seconds;
350
+ ```
351
+
352
+ Postgres에서는 두 인자 `MIN`을 `LEAST`로 쓰고, 애플리케이션 역할에 `SELECT, INSERT`만 주어 추가 전용으로 둔다(정리 작업은 다른 역할로 돈다).
353
+
354
+ **Solid 없이 (devslab.kr, Next.js 정적 내보내기).** 정적 페이지는 빌드할 때 쿠키를 읽을 수 없으므로 head에는 기본값만 싣고, 쿠키가 허용하면 매니저가 하이드레이션 뒤에 Tag Manager를 로드한다.
355
+
356
+ ```tsx
357
+ // pages/_document.tsx — Tag Manager 로더와 noscript iframe을 대신한다
358
+ <Head>
359
+ <script dangerouslySetInnerHTML={{ __html: consentHeadScript() }} />
360
+ </Head>
361
+ ```
362
+
363
+ ```tsx
364
+ // src/consent.ts
365
+ import { createConsentManager } from "@devslab/site-kit";
366
+ export const consent = createConsentManager({ policyVersion: "2026-10-05", gtm: "GTM-XXXXXXX", measurementIds: ["G-XXXXXXXXXX"] });
367
+
368
+ // src/components/ConsentBar.tsx — 마크업은 제품 것, 동작과 문구는 킷 것
369
+ import { useEffect, useState } from "react";
370
+ import { CONSENT_MESSAGES_KO as m } from "@devslab/site-kit";
371
+ import { consent } from "../consent";
372
+
373
+ export function ConsentBar() {
374
+ const [ask, setAsk] = useState(false);
375
+ const [settings, setSettings] = useState(false);
376
+ useEffect(() => {
377
+ consent.start(); // 저장된 허용을 적용: Tag Manager를 한 번 로드
378
+ const sync = () => setAsk(consent.needsDecision() && !consent.dismissed());
379
+ sync();
380
+ const off = consent.subscribe((event) => (event.type === "open-settings" ? setSettings(true) : sync()));
381
+ const unbind = consent.bindTriggers(); // 어떤 [data-consent-settings]든 설정을 연다
382
+ return () => { off(); unbind(); };
383
+ }, []);
384
+ // `ask`인 동안 바를 렌더: m.title, m.body, /privacy 링크, 같은 무게의 버튼 셋 —
385
+ // consent.acceptAll(), consent.rejectAll(), setSettings(true). Esc → consent.dismiss().
386
+ // 설정 대화상자: m.necessaryTitle은 글로, m.analyticsSwitch는 꺼진 스위치로,
387
+ // 그리고 consent.save({ analytics }) / acceptAll() / rejectAll().
388
+ return null;
389
+ }
390
+
391
+ // 바닥글:
392
+ // <a href="#cookie-settings" data-consent-settings>{m.trigger}</a>
393
+ ```
394
+
395
+ 쿠키는 호스트 단위이고 `Path=/`이므로, 같은 호스트에서 React를 싣지 않는 정적 HTML 페이지는 모듈 스크립트에서 `consent.start()`만 부르면 된다. 그 호스트의 어느 페이지에서 한 결정이든 그 페이지에도 적용된다.
396
+
397
+ **코드로 할 수 없는 콘솔 작업.** Tag Manager에서 GA4 태그마다 동의 설정을 *태그 실행에 추가 동의 필요: `analytics_storage`*로 둔다. 철회 뒤 Tag Manager가 메모리에 남아 있어도 거부된 페이지에서 태그가 실행되지 않는다. GA4에서는 데이터 보관 기간을 14개월로 두고(기본 문구가 그렇게 말한다), Google 신호 데이터와 광고 개인 최적화는 끈다(광고 항목이 없다).
398
+
136
399
  ## 섹션
137
400
 
138
401
  가족 랜딩 페이지를 위한 상태 없는 원시 컴포넌트 여섯 개, VisionLinq에서 추출했다. `@devslab/site-kit/solid`에서 import한다; 스타일시트는 `styles.css` 안에 들어 있다. 이 원시 컴포넌트들은 full-bleed — 각자 자기 내부 너비를 갖는다 — 라서 이걸로 구성한 페이지는 `<MarketingShell mainWidth="bleed">` 안에서 렌더해야 한다; 그렇지 않으면 셸의 기본 `<main>` 인셋이 이중으로 안쪽 여백을 주고 `tone="band"`가 박스형 사각형이 되어 버린다.
package/README.md CHANGED
@@ -4,10 +4,11 @@ Public product-site infrastructure for DevsLab products. It provides strict cata
4
4
 
5
5
  ## Entry points
6
6
 
7
- - `@devslab/site-kit` — runtime-neutral locale, catalog, SEO, sitemap, robots, verified-fact, and Google Tag Manager utilities.
8
- - `@devslab/site-kit/solid` — header, footer, locale/theme controls, marketing/legal/status/error layouts, and request-access form.
9
- - `@devslab/site-kit/tanstack-start` — conversion of neutral metadata to TanStack Start head descriptors (with opt-in brand icons and Tag Manager loader).
7
+ - `@devslab/site-kit` — runtime-neutral locale, catalog, SEO, sitemap, robots, verified-fact, Google Tag Manager and analytics-consent utilities.
8
+ - `@devslab/site-kit/solid` — header, footer, locale/theme controls, marketing/legal/status/error layouts, request-access form, and the consent bar.
9
+ - `@devslab/site-kit/tanstack-start` — conversion of neutral metadata to TanStack Start head descriptors (with opt-in brand icons and the consent-gated Tag Manager loader).
10
10
  - `@devslab/site-kit/styles.css` — logical-property, RTL-aware shared site styles. It also gives the page root the family face (`:where(html) { font-family: var(--dds-font-family-sans) }`), so bare headings and paragraphs are not left in the browser's serif. The rule has zero specificity: a product's own rule on `html`, `:root`, `body` or `:lang()` wins.
11
+ - `@devslab/site-kit/fonts.css` — the family font (Geist, Geist Mono, Pretendard) as self-hosted woff2 under the token's family names; see [Family font](#family-font).
11
12
 
12
13
  Catalog construction is intentionally strict: every locale in the registry must have exactly the same keys and named placeholders. There is no runtime copy fallback.
13
14
 
@@ -55,6 +56,7 @@ For a single-language product, and for a footer that has to print business detai
55
56
  - Touch (`pointer: coarse`): the brand link, navigation, footer and footer-language links are 44px targets, as buttons already are. At 720px and below the open menu's links are 44px rows, and the closed header's first row keeps its 64px height.
56
57
  - `SiteFooter` `details`: a block under the brand line (business registration, an `<address>`), its lines 4px apart with no paragraph margins. `linksLabel` wraps the links in `<nav aria-label>`; name it differently from the header's navigation. A footer wordmark passed as `logo` (with `name: ""`) keeps its own size — the 16px size is for an icon mark beside a printed name — and carries its own accessible text (`<img alt>`, or text), because `label` names only the header link.
57
58
  - `SiteLink.emphasis` draws a link heavier (header navigation, footer links, family links) — for a Korean site's privacy policy, which the law asks to stand out.
59
+ - `SiteFooter` `consentSettings: { controller, label }` adds a "쿠키 설정" button after the links, before the copyright, that reopens the consent settings (D-034). It reads as one of the links and is a 44px target on touch; see [Consent](#consent-opt-in-analytics).
58
60
  - Sections (and the hero) stop below the sticky header when an in-page link targets them: `scroll-margin-block-start` is the header height plus 8px. The header height is `--site-header-block-size` (default 64px); set it on `:root` or `.site-shell` — an ancestor of both the header and `<main>` — not on `.site-header`, or the sections keep the 64px offset.
59
61
  - `--site-hero-eyebrow-tracking` (default `.18em`) and `--site-hero-eyebrow-weight` (default `normal`) tune the hero eyebrow. Wide mono tracking suits Latin capitals; a product whose eyebrow is Korean sets the tracking to `0` on its landing root.
60
62
 
@@ -62,6 +64,73 @@ Defaults that apply to every product, with or without the new props: the 44px to
62
64
 
63
65
  Type note: `SiteHeaderProps["locale"]` is now `LocaleState | undefined`. Code that reads it from a `SiteHeaderProps` value (`header.locale.locale`) must narrow it — for example type the value as `SiteHeaderProps & { locale: LocaleState }` where the product always sets it.
64
66
 
67
+ ## Family font
68
+
69
+ The family face — Geist for Latin and digits, Geist Mono for code and labels, Pretendard for Korean — ships here once, as woff2 files plus one stylesheet (D-033). Every product serves the same files from its own origin, so a CSP of `font-src 'self'` holds and no font request leaves the product's domain.
70
+
71
+ | Family | Files | Upstream | License |
72
+ |---|---|---|---|
73
+ | `Geist` | 5 subsets by script (latin 29 KB, latin-ext, vietnamese, cyrillic, cyrillic-ext), variable 100–900 | `@fontsource-variable/geist` 5.3.0 | SIL OFL 1.1 — `fonts/geist/LICENSE.txt` |
74
+ | `Geist Mono` | 6 subsets (latin 23 KB, …, symbols2), variable 100–900 | `@fontsource-variable/geist-mono` 5.3.0 | SIL OFL 1.1 — `fonts/geist-mono/LICENSE.txt` |
75
+ | `Pretendard` | 92 dynamic subsets (8–44 KB each, 2.9 MB in all), variable 45–920 | `pretendard` 1.3.9 | SIL OFL 1.1 — `fonts/pretendard/LICENSE.txt` |
76
+
77
+ **The family names are the token's names.** `--dds-font-family-sans` is `Geist, Pretendard, …` and `--dds-font-family-mono` is `'Geist Mono', …`, and `fonts.css` registers exactly `"Geist"`, `"Pretendard"` and `"Geist Mono"`. Load the stylesheet and the tokens (and the page-root rule in `styles.css`) resolve to these files with nothing renamed. Not `"Geist Variable"` / `"Pretendard Variable"`: those are what the upstream packages call their own copies, and a stack naming them would not pick these files up.
78
+
79
+ **A page fetches only what it draws.** Every face has a `unicode-range`, so the browser downloads a file only when the page contains a character in its range. An English page fetches Geist latin (29 KB). A Korean landing page also fetches 12–16 Pretendard subsets — 305 KB for getasklinq.app, 347 KB for gettracelinq.app/ko, 424 KB for getbooklinq.app in Korean (measured in Chromium) — instead of the 2 MB single `PretendardVariable.woff2`. All faces use `font-display: swap`: text paints at once in the fallback and switches when the face arrives.
80
+
81
+ ### Adopting it (Vite, TanStack Start on Workers)
82
+
83
+ Import the stylesheet once, from the CSS (or the entry) every page loads:
84
+
85
+ ```css
86
+ /* src/styles/app.css */
87
+ @import "@devslab/site-kit/fonts.css";
88
+ ```
89
+
90
+ Vite follows each relative `url()` into `node_modules/@devslab/site-kit/fonts/`, copies the faces it finds into the build's `assets/` with hashed names and rewrites the stylesheet to `/assets/…woff2`. The Worker serves them as static assets from the product's own origin. Every face is larger than Vite's 4 KB inlining limit, so none becomes a `data:` URI (which `font-src 'self'` would block) — if a product raises `build.assetsInlineLimit`, exclude fonts: `assetsInlineLimit: (file) => (file.endsWith(".woff2") ? false : undefined)`.
91
+
92
+ Optionally preload the Latin face, which every page uses. Import it with `?url` — the same hashed URL the stylesheet ends up with — and pass it to the head:
93
+
94
+ ```ts
95
+ import geistLatin from "@devslab/site-kit/fonts/geist/geist-latin-wght-normal.woff2?url";
96
+
97
+ head: () => toTanStackHead(metadata, { icons: true, fontPreload: geistLatin }),
98
+ ```
99
+
100
+ `fontPreload` appends `fontPreloadLinks(geistLatin)` — `{ rel: "preload", as: "font", type: "font/woff2", crossorigin: "anonymous" }`. The `crossorigin` is required even on the same origin: fonts are fetched in CORS mode, and a preload without it is downloaded a second time. Only same-origin paths ending in `.woff2` are accepted. `FAMILY_FONT_PRELOAD_FILE` names the file's path inside the package. Preload nothing else: which Pretendard subsets a page needs depends on its text.
101
+
102
+ ### Without a bundler
103
+
104
+ Copy `fonts.css` and `fonts/` next to each other into the directory the site serves, keeping the layout, and link the stylesheet:
105
+
106
+ ```js
107
+ // scripts/copy-fonts.mjs
108
+ import { cpSync } from "node:fs";
109
+ import { dirname, join } from "node:path";
110
+ import { createRequire } from "node:module";
111
+
112
+ const kit = dirname(createRequire(import.meta.url).resolve("@devslab/site-kit/fonts.css"));
113
+ cpSync(join(kit, "fonts.css"), "public/site-kit/fonts.css");
114
+ cpSync(join(kit, "fonts"), "public/site-kit/fonts", { recursive: true });
115
+ ```
116
+
117
+ ```html
118
+ <link rel="stylesheet" href="/site-kit/fonts.css">
119
+ ```
120
+
121
+ The `url()`s are relative to `fonts.css`, so they resolve to `/site-kit/fonts/…` wherever the pair is copied. `fonts/manifest.json` (copied along) records each file's upstream package, version, size and sha256.
122
+
123
+ ### Moving off a product's own copy
124
+
125
+ 1. Delete the product's `@font-face` rules for Geist, Geist Mono and Pretendard, the script that copies woff2 into `public/fonts/` (and its `.gitignore` lines), any `@fontsource-variable/*` imports, and the `@fontsource-variable/geist`, `@fontsource-variable/geist-mono` and `pretendard` dependencies.
126
+ 2. Import `@devslab/site-kit/fonts.css` as above.
127
+ 3. In the product's own stacks, rename `"Geist Variable"` → `Geist`, `"Geist Mono Variable"` → `"Geist Mono"`, `"Pretendard Variable"` → `Pretendard` — or drop a root rule that only restated the token stack: `styles.css` already sets `:where(html) { font-family: var(--dds-font-family-sans) }` (D-032). A Korean-first order such as `:lang(ko) { font-family: Pretendard, Geist, … }` stays the product's choice and works with these faces.
128
+ 4. Replace a hand-written `<link rel="preload" href="/fonts/geist.woff2">` with `fontPreload`, and update tests that load or check fonts by the old names (`document.fonts.check('1rem "Pretendard Variable"')` → `"Pretendard"`).
129
+
130
+ ### Licenses
131
+
132
+ All three families are under the SIL Open Font License 1.1; each directory under `fonts/` carries its license text, and the package redistributes the files unmodified. Pretendard reserves its font name, so a modified version (a subset cut by someone else, for example) may not be called Pretendard: these are the author's own subsets, and `scripts/build-fonts.mjs` copies them byte for byte from the published package, never re-cuts them. `node scripts/build-fonts.mjs --check` (part of `check`) fails if a file differs from the manifest; `--vendor` refetches the pinned upstream tarballs (verifying their npm integrity) when a version is bumped.
133
+
65
134
  ## Brand icons
66
135
 
67
136
  Every product's icon files come from `@devslab/linq-brand` (`dist/<product>/`); `brandIconLinks()` is the one place that says which of them a page head links, and in what order:
@@ -86,6 +155,8 @@ Omitted, the adapter emits no icon links: it cannot know where a product serves
86
155
 
87
156
  Each product site loads Tag Manager on its public marketing and legal pages with its own container id; consoles, dashboards and the chat widget stay out. The snippet lives here once (D-031).
88
157
 
158
+ **Analytics is opt-in (D-034).** `toTanStackHead(…, { gtm })` and `gtmHeadEntry` below load Tag Manager on every page load without asking. They stay for compatibility; family products switch to the consent-gated path in [Consent](#consent-opt-in-analytics), which emits the same loader only after a grant.
159
+
89
160
  | Export | Entry | What it is |
90
161
  |---|---|---|
91
162
  | `gtmHeadScript(id)` | `@devslab/site-kit` | the head loader's script body, no `<script>` tag, no nonce |
@@ -133,6 +204,198 @@ The loader runs on a full page load of a route that has it. Client-side navigati
133
204
 
134
205
  These are Google's lists for the Tag Manager container and for "Google Analytics without any Ads features", plus `frame-src` for the noscript iframe. Google names `script-src-elem`; a policy without it falls back to `script-src`. `*.google.com` also covers `www.google.com` and GA4's `*.analytics.google.com` hosts. Not included (add them from the same guide if a container needs them): preview mode (`tagmanager.google.com`, `gstatic`, Google Fonts), Custom JavaScript variables (`'unsafe-eval'`), and Ads or Google signals hosts (`*.g.doubleclick.net`, `pagead2.googlesyndication.com`, `www.googleadservices.com`, `*.google.<TLD>`).
135
206
 
207
+ ## Consent (opt-in analytics)
208
+
209
+ Analytics is opt-in for the family (D-034). Until a visitor grants analytics for the current policy version, nothing that contacts Google runs: no Tag Manager loader, no gtag, no GA cookies. The page only pushes Consent Mode v2 defaults (all four signals denied) into its own `dataLayer`. No decision, "거부", a dismissed bar and a decision made under an older policy version all mean the same: nothing loads. Every feature works without consent.
210
+
211
+ | Export | Entry | What it is |
212
+ |---|---|---|
213
+ | `createConsentManager(options)` | `@devslab/site-kit` | the browser side: reads and writes the consent cookie, Consent Mode, loads Tag Manager on a grant, withdraws |
214
+ | `readConsentCookie(cookie, { policyVersion })` / `consentCookieGrantsAnalytics(…)` | `@devslab/site-kit` | the current, unexpired decision in a Cookie header or `document.cookie` (server or browser) |
215
+ | `consentHeadScript({ granted, gtm })` | `@devslab/site-kit` | the head script body: defaults only, or defaults + grant + Google's loader |
216
+ | `toTanStackHead(metadata, { consent })` / `consentHeadEntry(…)` | `@devslab/site-kit/tanstack-start` | the same script as a route `scripts` entry, decided from the request's cookie |
217
+ | `CONSENT_RESPONSE_HEADERS` | both | `Cache-Control: private, no-store` and `Vary: Cookie` for every response whose head was decided from the cookie |
218
+ | `ConsentBanner`, `ConsentSettingsButton` | `@devslab/site-kit/solid` | the bar, its settings dialog, and a "쿠키 설정" button for anywhere else |
219
+ | `SiteFooter consentSettings` | `@devslab/site-kit/solid` | the same button at the end of the footer's links |
220
+ | `postConsentRecord(path)`, `parseConsentRecord(body, …)`, `isSameOriginRequest(…)` | `@devslab/site-kit` | the record: send it, validate it on the server, refuse cross-site writes |
221
+ | `CONSENT_MESSAGES_KO`, `CONSENT_MESSAGES_EN` | `@devslab/site-kit` | default strings; pass your own with the same keys |
222
+
223
+ **The contract.**
224
+
225
+ - Categories: *necessary* (always on, shown as information, never a control) and *analytics* (Google Analytics 4 through Tag Manager; off until switched on, never pre-ticked). No advertising category: `ad_storage`, `ad_user_data` and `ad_personalization` stay denied.
226
+ - The cookie: `site_consent=v=<policy>&a=<0|1>&t=<unix seconds>&id=<32 hex>`, first-party, `Path=/`, `SameSite=Lax`, `Secure`, 12 months, not `HttpOnly` (the banner reads it). `id` is random, not derived from the visitor. The parser takes exactly those four keys.
227
+ - The policy version is one string per product (`"2026-10-05"`). Change it when what the privacy policy says about analytics changes: every visitor is asked again, and nothing loads until they answer. The anonymous id carries over, so the records link.
228
+ - Granting pushes `gtag('consent','update',{analytics_storage:'granted'})` and loads Tag Manager once, with the page's nonce (a `csp-nonce` meta, else the first `[nonce]` element, else `options.nonce`).
229
+ - Withdrawing pushes the update back to `denied`, sets `ga-disable-<id>` for every id in `measurementIds` (required whenever `gtm` is given — it is the only switch that stops the GA4 tag Tag Manager already initialised, whose own listeners for history page views, scrolls and outbound clicks would otherwise keep sending cookieless pings until the page reloads; `createConsentManager` throws without it), deletes `_ga`, `_ga_*`, `_gid` and `_gat*` on the host and every parent domain, and closes the `dataLayer`: while analytics is not granted, the page's `dataLayer` keeps consent commands and drops everything else, so events pushed before consent are not queued for a Tag Manager that loads later, and events after a withdrawal never reach the one already loaded. Tag Manager itself stays in memory until the next page load.
230
+ - Actions in the record: `grant` (analytics becomes granted under this version), `deny` (first decision, refused), `withdraw` (granted → refused), `update` (the same choice saved again).
231
+
232
+ **TanStack Start.** One module owns the manager:
233
+
234
+ ```ts
235
+ // src/consent.ts
236
+ import { createConsentManager, postConsentRecord } from "@devslab/site-kit";
237
+
238
+ export const CONSENT_POLICY_VERSION = "2026-10-05"; // bump with the privacy policy's analytics terms
239
+ export const GTM_ID = "GTM-XXXXXXX";
240
+ export const consent = createConsentManager({
241
+ policyVersion: CONSENT_POLICY_VERSION,
242
+ gtm: GTM_ID,
243
+ measurementIds: ["G-XXXXXXXXXX"], // every GA4 stream the container sends to; required with gtm
244
+ onChange: postConsentRecord("/api/consent"),
245
+ });
246
+ ```
247
+
248
+ Each public route decides its head from the request's cookie. The same cookie is read on both sides of hydration, so both render the same script:
249
+
250
+ ```ts
251
+ import { createIsomorphicFn } from "@tanstack/solid-start";
252
+ import { getRequestHeader } from "@tanstack/solid-start/server";
253
+
254
+ export const requestCookie = createIsomorphicFn()
255
+ .server(() => getRequestHeader("cookie"))
256
+ .client(() => document.cookie);
257
+
258
+ head: () => toTanStackHead(metadata, {
259
+ icons: true,
260
+ consent: { policyVersion: CONSENT_POLICY_VERSION, gtm: GTM_ID, cookie: requestCookie() },
261
+ }),
262
+ ```
263
+
264
+ Without a current grant the entry is the defaults only and nothing in the head names a Google host; with one it adds Google's nonce-aware loader, skipped if `gtm.js` is already on the page. The router stamps `ssr.nonce` on it exactly as on the plain loader (the nonce requirements in [Google Tag Manager](#google-tag-manager) apply unchanged). Passing both `gtm` and `consent` throws: `gtm` alone loads Tag Manager without asking. Render the noscript iframe only when `consentCookieGrantsAnalytics(requestCookie(), { policyVersion })` is true; a visitor without JavaScript has no way to grant, so they never get it.
265
+
266
+ **Never let a shared cache store these pages.** The head now depends on the visitor's cookie: a granted visitor's HTML carries the Tag Manager loader. If a CDN or edge cache stored it, visitors who never consented would get the loader — Google contact before a grant. Every route that uses `consent` sends `CONSENT_RESPONSE_HEADERS` (`Cache-Control: private, no-store` and `Vary: Cookie`):
267
+
268
+ ```ts
269
+ import { CONSENT_RESPONSE_HEADERS } from "@devslab/site-kit/tanstack-start";
270
+
271
+ export const Route = createRootRoute({
272
+ headers: () => ({ ...CONSENT_RESPONSE_HEADERS }),
273
+ head: () => toTanStackHead(metadata, { consent: { … } }),
274
+ });
275
+ ```
276
+
277
+ A route that must stay cacheable renders the denied head for everyone (`consentHeadScript()` with no grant) and lets the manager load Tag Manager after hydration, as the static export below does.
278
+
279
+ Mount the banner once, as the first element in `<body>`, so it is the first thing keyboard users reach, and put the trigger in the footer:
280
+
281
+ ```tsx
282
+ <body>
283
+ <ConsentBanner controller={consent} messages={lang() === "ko" ? CONSENT_MESSAGES_KO : CONSENT_MESSAGES_EN} privacyHref={`/${lang()}/privacy`} />
284
+ <MarketingShell
285
+ footer={{ …, consentSettings: { controller: consent, label: t("cookieSettings") } }}
286
+ …
287
+ />
288
+ </body>
289
+ ```
290
+
291
+ The banner renders nothing on the server and nothing during hydration; the bar comes in after mount, from the browser's own cookie (`tests/site-kit-consent-hydration.test.mjs` hydrates exactly this under the development build). Its three choices — 모두 허용, 거부, 설정 — are the same button at the same size. ✕ and Escape close it without a decision. The settings dialog traps focus, closes on Escape without saving, and shows *necessary* as text and *analytics* as an unticked switch. The footer button (or `ConsentSettingsButton`, or `consent.openSettings()`) reopens it to change or withdraw at any time.
292
+
293
+ A path that carries a secret goes into the record redacted: `createConsentManager({ …, recordPath: (path) => path.replace(/\/k\/[^/]+/, "/k/:key") })`.
294
+
295
+ **Recording consent (server).** Every decision POSTs a record to the product's own backend:
296
+
297
+ ```json
298
+ { "policyVersion": "2026-10-05", "analytics": true, "action": "grant", "anonymousId": "<32 hex>", "decidedAt": 1759650000, "source": "web", "path": "/ko/pricing" }
299
+ ```
300
+
301
+ The endpoint must not become a tracking vector: same origin only, the exact payload, rate-limited.
302
+
303
+ ```ts
304
+ import { CONSENT_RECORD_MAX_BYTES, isSameOriginRequest, parseConsentRecord } from "@devslab/site-kit";
305
+
306
+ if (request.method !== "POST") return new Response(null, { status: 405 });
307
+ if (!isSameOriginRequest(request, "https://getasklinq.app")) return new Response(null, { status: 403 });
308
+ if (Number(request.headers.get("content-length") ?? 0) > CONSENT_RECORD_MAX_BYTES) return new Response(null, { status: 413 });
309
+ const ip = request.headers.get("cf-connecting-ip") ?? "";
310
+ if (!(await env.CONSENT_RATE_LIMIT.limit({ key: ip })).success) return new Response(null, { status: 429 });
311
+ const record = parseConsentRecord(await request.text(), { policyVersion: CONSENT_POLICY_VERSION });
312
+ if (!record) return new Response(null, { status: 400 });
313
+ await insertConsentRecord(env, { ...record, receivedAt: new Date().toISOString(), ip, userAgent: (request.headers.get("user-agent") ?? "").slice(0, 256) });
314
+ return new Response(null, { status: 204 });
315
+ ```
316
+
317
+ `parseConsentRecord` takes the exact seven keys, a known policy version (pass a list while records made under the previous one may still arrive), an action that agrees with `analytics`, a path with no query, and a `decidedAt` neither in the future nor older than 31 days. A failed POST (network, 429, 5xx) is kept in `localStorage` and sent again on the next page; a 4xx is dropped. A reference table, append-only for the application:
318
+
319
+ ```sql
320
+ CREATE TABLE consent_records (
321
+ id TEXT PRIMARY KEY, -- server-generated
322
+ received_at TEXT NOT NULL, -- server clock, UTC
323
+ decided_at INTEGER NOT NULL, -- the record's decidedAt (the cookie's t)
324
+ subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'anonymous')),
325
+ subject_id TEXT NOT NULL, -- internal user id when logged in, else the anonymous id
326
+ login_id TEXT, -- login id snapshot when logged in
327
+ anonymous_id TEXT NOT NULL,
328
+ policy_version TEXT NOT NULL,
329
+ analytics INTEGER NOT NULL CHECK (analytics IN (0, 1)),
330
+ action TEXT NOT NULL CHECK (action IN ('grant', 'deny', 'withdraw', 'update')),
331
+ source TEXT NOT NULL CHECK (source IN ('web', 'app')),
332
+ path TEXT NOT NULL,
333
+ ip TEXT,
334
+ user_agent TEXT -- first 256 characters
335
+ );
336
+ CREATE INDEX consent_records_by_visitor ON consent_records (anonymous_id, decided_at);
337
+ CREATE TRIGGER consent_records_append_only BEFORE UPDATE ON consent_records
338
+ BEGIN SELECT RAISE(ABORT, 'consent records are append-only'); END;
339
+ ```
340
+
341
+ Keep each record while its consent is in effect and for a retention period after it ends (3 years by default — make it a setting, and say it in the privacy policy). A consent ends when the same visitor decides again or when it expires 12 months after `decided_at`, whichever is first. The retention sweep is the only `DELETE`:
342
+
343
+ ```sql
344
+ DELETE FROM consent_records AS r
345
+ WHERE MIN(
346
+ COALESCE((SELECT MIN(n.decided_at) FROM consent_records AS n
347
+ WHERE n.anonymous_id = r.anonymous_id AND n.decided_at > r.decided_at), r.decided_at + 31536000),
348
+ r.decided_at + 31536000
349
+ ) + :retention_seconds < :now_seconds;
350
+ ```
351
+
352
+ In Postgres, write the two-argument `MIN` as `LEAST`, and keep the table append-only by granting the application role `SELECT, INSERT` only (the sweep runs as a separate role).
353
+
354
+ **Without Solid (devslab.kr, Next.js static export).** A static page cannot read the cookie when it is built, so its head carries only the defaults and the manager loads Tag Manager after hydration when the cookie grants:
355
+
356
+ ```tsx
357
+ // pages/_document.tsx — replaces the Tag Manager loader and the noscript iframe
358
+ <Head>
359
+ <script dangerouslySetInnerHTML={{ __html: consentHeadScript() }} />
360
+ </Head>
361
+ ```
362
+
363
+ ```tsx
364
+ // src/consent.ts
365
+ import { createConsentManager } from "@devslab/site-kit";
366
+ export const consent = createConsentManager({ policyVersion: "2026-10-05", gtm: "GTM-XXXXXXX", measurementIds: ["G-XXXXXXXXXX"] });
367
+
368
+ // src/components/ConsentBar.tsx — the product's own markup, the kit's behaviour and strings
369
+ import { useEffect, useState } from "react";
370
+ import { CONSENT_MESSAGES_KO as m } from "@devslab/site-kit";
371
+ import { consent } from "../consent";
372
+
373
+ export function ConsentBar() {
374
+ const [ask, setAsk] = useState(false);
375
+ const [settings, setSettings] = useState(false);
376
+ useEffect(() => {
377
+ consent.start(); // applies a stored grant: loads Tag Manager, once
378
+ const sync = () => setAsk(consent.needsDecision() && !consent.dismissed());
379
+ sync();
380
+ const off = consent.subscribe((event) => (event.type === "open-settings" ? setSettings(true) : sync()));
381
+ const unbind = consent.bindTriggers(); // any [data-consent-settings] opens the settings
382
+ return () => { off(); unbind(); };
383
+ }, []);
384
+ // Render the bar while `ask`: m.title, m.body, a link to /privacy, and three equal buttons —
385
+ // consent.acceptAll(), consent.rejectAll(), setSettings(true). Escape → consent.dismiss().
386
+ // The settings dialog: m.necessaryTitle as text, an unticked switch for m.analyticsSwitch,
387
+ // and consent.save({ analytics }) / acceptAll() / rejectAll().
388
+ return null;
389
+ }
390
+
391
+ // In the footer:
392
+ // <a href="#cookie-settings" data-consent-settings>{m.trigger}</a>
393
+ ```
394
+
395
+ The cookie is per host and `Path=/`, so plain HTML pages on the same host that load no React only need `consent.start()` from a module script: a decision made on any page of the host applies to them.
396
+
397
+ **Console steps the code cannot do.** In Tag Manager, set each GA4 tag's consent settings to *Require additional consent for tag to fire: `analytics_storage`*, so a tag never fires on a denied page even if Tag Manager is still in memory after a withdrawal. In GA4, keep data retention at 14 months (the default strings say so) and Google signals and ads personalisation off (there is no advertising category).
398
+
136
399
  ## Sections
137
400
 
138
401
  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 `<MarketingShell mainWidth="bleed">`; the shell's default `<main>` inset would otherwise double-inset them and turn `tone="band"` into a boxed rectangle.