@cparkerwebm/webmonterey 1.0.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 (133) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +104 -0
  4. package/agents/.gitkeep +0 -0
  5. package/dist/webm.mjs +2381 -0
  6. package/hooks/.gitkeep +0 -0
  7. package/package.json +101 -0
  8. package/schema/design.json +118 -0
  9. package/skills/launch/SKILL.md +183 -0
  10. package/skills/new-component/SKILL.md +85 -0
  11. package/skills/start/SKILL.md +117 -0
  12. package/skills/traps/SKILL.md +333 -0
  13. package/skills/upgrade/SKILL.md +42 -0
  14. package/src/actions/index.ts +348 -0
  15. package/src/cli/checks.test.ts +711 -0
  16. package/src/cli/checks.ts +822 -0
  17. package/src/cli/codemods.test.ts +42 -0
  18. package/src/cli/codemods.ts +51 -0
  19. package/src/cli/compare.test.ts +144 -0
  20. package/src/cli/compare.ts +222 -0
  21. package/src/cli/design-extract.test.ts +96 -0
  22. package/src/cli/design-extract.ts +229 -0
  23. package/src/cli/doctor.ts +187 -0
  24. package/src/cli/mcp.test.ts +57 -0
  25. package/src/cli/mcp.ts +110 -0
  26. package/src/cli/new.ts +148 -0
  27. package/src/cli/package-root.ts +58 -0
  28. package/src/cli/scaffold.test.ts +230 -0
  29. package/src/cli/scaffold.ts +424 -0
  30. package/src/cli/seed.ts +133 -0
  31. package/src/cli/slug.test.ts +70 -0
  32. package/src/cli/slug.ts +109 -0
  33. package/src/cli/sync.test.ts +137 -0
  34. package/src/cli/sync.ts +266 -0
  35. package/src/cli/upgrade.ts +93 -0
  36. package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
  37. package/src/design/brand.ts +49 -0
  38. package/src/design/compile.test.ts +98 -0
  39. package/src/design/compile.ts +155 -0
  40. package/src/design/defaults.ts +315 -0
  41. package/src/design/index.ts +18 -0
  42. package/src/design/resolve.test.ts +80 -0
  43. package/src/design/resolve.ts +108 -0
  44. package/src/design/types.ts +96 -0
  45. package/src/emails/autoresponse.test.ts +82 -0
  46. package/src/emails/autoresponse.ts +83 -0
  47. package/src/emails/footer.test.ts +102 -0
  48. package/src/emails/footer.ts +91 -0
  49. package/src/emails/index.ts +24 -0
  50. package/src/emails/subject.test.ts +66 -0
  51. package/src/emails/subject.ts +80 -0
  52. package/src/emails/submission-notification.ts +76 -0
  53. package/src/env.d.ts +12 -0
  54. package/src/includes/cloudflare/d1/client.ts +86 -0
  55. package/src/includes/cloudflare/r2/README.md +68 -0
  56. package/src/includes/cloudflare/r2/media.ts +41 -0
  57. package/src/includes/cloudflare/r2/url.test.ts +44 -0
  58. package/src/includes/cloudflare/r2/url.ts +34 -0
  59. package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
  60. package/src/includes/cloudflare/turnstile/verify.ts +157 -0
  61. package/src/includes/cloudflare/workers/env.ts +56 -0
  62. package/src/includes/google/tag-manager/TagManager.astro +73 -0
  63. package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
  64. package/src/includes/sinch/mailgun/redirect.ts +96 -0
  65. package/src/includes/sinch/mailgun/send.ts +186 -0
  66. package/src/includes/webmonterey/animations/animations.css +347 -0
  67. package/src/includes/webmonterey/animations/observe.test.ts +88 -0
  68. package/src/includes/webmonterey/animations/observe.ts +209 -0
  69. package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
  70. package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
  71. package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
  72. package/src/includes/webmonterey/compliance/consent.ts +142 -0
  73. package/src/includes/webmonterey/config.test.ts +94 -0
  74. package/src/includes/webmonterey/config.ts +346 -0
  75. package/src/includes/webmonterey/copy-defaults.ts +148 -0
  76. package/src/includes/webmonterey/copy.ts +13 -0
  77. package/src/includes/webmonterey/credits/Credit.astro +80 -0
  78. package/src/includes/webmonterey/credits/credit.test.ts +111 -0
  79. package/src/includes/webmonterey/credits/credit.ts +59 -0
  80. package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
  81. package/src/includes/webmonterey/forms/honeypot.ts +66 -0
  82. package/src/includes/webmonterey/prose/inline.test.ts +94 -0
  83. package/src/includes/webmonterey/prose/inline.ts +71 -0
  84. package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
  85. package/src/includes/webmonterey/site.ts +136 -0
  86. package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
  87. package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
  88. package/src/integration/adapter.ts +53 -0
  89. package/src/integration/app-middleware.ts +43 -0
  90. package/src/integration/config.ts +96 -0
  91. package/src/integration/content.ts +66 -0
  92. package/src/integration/image-size.test.ts +83 -0
  93. package/src/integration/image-size.ts +100 -0
  94. package/src/integration/index.ts +386 -0
  95. package/src/integration/virtual.d.ts +101 -0
  96. package/src/layouts/base.astro +402 -0
  97. package/src/package.test.ts +121 -0
  98. package/src/pages/404.astro +33 -0
  99. package/src/pages/[...slug].astro +126 -0
  100. package/src/pages/robots.txt.ts +41 -0
  101. package/src/pages/webm.astro +23 -0
  102. package/src/scripts/.gitkeep +0 -0
  103. package/src/styles/base.css +185 -0
  104. package/src/styles/global.css +47 -0
  105. package/src/styles/layers.test.ts +32 -0
  106. package/src/styles/layers.ts +21 -0
  107. package/src/styles/layout.css +235 -0
  108. package/src/styles/reset.css +135 -0
  109. package/src/styles/utilities.css +90 -0
  110. package/src/worker.ts +76 -0
  111. package/template/assets/logo.svg +14 -0
  112. package/template/assets/open-graph.png +0 -0
  113. package/template/migrations/0001_create_submissions.sql +40 -0
  114. package/template/migrations/README.md +41 -0
  115. package/template/public/android-chrome-192x192.png +0 -0
  116. package/template/public/android-chrome-512x512.png +0 -0
  117. package/template/public/apple-touch-icon.png +0 -0
  118. package/template/public/favicon-16x16.png +0 -0
  119. package/template/public/favicon-32x32.png +0 -0
  120. package/template/public/favicon.ico +0 -0
  121. package/template/public/favicon.svg +4 -0
  122. package/template/public/open-graph.png +0 -0
  123. package/template/scripts/check-node.mjs +72 -0
  124. package/template/scripts/test-hooks.mjs +96 -0
  125. package/template/site/.editorconfig +22 -0
  126. package/template/site/.prettierignore +15 -0
  127. package/template/site/.prettierrc.json +17 -0
  128. package/template/site/CLAUDE.md +245 -0
  129. package/template/site/CONTENT.md +135 -0
  130. package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
  131. package/template/site/public/_headers +70 -0
  132. package/template/site/src/forms/contact.json +45 -0
  133. package/template/workflows/ci.yml +52 -0
@@ -0,0 +1,74 @@
1
+ ---
2
+ /*
3
+ * Consent bootstrap. MUST be the first thing in <head>, ABOVE <TagManager />.
4
+ *
5
+ * <head>
6
+ * <ConsentInit />
7
+ * <TagManager id={gtmId} />
8
+ * </head>
9
+ *
10
+ * Everything here runs synchronously and inline, before any third party loads, because:
11
+ *
12
+ * 1. Google Consent Mode requires the `default` call to precede the GTM snippet. Arriving
13
+ * late means tags have already fired under the wrong assumption.
14
+ * 2. `data-webm-consent` has to exist before any feature script reads it, or features race
15
+ * the banner.
16
+ *
17
+ * It is deliberately dependency-free and unbundled (`is:inline`) — a module import would be
18
+ * deferred, which defeats both points above.
19
+ *
20
+ * DISABLING: set features.compliance = false in webmonterey.json. This component then renders
21
+ * NOTHING: no attribute, no Consent Mode defaults, no banner. Features gated with
22
+ * `hasConsent()` / `whenConsented()` see everything granted and simply run. See consent.ts.
23
+ */
24
+ import { features } from '../site.ts';
25
+
26
+ interface Props {
27
+ /**
28
+ * Categories granted before the visitor chooses anything.
29
+ *
30
+ * Defaults to all of them — an opt-out model, lawful under CCPA/CPRA for US audiences.
31
+ * Pass `[]` for an opt-in model if a client takes EU/UK traffic, where GDPR requires
32
+ * non-essential categories to start off.
33
+ */
34
+ defaults?: Array<'functional' | 'analytics' | 'marketing'>;
35
+ }
36
+
37
+ const { defaults = ['functional', 'analytics', 'marketing'] } = Astro.props;
38
+
39
+ const enabled = features.compliance !== false;
40
+ const defaultsJson = JSON.stringify(defaults);
41
+ ---
42
+
43
+ {
44
+ enabled && (
45
+ <script
46
+ is:inline
47
+ set:html={`(function(){
48
+ var d=document.documentElement,DEF=${defaultsJson};
49
+ var m=document.cookie.match(/(?:^|;\\s*)webm_consent=([^;]*)/);
50
+ var stored=m?decodeURIComponent(m[1]):null,granted,pending=false;
51
+ var gpc=navigator.globalPrivacyControl===true;
52
+ if(stored!==null){granted=stored.split(',').filter(Boolean);}
53
+ else if(gpc){granted=['functional'];d.dataset.webmConsentGpc='1';}
54
+ else{granted=DEF.slice();pending=true;}
55
+ d.dataset.webmConsent=granted.join(',');
56
+ if(pending)d.dataset.webmConsentPending='1';
57
+ var has=function(c){return granted.indexOf(c)>-1;};
58
+ var g=function(v){return v?'granted':'denied';};
59
+ window.dataLayer=window.dataLayer||[];
60
+ function gtag(){dataLayer.push(arguments);}
61
+ window.gtag=window.gtag||gtag;
62
+ gtag('consent','default',{
63
+ ad_storage:g(has('marketing')),
64
+ ad_user_data:g(has('marketing')),
65
+ ad_personalization:g(has('marketing')),
66
+ analytics_storage:g(has('analytics')),
67
+ functionality_storage:g(has('functional')),
68
+ personalization_storage:g(has('functional')),
69
+ security_storage:'granted'
70
+ });
71
+ })();`}
72
+ />
73
+ )
74
+ }
@@ -0,0 +1,558 @@
1
+ ---
2
+ import { copy } from '../copy.ts';
3
+ /*
4
+ * Cookie consent — banner and preferences dialog. Goes near the end of <body>.
5
+ *
6
+ * <body>
7
+ * …
8
+ * <CookieConsent />
9
+ * </body>
10
+ *
11
+ * Pairs with <ConsentInit /> in <head>, which does the actual state and Consent Mode work.
12
+ * This component is only the UI.
13
+ *
14
+ * The dialog is a native <dialog> element: focus trapping, Escape, inertness of the page
15
+ * behind it, and the top layer all come from the platform. Do not reimplement any of that.
16
+ *
17
+ * DISABLING: features.compliance = false in webmonterey.json renders nothing at all.
18
+ */
19
+ import { features } from '../site.ts';
20
+
21
+ interface Props {
22
+ /** Banner heading. */
23
+ title?: string;
24
+ /** Banner body copy. */
25
+ body?: string;
26
+ /** Where "Privacy policy" points. Omit to hide the link. */
27
+ privacyUrl?: string;
28
+ }
29
+
30
+ const { title = copy.consent.title, body = copy.consent.body, privacyUrl } = Astro.props;
31
+
32
+ const enabled = features.compliance !== false;
33
+
34
+ const CATEGORIES = [
35
+ {
36
+ id: 'essential',
37
+ name: copy.consent.categories.essential.name,
38
+ description: copy.consent.categories.essential.description,
39
+ locked: true,
40
+ },
41
+ {
42
+ id: 'functional',
43
+ name: copy.consent.categories.functional.name,
44
+ description: copy.consent.categories.functional.description,
45
+ locked: false,
46
+ },
47
+ {
48
+ id: 'analytics',
49
+ name: copy.consent.categories.analytics.name,
50
+ description: copy.consent.categories.analytics.description,
51
+ locked: false,
52
+ },
53
+ {
54
+ id: 'marketing',
55
+ name: copy.consent.categories.marketing.name,
56
+ description: copy.consent.categories.marketing.description,
57
+ locked: false,
58
+ },
59
+ ] as const;
60
+ ---
61
+
62
+ {
63
+ enabled && (
64
+ <>
65
+ {/* Banner. Hidden until the script confirms no decision has been recorded. */}
66
+ <div
67
+ class="webm-consent-banner"
68
+ role="region"
69
+ aria-label={copy.consent.notice}
70
+ hidden
71
+ data-webm-consent-banner
72
+ >
73
+ <div class="webm-consent-banner__inner">
74
+ <h2 class="webm-consent-banner__title">{title}</h2>
75
+ <p class="webm-consent-banner__body">
76
+ {body}
77
+ {privacyUrl && (
78
+ <>
79
+ {' '}
80
+ <a href={privacyUrl}>{copy.consent.privacyLink}</a>.
81
+ </>
82
+ )}
83
+ </p>
84
+ <div class="webm-consent-banner__actions">
85
+ <button type="button" class="webm-consent-btn" data-webm-consent-manage>
86
+ {copy.consent.manage}
87
+ </button>
88
+ <button type="button" class="webm-consent-btn" data-webm-consent-reject>
89
+ {copy.consent.rejectAll}
90
+ </button>
91
+ <button
92
+ type="button"
93
+ class="webm-consent-btn webm-consent-btn--primary"
94
+ data-webm-consent-accept
95
+ >
96
+ {copy.consent.acceptAll}
97
+ </button>
98
+ </div>
99
+ </div>
100
+ </div>
101
+
102
+ {/* Preferences dialog. */}
103
+ <dialog
104
+ class="webm-consent-dialog"
105
+ aria-labelledby="webm-consent-title"
106
+ data-webm-consent-dialog
107
+ >
108
+ <form method="dialog" class="webm-consent-dialog__form">
109
+ <div class="webm-consent-dialog__head">
110
+ <h2 class="webm-consent-dialog__title" id="webm-consent-title">
111
+ {copy.consent.prefsTitle}
112
+ </h2>
113
+ <button
114
+ type="button"
115
+ class="webm-consent-dialog__close"
116
+ aria-label="Close"
117
+ data-webm-consent-close
118
+ >
119
+ <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
120
+ <path
121
+ d="M6 6l12 12M18 6L6 18"
122
+ fill="none"
123
+ stroke="currentColor"
124
+ stroke-width="2"
125
+ stroke-linecap="round"
126
+ />
127
+ </svg>
128
+ </button>
129
+ </div>
130
+
131
+ <p class="webm-consent-dialog__intro">
132
+ Choose which categories of cookies this site may use. Essential cookies are always on.
133
+ Your choice is saved for one year.
134
+ </p>
135
+
136
+ <p class="webm-consent-dialog__gpc" hidden data-webm-consent-gpc-note>
137
+ Your browser is sending a Global Privacy Control signal, so analytics and marketing were
138
+ turned off automatically. You can still change these below.
139
+ </p>
140
+
141
+ <ul class="webm-consent-list" role="list">
142
+ {CATEGORIES.map((category) => (
143
+ <li class="webm-consent-item">
144
+ <div class="webm-consent-item__text">
145
+ <h3 class="webm-consent-item__name">{category.name}</h3>
146
+ <p class="webm-consent-item__desc">{category.description}</p>
147
+ </div>
148
+ <label class="webm-consent-switch">
149
+ <input
150
+ type="checkbox"
151
+ name={category.id}
152
+ checked={category.locked}
153
+ disabled={category.locked}
154
+ aria-label={`${category.name} cookies`}
155
+ />
156
+ <span class="webm-consent-switch__track" aria-hidden="true" />
157
+ </label>
158
+ </li>
159
+ ))}
160
+ </ul>
161
+
162
+ <div class="webm-consent-dialog__actions">
163
+ <button type="button" class="webm-consent-btn" data-webm-consent-reject>
164
+ {copy.consent.rejectAll}
165
+ </button>
166
+ <button type="button" class="webm-consent-btn" data-webm-consent-accept>
167
+ {copy.consent.acceptAll}
168
+ </button>
169
+ <button
170
+ type="button"
171
+ class="webm-consent-btn webm-consent-btn--primary"
172
+ data-webm-consent-save
173
+ >
174
+ {copy.consent.save}
175
+ </button>
176
+ </div>
177
+ </form>
178
+ </dialog>
179
+ </>
180
+ )
181
+ }
182
+
183
+ <style>
184
+ @layer webm.components.core {
185
+ /* --- shared button ---------------------------------------------------- */
186
+
187
+ /*
188
+ * The border is load-bearing, not decoration. reset.css sets `button { border: none }`,
189
+ * so without an explicit border these render as a fill only — and the secondary fill is
190
+ * --webm-surface-alt (#f1eae8) sitting on --webm-surface-raised (#fff), which is 1.19:1.
191
+ * That is below the threshold where an edge is perceptible at all, so "Reject all" and
192
+ * "Manage preferences" read as plain text while only the primary button looks pressable.
193
+ * Reported from a live site. Restyle freely, but keep SOME visible edge on the secondary
194
+ * variant or the choice architecture quietly favours "Accept all".
195
+ */
196
+ .webm-consent-btn {
197
+ padding: var(--webm-space-xs) var(--webm-space-md);
198
+ border: var(--webm-border-width-1) solid var(--webm-border-interactive);
199
+ border-radius: var(--webm-radius-md);
200
+ background-color: var(--webm-surface-alt);
201
+ color: var(--webm-text);
202
+ font-size: var(--webm-font-size-xs);
203
+ font-weight: var(--webm-weight-medium);
204
+ white-space: nowrap;
205
+ transition:
206
+ background-color var(--webm-duration-fast) var(--webm-ease-out),
207
+ border-color var(--webm-duration-fast) var(--webm-ease-out);
208
+ }
209
+
210
+ .webm-consent-btn:hover {
211
+ background-color: var(--webm-border-subtle);
212
+ }
213
+
214
+ /* Border matches the fill, so the primary reads as one solid shape rather than outlined. */
215
+ .webm-consent-btn--primary {
216
+ border-color: var(--webm-action);
217
+ background-color: var(--webm-action);
218
+ color: var(--webm-text-on-action);
219
+ }
220
+
221
+ .webm-consent-btn--primary:hover {
222
+ border-color: var(--webm-action-dark);
223
+ background-color: var(--webm-action-dark);
224
+ }
225
+
226
+ /* --- banner ----------------------------------------------------------- */
227
+
228
+ .webm-consent-banner {
229
+ position: fixed;
230
+ inset-block-end: var(--webm-space-md);
231
+ inset-inline-start: var(--webm-space-md);
232
+ z-index: var(--webm-z-toast);
233
+ max-inline-size: min(32rem, calc(100vw - var(--webm-space-lg)));
234
+ background-color: var(--webm-surface-raised);
235
+ border-radius: var(--webm-radius-lg);
236
+ box-shadow: var(--webm-shadow-xl);
237
+ }
238
+
239
+ .webm-consent-banner__inner {
240
+ padding: var(--webm-space-lg);
241
+ display: flex;
242
+ flex-direction: column;
243
+ gap: var(--webm-space-sm);
244
+ }
245
+
246
+ .webm-consent-banner__title {
247
+ /*
248
+ * `md`, not `lg`. This is a notice, not a page heading — at `lg` it out-shouts the
249
+ * page's own h1 while carrying far less meaning.
250
+ */
251
+ font-size: var(--webm-font-size-md);
252
+ font-weight: var(--webm-weight-semibold);
253
+ line-height: var(--webm-leading-snug);
254
+ }
255
+
256
+ .webm-consent-banner__body {
257
+ font-size: var(--webm-font-size-xs);
258
+ color: var(--webm-text-muted);
259
+ }
260
+
261
+ .webm-consent-banner__actions {
262
+ display: flex;
263
+ flex-wrap: wrap;
264
+ gap: var(--webm-space-2xs);
265
+ margin-block-start: var(--webm-space-2xs);
266
+ }
267
+
268
+ /* "Manage preferences" is the low-emphasis choice — push the decisions right. */
269
+ .webm-consent-banner__actions > :first-child {
270
+ margin-inline-end: auto;
271
+ }
272
+
273
+ /* --- dialog ----------------------------------------------------------- */
274
+
275
+ .webm-consent-dialog {
276
+ inline-size: min(34rem, calc(100vw - var(--webm-space-lg)));
277
+ max-block-size: min(44rem, calc(100vh - var(--webm-space-lg)));
278
+ padding: 0;
279
+ border: 0;
280
+ border-radius: var(--webm-radius-lg);
281
+ background-color: var(--webm-surface-raised);
282
+ color: var(--webm-text);
283
+ box-shadow: var(--webm-shadow-xl);
284
+ }
285
+
286
+ /*
287
+ * `[open]`, NOT the bare element. A closed <dialog> is display:none, and a plain
288
+ * `display: flex` here would override that and leave the dialog on the page permanently.
289
+ * It is opened with showModal(), so [open] is present exactly when it is visible.
290
+ *
291
+ * Flex column is what lets the category list scroll while the buttons stay put - see the
292
+ * form rule below.
293
+ */
294
+ .webm-consent-dialog[open] {
295
+ display: flex;
296
+ flex-direction: column;
297
+ }
298
+
299
+ .webm-consent-dialog::backdrop {
300
+ background-color: rgb(0 0 0 / 0.5);
301
+ }
302
+
303
+ /*
304
+ * THE FORM DOES NOT SCROLL - THE CATEGORY LIST DOES.
305
+ *
306
+ * It used to carry `overflow-y: auto` for the whole form, which put the action buttons
307
+ * inside the scrolling region. With four categories on a laptop that pushed "Reject all"
308
+ * and "Save preferences" below the fold: you had to scroll a consent dialog to find the
309
+ * way to decline. That is the exact shape regulators treat as a dark pattern, and it was
310
+ * an accident rather than a choice.
311
+ *
312
+ * `min-block-size: 0` is the load-bearing line. A flex item's default min-height is
313
+ * `auto`, which refuses to shrink below its content - so the inner scroller never gets a
314
+ * bounded height and the overflow silently moves back out to the dialog.
315
+ */
316
+ .webm-consent-dialog__form {
317
+ display: flex;
318
+ flex-direction: column;
319
+ gap: var(--webm-space-md);
320
+ padding: var(--webm-space-lg);
321
+ min-block-size: 0;
322
+ overflow: hidden;
323
+ }
324
+
325
+ .webm-consent-dialog__head {
326
+ display: flex;
327
+ align-items: start;
328
+ justify-content: space-between;
329
+ gap: var(--webm-space-sm);
330
+ }
331
+
332
+ .webm-consent-dialog__title {
333
+ font-size: var(--webm-font-size-lg);
334
+ font-weight: var(--webm-weight-bold);
335
+ }
336
+
337
+ .webm-consent-dialog__close {
338
+ color: var(--webm-text-muted);
339
+ padding: var(--webm-space-3xs);
340
+ border-radius: var(--webm-radius-sm);
341
+ }
342
+
343
+ .webm-consent-dialog__close:hover {
344
+ color: var(--webm-text);
345
+ }
346
+
347
+ .webm-consent-dialog__intro,
348
+ .webm-consent-dialog__gpc {
349
+ font-size: var(--webm-font-size-xs);
350
+ color: var(--webm-text-muted);
351
+ }
352
+
353
+ .webm-consent-dialog__gpc {
354
+ padding: var(--webm-space-xs) var(--webm-space-sm);
355
+ border-radius: var(--webm-radius-md);
356
+ background-color: var(--webm-surface-alt);
357
+ color: var(--webm-text);
358
+ }
359
+
360
+ /*
361
+ * Pinned below the scrolling list, never compressed. `flex-shrink: 0` matters: without it
362
+ * a short viewport squeezes the buttons instead of the list, and they end up clipped
363
+ * rather than scrolled - which looks like a rendering bug and is harder to diagnose.
364
+ */
365
+ .webm-consent-dialog__actions {
366
+ display: flex;
367
+ flex-wrap: wrap;
368
+ justify-content: flex-end;
369
+ gap: var(--webm-space-2xs);
370
+ flex-shrink: 0;
371
+ }
372
+
373
+ /* --- category list ---------------------------------------------------- */
374
+
375
+ .webm-consent-list {
376
+ list-style: none;
377
+ padding: 0;
378
+ display: flex;
379
+ flex-direction: column;
380
+ gap: var(--webm-space-2xs);
381
+ /* The one scrolling region. See the form rule above for why min-block-size is here. */
382
+ min-block-size: 0;
383
+ overflow-y: auto;
384
+ }
385
+
386
+ .webm-consent-item {
387
+ display: flex;
388
+ align-items: start;
389
+ justify-content: space-between;
390
+ gap: var(--webm-space-md);
391
+ padding: var(--webm-space-sm) var(--webm-space-md);
392
+ border-radius: var(--webm-radius-md);
393
+ background-color: var(--webm-surface-alt);
394
+ }
395
+
396
+ .webm-consent-item__name {
397
+ font-size: var(--webm-font-size-sm);
398
+ font-weight: var(--webm-weight-semibold);
399
+ }
400
+
401
+ .webm-consent-item__desc {
402
+ margin-block-start: var(--webm-space-3xs);
403
+ font-size: var(--webm-font-size-xs);
404
+ color: var(--webm-text-muted);
405
+ }
406
+
407
+ /* --- switch ----------------------------------------------------------- */
408
+
409
+ .webm-consent-switch {
410
+ flex-shrink: 0;
411
+ display: inline-flex;
412
+ cursor: pointer;
413
+ }
414
+
415
+ /*
416
+ * The checkbox stays in the accessibility tree and keeps native keyboard behavior and
417
+ * focus; only its painting is replaced. Do not swap it for a div with role="switch".
418
+ */
419
+ .webm-consent-switch input {
420
+ position: absolute;
421
+ opacity: 0;
422
+ inline-size: 1px;
423
+ block-size: 1px;
424
+ }
425
+
426
+ .webm-consent-switch__track {
427
+ display: block;
428
+ inline-size: 2.75rem;
429
+ block-size: 1.5rem;
430
+ padding: 0.1875rem;
431
+ border-radius: var(--webm-radius-pill);
432
+ background-color: var(--webm-border-subtle);
433
+ transition: background-color var(--webm-duration-fast) var(--webm-ease-out);
434
+ }
435
+
436
+ .webm-consent-switch__track::after {
437
+ content: '';
438
+ display: block;
439
+ inline-size: 1.125rem;
440
+ block-size: 1.125rem;
441
+ border-radius: var(--webm-radius-circle);
442
+ background-color: var(--webm-base-100);
443
+ box-shadow: var(--webm-shadow-sm);
444
+ transition: translate var(--webm-duration-fast) var(--webm-ease-out);
445
+ }
446
+
447
+ .webm-consent-switch input:checked + .webm-consent-switch__track {
448
+ background-color: var(--webm-action);
449
+ }
450
+
451
+ .webm-consent-switch input:checked + .webm-consent-switch__track::after {
452
+ translate: 1.25rem 0;
453
+ }
454
+
455
+ /* Essential: on, but visibly not yours to change. */
456
+ .webm-consent-switch input:disabled + .webm-consent-switch__track {
457
+ opacity: 0.55;
458
+ cursor: not-allowed;
459
+ }
460
+
461
+ .webm-consent-switch input:focus-visible + .webm-consent-switch__track {
462
+ outline: var(--webm-focus-ring);
463
+ outline-offset: var(--webm-focus-offset);
464
+ }
465
+ }
466
+ </style>
467
+
468
+ <script>
469
+ import { CONSENT_COOKIE, CONSENT_EVENT, CONSENT_MAX_AGE, getConsent } from './consent.ts';
470
+
471
+ const banner = document.querySelector<HTMLElement>('[data-webm-consent-banner]');
472
+ const dialog = document.querySelector<HTMLDialogElement>('[data-webm-consent-dialog]');
473
+
474
+ if (banner && dialog) {
475
+ const root = document.documentElement;
476
+ const OPTIONAL = ['functional', 'analytics', 'marketing'] as const;
477
+
478
+ const commit = (granted: readonly string[]) => {
479
+ const value = granted.join(',');
480
+
481
+ // `SameSite=Lax` and no `Secure` on localhost, so it works in dev over http.
482
+ const secure = location.protocol === 'https:' ? '; Secure' : '';
483
+ document.cookie = `${CONSENT_COOKIE}=${encodeURIComponent(value)}; Max-Age=${CONSENT_MAX_AGE}; Path=/; SameSite=Lax${secure}`;
484
+
485
+ root.dataset.webmConsent = value;
486
+ delete root.dataset.webmConsentPending;
487
+
488
+ const has = (c: string) => granted.includes(c);
489
+ const g = (v: boolean) => (v ? 'granted' : 'denied');
490
+
491
+ /*
492
+ * Tell Google what changed. Use the gtag() function ConsentInit defined, NOT a raw
493
+ * dataLayer.push of an array — gtag pushes the `arguments` object, and GTM's consent
494
+ * handling keys off that shape. Harmless when GTM is absent: it just appends to an
495
+ * array nobody reads.
496
+ */
497
+ window.gtag?.('consent', 'update', {
498
+ ad_storage: g(has('marketing')),
499
+ ad_user_data: g(has('marketing')),
500
+ ad_personalization: g(has('marketing')),
501
+ analytics_storage: g(has('analytics')),
502
+ functionality_storage: g(has('functional')),
503
+ personalization_storage: g(has('functional')),
504
+ security_storage: 'granted',
505
+ });
506
+
507
+ banner.hidden = true;
508
+ if (dialog.open) dialog.close();
509
+
510
+ // Features listening via whenConsented()/onConsentChange() react to this.
511
+ document.dispatchEvent(new CustomEvent(CONSENT_EVENT));
512
+ };
513
+
514
+ const syncToggles = () => {
515
+ const state = getConsent();
516
+ for (const id of OPTIONAL) {
517
+ const input = dialog.querySelector<HTMLInputElement>(`input[name="${id}"]`);
518
+ if (input) input.checked = state[id];
519
+ }
520
+ const note = dialog.querySelector<HTMLElement>('[data-webm-consent-gpc-note]');
521
+ if (note) note.hidden = root.dataset.webmConsentGpc !== '1';
522
+ };
523
+
524
+ const openDialog = () => {
525
+ syncToggles();
526
+ dialog.showModal();
527
+ };
528
+
529
+ // Banner shows only when no decision is recorded. A GPC signal counts as a decision —
530
+ // re-asking would undermine the signal the visitor already sent.
531
+ if (root.dataset.webmConsentPending === '1') banner.hidden = false;
532
+
533
+ dialog
534
+ .querySelector('[data-webm-consent-close]')
535
+ ?.addEventListener('click', () => dialog.close());
536
+
537
+ for (const el of document.querySelectorAll('[data-webm-consent-manage]')) {
538
+ el.addEventListener('click', openDialog);
539
+ }
540
+ for (const el of document.querySelectorAll('[data-webm-consent-accept]')) {
541
+ el.addEventListener('click', () => commit(['functional', 'analytics', 'marketing']));
542
+ }
543
+ for (const el of document.querySelectorAll('[data-webm-consent-reject]')) {
544
+ el.addEventListener('click', () => commit([]));
545
+ }
546
+
547
+ dialog.querySelector('[data-webm-consent-save]')?.addEventListener('click', () => {
548
+ commit(
549
+ OPTIONAL.filter(
550
+ (id) => dialog.querySelector<HTMLInputElement>(`input[name="${id}"]`)?.checked,
551
+ ),
552
+ );
553
+ });
554
+
555
+ // openPreferences() from consent.ts — lets a footer link reopen this.
556
+ document.addEventListener('webm:consent-open', openDialog);
557
+ }
558
+ </script>