@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.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/agents/.gitkeep +0 -0
- package/dist/webm.mjs +2381 -0
- package/hooks/.gitkeep +0 -0
- package/package.json +101 -0
- package/schema/design.json +118 -0
- package/skills/launch/SKILL.md +183 -0
- package/skills/new-component/SKILL.md +85 -0
- package/skills/start/SKILL.md +117 -0
- package/skills/traps/SKILL.md +333 -0
- package/skills/upgrade/SKILL.md +42 -0
- package/src/actions/index.ts +348 -0
- package/src/cli/checks.test.ts +711 -0
- package/src/cli/checks.ts +822 -0
- package/src/cli/codemods.test.ts +42 -0
- package/src/cli/codemods.ts +51 -0
- package/src/cli/compare.test.ts +144 -0
- package/src/cli/compare.ts +222 -0
- package/src/cli/design-extract.test.ts +96 -0
- package/src/cli/design-extract.ts +229 -0
- package/src/cli/doctor.ts +187 -0
- package/src/cli/mcp.test.ts +57 -0
- package/src/cli/mcp.ts +110 -0
- package/src/cli/new.ts +148 -0
- package/src/cli/package-root.ts +58 -0
- package/src/cli/scaffold.test.ts +230 -0
- package/src/cli/scaffold.ts +424 -0
- package/src/cli/seed.ts +133 -0
- package/src/cli/slug.test.ts +70 -0
- package/src/cli/slug.ts +109 -0
- package/src/cli/sync.test.ts +137 -0
- package/src/cli/sync.ts +266 -0
- package/src/cli/upgrade.ts +93 -0
- package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
- package/src/design/brand.ts +49 -0
- package/src/design/compile.test.ts +98 -0
- package/src/design/compile.ts +155 -0
- package/src/design/defaults.ts +315 -0
- package/src/design/index.ts +18 -0
- package/src/design/resolve.test.ts +80 -0
- package/src/design/resolve.ts +108 -0
- package/src/design/types.ts +96 -0
- package/src/emails/autoresponse.test.ts +82 -0
- package/src/emails/autoresponse.ts +83 -0
- package/src/emails/footer.test.ts +102 -0
- package/src/emails/footer.ts +91 -0
- package/src/emails/index.ts +24 -0
- package/src/emails/subject.test.ts +66 -0
- package/src/emails/subject.ts +80 -0
- package/src/emails/submission-notification.ts +76 -0
- package/src/env.d.ts +12 -0
- package/src/includes/cloudflare/d1/client.ts +86 -0
- package/src/includes/cloudflare/r2/README.md +68 -0
- package/src/includes/cloudflare/r2/media.ts +41 -0
- package/src/includes/cloudflare/r2/url.test.ts +44 -0
- package/src/includes/cloudflare/r2/url.ts +34 -0
- package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
- package/src/includes/cloudflare/turnstile/verify.ts +157 -0
- package/src/includes/cloudflare/workers/env.ts +56 -0
- package/src/includes/google/tag-manager/TagManager.astro +73 -0
- package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
- package/src/includes/sinch/mailgun/redirect.ts +96 -0
- package/src/includes/sinch/mailgun/send.ts +186 -0
- package/src/includes/webmonterey/animations/animations.css +347 -0
- package/src/includes/webmonterey/animations/observe.test.ts +88 -0
- package/src/includes/webmonterey/animations/observe.ts +209 -0
- package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
- package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
- package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
- package/src/includes/webmonterey/compliance/consent.ts +142 -0
- package/src/includes/webmonterey/config.test.ts +94 -0
- package/src/includes/webmonterey/config.ts +346 -0
- package/src/includes/webmonterey/copy-defaults.ts +148 -0
- package/src/includes/webmonterey/copy.ts +13 -0
- package/src/includes/webmonterey/credits/Credit.astro +80 -0
- package/src/includes/webmonterey/credits/credit.test.ts +111 -0
- package/src/includes/webmonterey/credits/credit.ts +59 -0
- package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
- package/src/includes/webmonterey/forms/honeypot.ts +66 -0
- package/src/includes/webmonterey/prose/inline.test.ts +94 -0
- package/src/includes/webmonterey/prose/inline.ts +71 -0
- package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
- package/src/includes/webmonterey/site.ts +136 -0
- package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
- package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
- package/src/integration/adapter.ts +53 -0
- package/src/integration/app-middleware.ts +43 -0
- package/src/integration/config.ts +96 -0
- package/src/integration/content.ts +66 -0
- package/src/integration/image-size.test.ts +83 -0
- package/src/integration/image-size.ts +100 -0
- package/src/integration/index.ts +386 -0
- package/src/integration/virtual.d.ts +101 -0
- package/src/layouts/base.astro +402 -0
- package/src/package.test.ts +121 -0
- package/src/pages/404.astro +33 -0
- package/src/pages/[...slug].astro +126 -0
- package/src/pages/robots.txt.ts +41 -0
- package/src/pages/webm.astro +23 -0
- package/src/scripts/.gitkeep +0 -0
- package/src/styles/base.css +185 -0
- package/src/styles/global.css +47 -0
- package/src/styles/layers.test.ts +32 -0
- package/src/styles/layers.ts +21 -0
- package/src/styles/layout.css +235 -0
- package/src/styles/reset.css +135 -0
- package/src/styles/utilities.css +90 -0
- package/src/worker.ts +76 -0
- package/template/assets/logo.svg +14 -0
- package/template/assets/open-graph.png +0 -0
- package/template/migrations/0001_create_submissions.sql +40 -0
- package/template/migrations/README.md +41 -0
- package/template/public/android-chrome-192x192.png +0 -0
- package/template/public/android-chrome-512x512.png +0 -0
- package/template/public/apple-touch-icon.png +0 -0
- package/template/public/favicon-16x16.png +0 -0
- package/template/public/favicon-32x32.png +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/favicon.svg +4 -0
- package/template/public/open-graph.png +0 -0
- package/template/scripts/check-node.mjs +72 -0
- package/template/scripts/test-hooks.mjs +96 -0
- package/template/site/.editorconfig +22 -0
- package/template/site/.prettierignore +15 -0
- package/template/site/.prettierrc.json +17 -0
- package/template/site/CLAUDE.md +245 -0
- package/template/site/CONTENT.md +135 -0
- package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
- package/template/site/public/_headers +70 -0
- package/template/site/src/forms/contact.json +45 -0
- 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>
|