@devslab/site-kit 0.15.0 → 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.
package/README.ko.md CHANGED
@@ -4,9 +4,9 @@ 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
11
  - `@devslab/site-kit/fonts.css` — 가족 서체(Geist·Geist Mono·Pretendard)를 토큰의 패밀리 이름으로 등록한 자체 호스팅 woff2. [가족 서체](#가족-서체) 참고
12
12
 
@@ -56,6 +56,7 @@ claim leaf가 검증된 사실 레지스트리를 참조하도록 강제한다.
56
56
  - 터치(`pointer: coarse`): 브랜드 링크·내비게이션·푸터·푸터 언어 링크가 버튼처럼 44px 누르는 면이다. 720px 이하에서 열린 메뉴의 링크는 44px 줄이고, 닫힌 헤더의 첫 줄은 64px 그대로다.
57
57
  - `SiteFooter`의 `details`: 브랜드 줄 아래 블록(사업자 정보, `<address>`), 줄 간격 4px·문단 여백 없음. `linksLabel`은 링크를 `<nav aria-label>`로 감싼다 — 헤더 내비게이션과 다른 이름으로. `logo`로 넘긴 푸터 워드마크(`name: ""`)는 자기 크기를 지킨다 — 16px은 이름 옆 아이콘 마크용 — 그리고 접근 가능한 이름을 스스로 가진다(`<img alt>`나 글자). `label`은 헤더 링크의 이름일 뿐이다.
58
58
  - `SiteLink.emphasis`는 링크를 굵게 그린다(헤더 내비게이션·푸터 링크·패밀리 링크). 법이 눈에 띄게 하라는 개인정보처리방침용.
59
+ - `SiteFooter` `consentSettings: { controller, label }`는 링크 뒤, 저작권 앞에 동의 설정을 다시 여는 "쿠키 설정" 버튼을 둔다(D-034). 다른 링크와 같아 보이고 터치에서는 44px 대상이다. [동의](#동의-옵트인-분석) 참고.
59
60
  - 섹션(그리고 히어로)은 같은 페이지 링크로 이동하면 붙어 있는 헤더 아래에 멈춘다: `scroll-margin-block-start` = 헤더 높이 + 8px. 헤더 높이는 `--site-header-block-size`(기본 64px) — `:root`나 `.site-shell`(헤더와 `<main>`의 공통 조상)에 정한다. `.site-header`에 정하면 섹션은 64px 간격 그대로다.
60
61
  - `--site-hero-eyebrow-tracking`(기본 `.18em`)과 `--site-hero-eyebrow-weight`(기본 `normal`)로 히어로 키커를 조정한다. 넓은 모노 자간은 라틴 대문자에 맞고, 키커가 한국어인 제품은 랜딩 뿌리에서 자간을 `0`으로 둔다.
61
62
 
@@ -154,6 +155,8 @@ toTanStackHead(metadata, { icons: { basePath: "/" } }); // /favicon.svg, /mark-4
154
155
 
155
156
  제품 사이트마다 자기 컨테이너 ID로 공개 마케팅·법적 페이지에 Tag Manager를 싣는다. 콘솔·대시보드·채팅 위젯은 제외한다. 스니펫은 여기 한 곳에 둔다(D-031).
156
157
 
158
+ **분석은 옵트인이다(D-034).** 아래의 `toTanStackHead(…, { gtm })`와 `gtmHeadEntry`는 묻지 않고 페이지를 열 때마다 Tag Manager를 로드한다. 호환을 위해 남겨 두며, 가족 제품은 [동의](#동의-옵트인-분석)의 동의 게이트 경로로 옮긴다. 같은 로더를 허용 뒤에만 낸다.
159
+
157
160
  | 내보내기 | 진입점 | 무엇인가 |
158
161
  |---|---|---|
159
162
  | `gtmHeadScript(id)` | `@devslab/site-kit` | head 로더의 스크립트 본문. `<script>` 태그도 nonce도 없음 |
@@ -201,6 +204,198 @@ head: () => toTanStackHead(metadata, { icons: true, gtm: GTM_ID }),
201
204
 
202
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>`).
203
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
+
204
399
  ## 섹션
205
400
 
206
401
  가족 랜딩 페이지를 위한 상태 없는 원시 컴포넌트 여섯 개, VisionLinq에서 추출했다. `@devslab/site-kit/solid`에서 import한다; 스타일시트는 `styles.css` 안에 들어 있다. 이 원시 컴포넌트들은 full-bleed — 각자 자기 내부 너비를 갖는다 — 라서 이걸로 구성한 페이지는 `<MarketingShell mainWidth="bleed">` 안에서 렌더해야 한다; 그렇지 않으면 셸의 기본 `<main>` 인셋이 이중으로 안쪽 여백을 주고 `tone="band"`가 박스형 사각형이 되어 버린다.
package/README.md CHANGED
@@ -4,9 +4,9 @@ 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
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).
12
12
 
@@ -56,6 +56,7 @@ For a single-language product, and for a footer that has to print business detai
56
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.
57
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.
58
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).
59
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.
60
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.
61
62
 
@@ -154,6 +155,8 @@ Omitted, the adapter emits no icon links: it cannot know where a product serves
154
155
 
155
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).
156
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
+
157
160
  | Export | Entry | What it is |
158
161
  |---|---|---|
159
162
  | `gtmHeadScript(id)` | `@devslab/site-kit` | the head loader's script body, no `<script>` tag, no nonce |
@@ -201,6 +204,198 @@ The loader runs on a full page load of a route that has it. Client-side navigati
201
204
 
202
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>`).
203
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
+
204
399
  ## Sections
205
400
 
206
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.
package/dist/chrome.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type JSX } from "solid-js";
2
2
  import { type LocaleMenuProps, type LocaleMenuVariant } from "./locale-menu";
3
+ import type { ConsentManager } from "../core/consent.mjs";
3
4
  import { type LocaleRegistry } from "../core/locales.mjs";
4
5
  import type { LocaleState, SiteBrand, SiteLink, SiteMessages, ThemePreference } from "./types";
5
6
  export type { LocaleMenuProps };
@@ -68,6 +69,15 @@ export interface SiteFooterProps {
68
69
  * header's navigation. Without it the list stays a plain list.
69
70
  */
70
71
  linksLabel?: string;
72
+ /**
73
+ * A "쿠키 설정" button at the end of the link list, before the copyright,
74
+ * that re-opens the consent settings (D-034). The page's ConsentBanner
75
+ * listens on the same controller.
76
+ */
77
+ consentSettings?: {
78
+ controller: ConsentManager;
79
+ label: string;
80
+ };
71
81
  }
72
82
  /**
73
83
  * The family footer: a language row, then the brand beside its family links,
@@ -1 +1 @@
1
- {"version":3,"file":"chrome.d.ts","sourceRoot":"","sources":["../src/solid/chrome.tsx"],"names":[],"mappings":"AACA,OAAO,EAAgD,KAAK,GAAG,EAAE,MAAM,UAAU,CAAC;AAElF,OAAO,EAAc,KAAK,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACzF,OAAO,EAAkB,KAAK,cAAc,EAAmB,MAAM,qBAAqB,CAAC;AAC3F,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAE/F,YAAY,EAAE,eAAe,EAAE,CAAC;AAEhC,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,YAAY,CAAC;IACvB,KAAK,CAAC,EAAE,eAAe,CAAC;IACxB,YAAY,CAAC,EAAE,eAAe,CAAC;IAC/B,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IACjD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,eA0ClD;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,SAAS,CAAC;IACjB,UAAU,EAAE,QAAQ,EAAE,CAAC;IACvB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,EAAE,YAAY,CAAC;IACvB,KAAK,CAAC,EAAE,IAAI,CAAC,gBAAgB,EAAE,UAAU,CAAC,CAAC;IAC3C,cAAc,CAAC,EAAE,eAAe,CAAC,gBAAgB,CAAC,CAAC;IACnD,aAAa,CAAC,EAAE,iBAAiB,CAAC;IAClC;;;;OAIG;IACH,cAAc,CAAC,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,OAAO,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;CACvB;AAKD,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,eA0DhD;AA4DD,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,SAAS,CAAC;IACjB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,YAAY,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,sEAAsE;IACtE,cAAc,CAAC,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;OAGG;IACH,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,mFAAmF;IACnF,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAC;IACpB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;IACtB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,eAwDhD"}
1
+ {"version":3,"file":"chrome.d.ts","sourceRoot":"","sources":["../src/solid/chrome.tsx"],"names":[],"mappings":"AACA,OAAO,EAAgD,KAAK,GAAG,EAAE,MAAM,UAAU,CAAC;AAElF,OAAO,EAAc,KAAK,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAEzF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAC1D,OAAO,EAAkB,KAAK,cAAc,EAAmB,MAAM,qBAAqB,CAAC;AAC3F,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAE/F,YAAY,EAAE,eAAe,EAAE,CAAC;AAEhC,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,YAAY,CAAC;IACvB,KAAK,CAAC,EAAE,eAAe,CAAC;IACxB,YAAY,CAAC,EAAE,eAAe,CAAC;IAC/B,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IACjD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,eA0ClD;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,SAAS,CAAC;IACjB,UAAU,EAAE,QAAQ,EAAE,CAAC;IACvB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,EAAE,YAAY,CAAC;IACvB,KAAK,CAAC,EAAE,IAAI,CAAC,gBAAgB,EAAE,UAAU,CAAC,CAAC;IAC3C,cAAc,CAAC,EAAE,eAAe,CAAC,gBAAgB,CAAC,CAAC;IACnD,aAAa,CAAC,EAAE,iBAAiB,CAAC;IAClC;;;;OAIG;IACH,cAAc,CAAC,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,OAAO,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;CACvB;AAKD,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,eA0DhD;AA4DD,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,SAAS,CAAC;IACjB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,YAAY,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,sEAAsE;IACtE,cAAc,CAAC,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;OAGG;IACH,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,mFAAmF;IACnF,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAC;IACpB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC;IACtB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,eAAe,CAAC,EAAE;QAAE,UAAU,EAAE,cAAc,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CACjE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,eA2DhD"}
@@ -0,0 +1,36 @@
1
+ import { type JSX } from "solid-js";
2
+ import type { ConsentManager, ConsentMessages } from "../core/consent.mjs";
3
+ export type { ConsentManager, ConsentMessages };
4
+ export interface ConsentBannerProps {
5
+ /** The page's manager from createConsentManager. The banner calls its start(). */
6
+ controller: ConsentManager;
7
+ /** CONSENT_MESSAGES_KO / CONSENT_MESSAGES_EN, or the product's own strings. */
8
+ messages: ConsentMessages;
9
+ /** The privacy policy page, linked from the bar and from the settings dialog. */
10
+ privacyHref: string;
11
+ }
12
+ /**
13
+ * The consent bar and its settings dialog (D-034).
14
+ *
15
+ * Renders nothing on the server and nothing during hydration: the bar comes
16
+ * in after mount, when the browser's own cookie is known. So it can never
17
+ * disagree with the server's markup, and a page without JavaScript shows no
18
+ * bar and loads no analytics.
19
+ *
20
+ * The bar's three choices are the same button, the same size: "모두 허용",
21
+ * "거부", "설정". Closing it any other way (✕, Escape) is no decision —
22
+ * nothing loads, and the next visit asks again.
23
+ */
24
+ export declare function ConsentBanner(props: ConsentBannerProps): JSX.Element;
25
+ export interface ConsentSettingsButtonProps {
26
+ controller: ConsentManager;
27
+ children: JSX.Element;
28
+ class?: string;
29
+ }
30
+ /**
31
+ * Re-opens the settings dialog from anywhere — the footer's "쿠키 설정", an
32
+ * account page. The ConsentBanner mounted on the page listens for it; a page
33
+ * without one has nothing to open.
34
+ */
35
+ export declare function ConsentSettingsButton(props: ConsentSettingsButtonProps): JSX.Element;
36
+ //# sourceMappingURL=consent.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consent.d.ts","sourceRoot":"","sources":["../src/solid/consent.tsx"],"names":[],"mappings":"AACA,OAAO,EAA0C,KAAK,GAAG,EAAE,MAAM,UAAU,CAAC;AAE5E,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAE3E,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,CAAC;AAEhD,MAAM,WAAW,kBAAkB;IACjC,kFAAkF;IAClF,UAAU,EAAE,cAAc,CAAC;IAC3B,+EAA+E;IAC/E,QAAQ,EAAE,eAAe,CAAC;IAC1B,iFAAiF;IACjF,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,eAsItD;AAED,MAAM,WAAW,0BAA0B;IACzC,UAAU,EAAE,cAAc,CAAC;IAC3B,QAAQ,EAAE,GAAG,CAAC,OAAO,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,0BAA0B,eAStE"}
package/dist/index.d.ts CHANGED
@@ -4,5 +4,6 @@ export { ErrorLayout, LegalLayout, MarketingShell, NotFoundLayout, StatusBanner,
4
4
  export { RequestAccessForm, type RequestAccessFormProps, type RequestAccessMessages } from "./request-access";
5
5
  export { OssProductMark, type OssProductMarkProps } from "./oss-product-mark";
6
6
  export { SectionBlock, SectionHead, HeroSplit, StepFlow, FeatureRows, PricingNote, type SectionBlockProps, type SectionHeadProps, type HeroSplitProps, type StepFlowProps, type StepFlowStep, type FeatureRow, type FeatureRowsProps, type PricingNoteProps } from "./sections";
7
+ export { ConsentBanner, ConsentSettingsButton, type ConsentBannerProps, type ConsentSettingsButtonProps } from "./consent";
7
8
  export type { LocaleState, SiteBrand, SiteLink, SiteMessages, ThemePreference } from "./types";
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/solid/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACzF,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,KAAK,eAAe,EAAE,KAAK,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAClI,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,cAAc,EAAE,cAAc,EAAE,YAAY,EAAE,KAAK,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,KAAK,mBAAmB,EAAE,KAAK,mBAAmB,EAAE,KAAK,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC7N,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,KAAK,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAC9G,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9E,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,WAAW,EAAE,WAAW,EAAE,KAAK,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,KAAK,UAAU,EAAE,KAAK,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAChR,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/solid/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACzF,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,KAAK,eAAe,EAAE,KAAK,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAClI,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,cAAc,EAAE,cAAc,EAAE,YAAY,EAAE,KAAK,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,KAAK,mBAAmB,EAAE,KAAK,mBAAmB,EAAE,KAAK,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC7N,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,KAAK,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAC9G,OAAO,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9E,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,WAAW,EAAE,WAAW,EAAE,KAAK,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,KAAK,UAAU,EAAE,KAAK,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAChR,OAAO,EAAE,aAAa,EAAE,qBAAqB,EAAE,KAAK,kBAAkB,EAAE,KAAK,0BAA0B,EAAE,MAAM,WAAW,CAAC;AAC3H,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC"}