@devslab/site-kit 0.15.0 → 0.17.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.
@@ -1,6 +1,14 @@
1
1
  import { brandIconLinks } from "./core/seo.mjs";
2
2
  import { gtmHeadScript } from "./core/gtm.mjs";
3
3
  import { fontPreloadLinks } from "./core/fonts.mjs";
4
+ import { consentCookieGrantsAnalytics, consentHeadScript } from "./core/consent.mjs";
5
+
6
+ /**
7
+ * Send these on every route that uses `consent` (TanStack Start: the route's
8
+ * `headers` option). The head differs per visitor's cookie, so the page must
9
+ * never sit in a shared cache.
10
+ */
11
+ export { CONSENT_RESPONSE_HEADERS } from "./core/consent.mjs";
4
12
 
5
13
  /**
6
14
  * A route `head().scripts` entry that loads Google Tag Manager (D-031).
@@ -17,11 +25,33 @@ export function gtmHeadEntry(containerId) {
17
25
  return { children: gtmHeadScript(containerId) };
18
26
  }
19
27
 
28
+ /**
29
+ * The consent-gated head entry (D-034): one inline script. Without a current
30
+ * analytics grant in `cookie` it only pushes Consent Mode defaults (all
31
+ * denied) into the page's dataLayer — nothing contacts Google. With one, it
32
+ * also grants `analytics_storage` and runs Google's Tag Manager loader.
33
+ *
34
+ * `cookie` is the Cookie header on the server and `document.cookie` in the
35
+ * browser (TanStack Start: createIsomorphicFn), so both sides of hydration
36
+ * render the same script. Only the consent cookie is read from it; nothing
37
+ * from it is written into the page. Like gtmHeadEntry, no nonce: the router
38
+ * stamps `ssr.nonce`.
39
+ */
40
+ export function consentHeadEntry(options) {
41
+ const granted = consentCookieGrantsAnalytics(options?.cookie, {
42
+ policyVersion: options?.policyVersion,
43
+ ...(options.cookieName === undefined ? {} : { cookieName: options.cookieName }),
44
+ });
45
+ return { children: consentHeadScript({ granted, gtm: options.gtm }) };
46
+ }
47
+
20
48
  // `icons` is opt-in: the adapter cannot know where (or whether) a product
21
49
  // serves the linq-brand files, and a head that links icons the server 404s
22
50
  // is worse than one that links none. `true` takes the /brand default.
23
51
  // `gtm` is opt-in the same way, and per route: the product decides which
24
52
  // pages carry Tag Manager (public marketing and legal pages, not consoles).
53
+ // It loads Tag Manager unconditionally and stays for compatibility; products
54
+ // switch to `consent` (D-034), which loads it only after an opt-in.
25
55
  // `fontPreload` is opt-in too: only the product knows the hashed URL its
26
56
  // bundler gave the face (D-033). Appended after the icons.
27
57
  export function toTanStackHead(metadata, options = {}) {
@@ -49,7 +79,11 @@ export function toTanStackHead(metadata, options = {}) {
49
79
  ...(options.fontPreload === undefined ? [] : fontPreloadLinks(options.fontPreload)),
50
80
  ],
51
81
  };
52
- if (options.gtm !== undefined) head.scripts = [gtmHeadEntry(options.gtm)];
82
+ if (options.gtm !== undefined && options.consent !== undefined) {
83
+ throw new TypeError("toTanStackHead: pass the container id as consent.gtm, not both gtm and consent — gtm alone loads Tag Manager without consent");
84
+ }
85
+ if (options.consent !== undefined) head.scripts = [consentHeadEntry(options.consent)];
86
+ else if (options.gtm !== undefined) head.scripts = [gtmHeadEntry(options.gtm)];
53
87
  return head;
54
88
  }
55
89
 
package/styles.css CHANGED
@@ -73,7 +73,7 @@
73
73
  /* Touch: every link in the chrome is a 44px target (spec §6), as every button already is
74
74
  (dds-css button.css). Placed before the narrow block so its menu rows can override the display. */
75
75
  @media (pointer: coarse) {
76
- .site-brand, .site-nav__list a, .site-footer__links a, .site-footer__brand a, .site-footer__langs-trigger { display: inline-flex; align-items: center; min-block-size: 44px; }
76
+ .site-brand, .site-nav__list a, .site-footer__links a, .site-footer__links .site-consent-trigger, .site-footer__brand a, .site-footer__langs-trigger { display: inline-flex; align-items: center; min-block-size: 44px; }
77
77
  .site-footer__langs-list a { display: flex; align-items: center; min-block-size: 44px; }
78
78
  }
79
79
 
@@ -92,7 +92,7 @@
92
92
  }
93
93
 
94
94
  @media (prefers-reduced-motion: reduce) { .site-header { scroll-behavior: auto; } }
95
- @media (forced-colors: active) { .site-header, .site-footer, .site-status { forced-color-adjust: auto; } }
95
+ @media (forced-colors: active) { .site-header, .site-footer, .site-status, .site-consent { forced-color-adjust: auto; } }
96
96
 
97
97
  .site-locale-flag { position: relative; }
98
98
  .site-locale-flag__trigger { list-style: none; display: inline-grid; place-items: center; min-inline-size: 44px; min-block-size: 44px; border-radius: var(--dds-radius-sm); cursor: pointer; }
@@ -109,3 +109,58 @@
109
109
  .site-locale-flag__option:focus-visible { outline: 2px solid var(--dds-color-border-focus); outline-offset: -2px; }
110
110
  .site-locale-flag__option[aria-current="true"] { font-weight: 700; }
111
111
  @media (max-width: 720px) { .site-locale-flag__list { position: static; inline-size: 100%; max-block-size: none; box-shadow: none; } }
112
+
113
+ /* Consent (D-034): a bottom bar with three equal choices, and a settings dialog
114
+ with two (cancel, save). Each set shares one tone and one grid track width,
115
+ so no choice is drawn heavier than another. Products bring their own, longer labels, so the
116
+ layout follows the bar's own width (a container), in three steps: one row
117
+ (copy, choices, close); copy above a row of three; copy above the three
118
+ stacked full width. A label wraps only at a space, never inside a word. */
119
+ /* Room under the page while the bar is up (ConsentBanner sets the height); zero specificity, so a product body rule wins. */
120
+ :where(body) { padding-block-end: var(--site-consent-block-size, 0px); }
121
+ .site-consent { container: site-consent / inline-size; position: fixed; inset-inline: 0; inset-block-end: 0; z-index: 900; max-block-size: 100dvh; overflow-y: auto; border-block-start: 1px solid var(--dds-color-border-default); background: var(--dds-color-bg-elevated); color: var(--dds-color-text-primary); box-shadow: var(--dds-elevation-3); }
122
+ .site-consent__inner { inline-size: min(100% - var(--dds-space-32), 1120px); margin-inline: auto; padding-block: var(--dds-space-16); display: grid; grid-template-columns: minmax(0, 1fr) fit-content(50%) auto; grid-template-areas: "copy actions close"; align-items: center; gap: var(--dds-space-12) var(--dds-space-24); }
123
+ .site-consent__copy { grid-area: copy; display: grid; gap: var(--dds-space-4); min-inline-size: 0; }
124
+ .site-consent__title { margin: 0; font-size: var(--dds-typo-body-1-font-size); line-height: var(--dds-typo-body-1-line-height); font-weight: 700; }
125
+ .site-consent__body { margin: 0; max-inline-size: 72ch; font-size: var(--dds-typo-body-2-font-size); line-height: var(--dds-typo-body-2-line-height); color: var(--dds-color-text-secondary); }
126
+ .site-consent__link { color: var(--dds-color-text-brand); text-decoration: underline; text-underline-offset: 2px; }
127
+ .site-consent__link:focus-visible, .site-consent-trigger:focus-visible { outline: 2px solid var(--dds-color-border-focus); outline-offset: 2px; border-radius: var(--dds-radius-sm); }
128
+ .site-consent__actions { grid-area: actions; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: var(--dds-space-8); }
129
+ /* dds.css lets a button label break anywhere (overflow-wrap: anywhere); here a
130
+ line ends only at a space. Korean keeps whole words, whatever the page root says. */
131
+ .site-consent__action { inline-size: 100%; min-inline-size: 7rem; overflow-wrap: normal; word-break: normal; text-align: center; }
132
+ .site-consent__action:lang(ko) { word-break: keep-all; }
133
+ .site-consent__dismiss { grid-area: close; align-self: start; }
134
+ .dds-dialog.site-consent-dialog { container: site-consent-dialog / inline-size; position: relative; max-inline-size: 36rem; max-block-size: calc(100dvh - 2 * var(--dds-space-16)); overflow-y: auto; }
135
+ .site-consent-dialog .dds-dialog__title { padding-inline-end: var(--dds-space-40); }
136
+ .site-consent-dialog__close { position: absolute; inset-block-start: var(--dds-space-12); inset-inline-end: var(--dds-space-12); }
137
+ .site-consent-categories { display: grid; margin: 0 0 var(--dds-space-12); padding: 0; list-style: none; }
138
+ .site-consent-category { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: var(--dds-space-16); padding-block: var(--dds-space-12); border-block-start: 1px solid var(--dds-color-border-default); }
139
+ .site-consent-category:last-child { border-block-end: 1px solid var(--dds-color-border-default); }
140
+ .site-consent-category__copy { display: grid; gap: var(--dds-space-4); min-inline-size: 0; }
141
+ .site-consent-category__title { margin: 0; font-size: var(--dds-typo-label-font-size); line-height: var(--dds-typo-label-line-height); font-weight: 600; }
142
+ .site-consent-category__body { margin: 0; font-size: var(--dds-typo-body-2-font-size); line-height: var(--dds-typo-body-2-line-height); color: var(--dds-color-text-secondary); }
143
+ .site-consent-category__status, .site-consent-category__switch { justify-self: end; }
144
+ .site-consent-category__status { background: var(--dds-color-bg-subtle); color: var(--dds-color-text-secondary); }
145
+ .site-consent-dialog__policy { margin: 0 0 var(--dds-space-16); font-size: var(--dds-typo-body-2-font-size); }
146
+ .site-consent-dialog .dds-dialog__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
147
+ /* The footer's "쿠키 설정" reads as one of its links. */
148
+ .site-consent-trigger { appearance: none; margin: 0; padding: 0; border: 0; background: none; font: inherit; color: var(--dds-color-text-secondary); cursor: pointer; }
149
+ .site-consent-trigger:hover { color: var(--dds-color-text-primary); text-decoration: underline; }
150
+ /* One row only while the copy keeps a readable measure beside the choices
151
+ (fit-content caps them at half the bar; their labels then wrap at spaces). */
152
+ @container site-consent (max-width: 60rem) {
153
+ .site-consent__inner { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "copy close" "actions actions"; align-items: start; }
154
+ .site-consent__actions { grid-auto-flow: row; grid-template-columns: repeat(3, minmax(0, 1fr)); }
155
+ .site-consent__action { min-inline-size: 0; }
156
+ }
157
+ /* A phone: a third of the bar is too narrow for a long word (Tamil, Telugu,
158
+ German), so the choices stack — full width, every row as tall as the tallest. */
159
+ @container site-consent (max-width: 36rem) {
160
+ .site-consent__actions { grid-template-columns: minmax(0, 1fr); grid-auto-rows: 1fr; }
161
+ }
162
+ /* The same in the settings dialog. A container query measures the content box:
163
+ the dialog's 36rem less its padding is 536px. */
164
+ @container site-consent-dialog (max-width: 32rem) {
165
+ .site-consent-dialog .dds-dialog__actions { grid-template-columns: minmax(0, 1fr); grid-auto-rows: 1fr; }
166
+ }