create-website-build-kit 0.1.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 (91) hide show
  1. package/README.md +54 -0
  2. package/index.mjs +149 -0
  3. package/package.json +42 -0
  4. package/template/.dev.vars.example +3 -0
  5. package/template/.github/workflows/gates.yml +58 -0
  6. package/template/.node-version +1 -0
  7. package/template/.pa11yci.json +24 -0
  8. package/template/BUILD-STATE.md +47 -0
  9. package/template/CLAUDE.md +153 -0
  10. package/template/astro.config.mjs +150 -0
  11. package/template/docs/analytics.md +86 -0
  12. package/template/docs/content.md +138 -0
  13. package/template/docs/handover.md +182 -0
  14. package/template/docs/handover.pdf +0 -0
  15. package/template/docs/runbook.md +661 -0
  16. package/template/docs/traps.md +903 -0
  17. package/template/gitignore +31 -0
  18. package/template/package-lock.json +8159 -0
  19. package/template/package.json +53 -0
  20. package/template/public/_headers +61 -0
  21. package/template/public/_redirects +39 -0
  22. package/template/public/site.webmanifest +13 -0
  23. package/template/scripts/a11y-evidence.mjs +258 -0
  24. package/template/scripts/check-console.mjs +125 -0
  25. package/template/scripts/check-env.mjs +99 -0
  26. package/template/scripts/check-reflow.mjs +148 -0
  27. package/template/scripts/check-sitemap.mjs +113 -0
  28. package/template/scripts/dns-snapshot.mjs +267 -0
  29. package/template/scripts/extract.mjs +317 -0
  30. package/template/scripts/indexnow.mjs +154 -0
  31. package/template/scripts/lastmod.mjs +147 -0
  32. package/template/scripts/lib/inventory.mjs +104 -0
  33. package/template/scripts/lib/preserved.mjs +42 -0
  34. package/template/scripts/lib/routes.mjs +92 -0
  35. package/template/scripts/md-to-pdf.mjs +335 -0
  36. package/template/scripts/og-cards.config.mjs +114 -0
  37. package/template/scripts/og-cards.mjs +487 -0
  38. package/template/scripts/optimize-media.mjs +380 -0
  39. package/template/scripts/recon.mjs +480 -0
  40. package/template/scripts/redirects.mjs +298 -0
  41. package/template/scripts/shots.mjs +447 -0
  42. package/template/scripts/staging-headers.mjs +102 -0
  43. package/template/scripts/tells.mjs +268 -0
  44. package/template/scripts/verify.mjs +1069 -0
  45. package/template/src/components/ContactForm.astro +405 -0
  46. package/template/src/components/CtaBand.astro +82 -0
  47. package/template/src/components/EnvBadge.astro +146 -0
  48. package/template/src/components/Footer.astro +210 -0
  49. package/template/src/components/Header.astro +530 -0
  50. package/template/src/components/Icon.astro +56 -0
  51. package/template/src/components/Img.astro +129 -0
  52. package/template/src/components/PageHero.astro +88 -0
  53. package/template/src/components/Seo.astro +119 -0
  54. package/template/src/components/StructuredData.astro +173 -0
  55. package/template/src/content/blog/.gitkeep +5 -0
  56. package/template/src/content/legal/.gitkeep +0 -0
  57. package/template/src/content.config.ts +81 -0
  58. package/template/src/data/areas.ts +31 -0
  59. package/template/src/data/business.ts +121 -0
  60. package/template/src/data/categories.ts +37 -0
  61. package/template/src/data/fonts.ts +25 -0
  62. package/template/src/data/image-manifest.json +1 -0
  63. package/template/src/data/lastmod.json +1 -0
  64. package/template/src/data/nav.ts +49 -0
  65. package/template/src/data/services.ts +39 -0
  66. package/template/src/data/site.ts +136 -0
  67. package/template/src/env.d.ts +28 -0
  68. package/template/src/layouts/Base.astro +223 -0
  69. package/template/src/lib/brevo.ts +96 -0
  70. package/template/src/lib/hast-media.mjs +55 -0
  71. package/template/src/lib/lastmod.mjs +47 -0
  72. package/template/src/lib/lead.ts +92 -0
  73. package/template/src/lib/legal-routes.mjs +31 -0
  74. package/template/src/lib/legal.ts +75 -0
  75. package/template/src/lib/posts.ts +64 -0
  76. package/template/src/lib/runtime.ts +33 -0
  77. package/template/src/pages/404.astro +51 -0
  78. package/template/src/pages/[slug].astro +111 -0
  79. package/template/src/pages/accessibility.astro +128 -0
  80. package/template/src/pages/api/contact.ts +191 -0
  81. package/template/src/pages/api/leads.csv.ts +82 -0
  82. package/template/src/pages/contact.astro +112 -0
  83. package/template/src/pages/index.astro +84 -0
  84. package/template/src/pages/robots.txt.ts +38 -0
  85. package/template/src/pages/rss.xml.ts +27 -0
  86. package/template/src/styles/global.css +463 -0
  87. package/template/src/styles/project.css +14 -0
  88. package/template/src/styles/prose.css +182 -0
  89. package/template/src/styles/tokens.css +218 -0
  90. package/template/tsconfig.json +5 -0
  91. package/template/wrangler.jsonc +63 -0
@@ -0,0 +1,405 @@
1
+ ---
2
+ import { SERVICE_OPTIONS } from '../lib/lead';
3
+ import { business } from '../data/business';
4
+
5
+ /**
6
+ * Contact form.
7
+ *
8
+ * Here for its behaviour: server-side validation surfaced per field, a
9
+ * no-JavaScript path that still submits, a honeypot, a busy state that
10
+ * prevents double submission, and an outcome announced via role="status"
11
+ * without stealing focus.
12
+ *
13
+ * The styling is the STATES, not a look — hover, focus-visible, invalid,
14
+ * disabled, busy, succeeded. Restyle all of it; do not remove a state.
15
+ * The fields themselves are a project decision: ask for what you will act on,
16
+ * and nothing a regulator says you may not hold (compliance.md §10).
17
+ */
18
+
19
+ interface Props {
20
+ /** Field names the server rejected, for the no-JavaScript path. */
21
+ invalidFields?: string[];
22
+ }
23
+
24
+ const { invalidFields = [] } = Astro.props;
25
+ const invalid = (name: string) => (invalidFields.includes(name) ? '' : undefined);
26
+ ---
27
+
28
+ <!--
29
+ The trailing slash is load-bearing. `trailingSlash: 'always'` makes
30
+ /api/contact 308 to /api/contact/, and a redirected POST loses the request
31
+ body on the Workers runtime — the endpoint then looks broken while being
32
+ perfectly fine. Post to the canonical URL directly.
33
+
34
+ data-phone carries the number into the script, so a failure message can name
35
+ it. Never hard-code a phone number in a component: one left in an error
36
+ string is a previous client's number in this client's worst moment.
37
+ -->
38
+ <form
39
+ class="form"
40
+ method="post"
41
+ action="/api/contact/"
42
+ novalidate
43
+ data-contact-form
44
+ data-phone={business.phone.display}
45
+ >
46
+ <div class="form__grid">
47
+ <div class="field" data-invalid={invalid('name')}>
48
+ <label for="cf-name">Your name <span aria-hidden="true">*</span></label>
49
+ <input
50
+ id="cf-name"
51
+ name="name"
52
+ type="text"
53
+ autocomplete="name"
54
+ required
55
+ aria-describedby="cf-name-error"
56
+ />
57
+ <p class="field__error" id="cf-name-error" data-error-for="name" hidden></p>
58
+ </div>
59
+
60
+ <div class="field" data-invalid={invalid('phone')}>
61
+ <label for="cf-phone">Phone <span aria-hidden="true">*</span></label>
62
+ <input
63
+ id="cf-phone"
64
+ name="phone"
65
+ type="tel"
66
+ autocomplete="tel"
67
+ inputmode="tel"
68
+ required
69
+ aria-describedby="cf-phone-error"
70
+ />
71
+ <p class="field__error" id="cf-phone-error" data-error-for="phone" hidden></p>
72
+ </div>
73
+
74
+ <div class="field field--full" data-invalid={invalid('email')}>
75
+ <label for="cf-email">Email <span aria-hidden="true">*</span></label>
76
+ <input
77
+ id="cf-email"
78
+ name="email"
79
+ type="email"
80
+ autocomplete="email"
81
+ required
82
+ aria-describedby="cf-email-error"
83
+ />
84
+ <p class="field__error" id="cf-email-error" data-error-for="email" hidden></p>
85
+ </div>
86
+
87
+ {
88
+ SERVICE_OPTIONS.length > 0 && (
89
+ <div class="field field--full">
90
+ <label for="cf-service">What do you need?</label>
91
+ <select id="cf-service" name="service">
92
+ <option value="">Select…</option>
93
+ {SERVICE_OPTIONS.map((option) => (
94
+ <option value={option}>{option}</option>
95
+ ))}
96
+ </select>
97
+ </div>
98
+ )
99
+ }
100
+
101
+ <div class="field field--full" data-invalid={invalid('message')}>
102
+ <label for="cf-message">Message</label>
103
+ <textarea id="cf-message" name="message" rows="4" aria-describedby="cf-message-error"
104
+ ></textarea>
105
+ <p class="field__error" id="cf-message-error" data-error-for="message" hidden></p>
106
+ </div>
107
+ </div>
108
+
109
+ <!-- Honeypot. Hidden from sight and from assistive tech, and never focusable
110
+ via the keyboard, so no real person can fill it by accident. -->
111
+ <div class="form__trap" aria-hidden="true">
112
+ <label for="cf-company">Company</label>
113
+ <input id="cf-company" name="company" type="text" tabindex="-1" autocomplete="off" />
114
+ </div>
115
+
116
+ <input type="hidden" name="page" data-page-field value="" />
117
+
118
+ <div class="form__foot">
119
+ <button class="btn form__submit" type="submit">
120
+ <span data-submit-label>Send</span>
121
+ </button>
122
+ </div>
123
+
124
+ <!-- role="status" so the outcome is announced without stealing focus. -->
125
+ <p class="form__result" role="status" aria-live="polite" data-form-result hidden></p>
126
+ </form>
127
+
128
+ <style>
129
+ .form {
130
+ display: grid;
131
+ gap: var(--space-m);
132
+ }
133
+
134
+ .form__grid {
135
+ display: grid;
136
+ gap: var(--space-m);
137
+ }
138
+
139
+ @media (min-width: 34rem) {
140
+ .form__grid {
141
+ grid-template-columns: 1fr 1fr;
142
+ }
143
+ .field--full {
144
+ grid-column: 1 / -1;
145
+ }
146
+ }
147
+
148
+ .field {
149
+ display: grid;
150
+ gap: var(--space-3xs);
151
+ }
152
+
153
+ .field label {
154
+ font-size: var(--step--1);
155
+ font-weight: var(--weight-semibold);
156
+ }
157
+
158
+ .field input,
159
+ .field textarea,
160
+ .field select {
161
+ inline-size: 100%;
162
+ /* 44px minimum tap target, from padding so it survives a wrapped label. */
163
+ min-block-size: 2.75rem;
164
+ padding: 0.65em 0.9em;
165
+ font-size: var(--step-0);
166
+ color: var(--text);
167
+ background: var(--surface);
168
+ border: 1.5px solid var(--border-strong);
169
+ border-radius: var(--radius-s);
170
+ }
171
+
172
+ .field textarea {
173
+ resize: vertical;
174
+ min-block-size: 7rem;
175
+ }
176
+
177
+ .field input:hover,
178
+ .field textarea:hover,
179
+ .field select:hover {
180
+ border-color: var(--text-muted);
181
+ }
182
+
183
+ /* The outline is replaced, not removed — the ring is the visible focus
184
+ indicator and it must stay at least as obvious as the default. */
185
+ .field input:focus-visible,
186
+ .field textarea:focus-visible,
187
+ .field select:focus-visible {
188
+ outline: none;
189
+ border-color: var(--focus);
190
+ box-shadow: var(--ring);
191
+ }
192
+
193
+ /* Colour is never the only cue: the error text is what carries the meaning,
194
+ and aria-invalid carries it to assistive tech. */
195
+ .field[data-invalid] input,
196
+ .field[data-invalid] textarea {
197
+ border-color: var(--danger);
198
+ background: var(--danger-bg);
199
+ }
200
+
201
+ .field__error {
202
+ font-size: var(--step--1);
203
+ color: var(--danger);
204
+ }
205
+
206
+ .field__error[hidden] {
207
+ display: none;
208
+ }
209
+
210
+ .form__trap {
211
+ position: absolute;
212
+ inline-size: 1px;
213
+ block-size: 1px;
214
+ overflow: hidden;
215
+ clip-path: inset(50%);
216
+ white-space: nowrap;
217
+ }
218
+
219
+ .form__foot {
220
+ display: flex;
221
+ flex-wrap: wrap;
222
+ align-items: center;
223
+ gap: var(--space-s) var(--space-m);
224
+ }
225
+
226
+ .form__result {
227
+ padding: var(--space-s) var(--space-m);
228
+ border: 1px solid var(--border);
229
+ border-radius: var(--radius-s);
230
+ font-size: var(--step--1);
231
+ }
232
+
233
+ .form__result[hidden] {
234
+ display: none;
235
+ }
236
+
237
+ .form__result[data-tone='error'] {
238
+ border-color: var(--danger);
239
+ background: var(--danger-bg);
240
+ color: var(--danger);
241
+ }
242
+ </style>
243
+
244
+ <script>
245
+ /**
246
+ * Progressive enhancement, not a requirement.
247
+ *
248
+ * Without this script the form is a plain HTML POST to /api/contact — it
249
+ * still submits, still stores, still emails. The script only upgrades the
250
+ * response to inline field errors and an in-page confirmation.
251
+ */
252
+ /**
253
+ * The single place a lead conversion is announced. One announcer, two pipes.
254
+ *
255
+ * ── WHY BOTH ─────────────────────────────────────────────────────────────
256
+ * `gtag` reaches GA4 directly; `dataLayer` reaches a Tag Manager container.
257
+ * A site may have either, both, or neither, and the two are independent —
258
+ * pushing to a dataLayer no container reads is inert, and so is a gtag call
259
+ * with no GA4 tag behind it.
260
+ *
261
+ * ⚠ NEVER ALSO SEND THIS CONVERSION FROM A TAG MANAGER TRIGGER. Two pipes
262
+ * for one event double-counts, and a doubled conversion looks like a good
263
+ * month rather than a bug. Nothing in a build will catch it — confirm in
264
+ * Realtime that one submission produces one event.
265
+ */
266
+ function announceLead() {
267
+ const w = window as any;
268
+ if (typeof w.gtag === 'function') {
269
+ w.gtag('event', 'generate_lead', { form: 'contact' });
270
+ }
271
+ w.dataLayer = w.dataLayer || [];
272
+ w.dataLayer.push({ event: 'lead_submitted', form: 'contact' });
273
+ }
274
+
275
+ /**
276
+ * The no-JavaScript conversion.
277
+ *
278
+ * Without JavaScript the form posts natively, the API 303s to `?sent=1`, and
279
+ * THIS page load is the only signal a lead happened. With JavaScript the
280
+ * fetch path calls the announcer and never navigates, so the two paths are
281
+ * mutually exclusive and cannot both fire.
282
+ *
283
+ * ⚠ THE OBVIOUS IMPLEMENTATION IS WRONG. A Tag Manager trigger on "URL
284
+ * contains sent=1" catches almost nothing here, because the enhanced path
285
+ * intercepts the submit and never changes the URL. It fires correctly in
286
+ * testing — where you click through by hand — and then reports a handful of
287
+ * conversions a month while looking like it works.
288
+ *
289
+ * The parameter is stripped with replaceState immediately after, so a
290
+ * reload, a bookmark or a shared link cannot invent a second conversion.
291
+ */
292
+ function announceNoScriptLead() {
293
+ const url = new URL(window.location.href);
294
+ if (!url.searchParams.has('sent')) return;
295
+
296
+ announceLead();
297
+
298
+ url.searchParams.delete('sent');
299
+ history.replaceState(null, '', url.pathname + url.search + url.hash);
300
+ }
301
+
302
+ function setupContactForm() {
303
+ const form = document.querySelector<HTMLFormElement>('[data-contact-form]');
304
+ if (!form) return;
305
+
306
+ const result = form.querySelector<HTMLElement>('[data-form-result]')!;
307
+ const submit = form.querySelector<HTMLButtonElement>('.form__submit')!;
308
+ const label = form.querySelector<HTMLElement>('[data-submit-label]')!;
309
+ const pageField = form.querySelector<HTMLInputElement>('[data-page-field]');
310
+ if (pageField) pageField.value = location.pathname + location.search;
311
+
312
+ // From the markup, which reads business.ts. Never a literal here.
313
+ const phone = form.dataset.phone ?? '';
314
+ const callUs = phone ? ` Please call us at ${phone}.` : ' Please call us instead.';
315
+
316
+ const clearErrors = () => {
317
+ form.querySelectorAll<HTMLElement>('[data-error-for]').forEach((node) => {
318
+ node.hidden = true;
319
+ node.textContent = '';
320
+ node.closest('.field')?.removeAttribute('data-invalid');
321
+ });
322
+ };
323
+
324
+ const showErrors = (errors: Record<string, string>) => {
325
+ let first: HTMLElement | null = null;
326
+ for (const [field, message] of Object.entries(errors)) {
327
+ const node = form.querySelector<HTMLElement>(`[data-error-for="${field}"]`);
328
+ if (!node) continue;
329
+ node.textContent = message;
330
+ node.hidden = false;
331
+ const wrapper = node.closest('.field');
332
+ wrapper?.setAttribute('data-invalid', '');
333
+ const input = wrapper?.querySelector<HTMLElement>('input, textarea, select');
334
+ if (input) {
335
+ input.setAttribute('aria-invalid', 'true');
336
+ if (!first) first = input;
337
+ }
338
+ }
339
+ first?.focus();
340
+ };
341
+
342
+ const say = (message: string, tone: 'success' | 'error') => {
343
+ result.textContent = message;
344
+ result.dataset.tone = tone;
345
+ result.hidden = false;
346
+ };
347
+
348
+ form.addEventListener('submit', async (event) => {
349
+ event.preventDefault();
350
+ clearErrors();
351
+ result.hidden = true;
352
+
353
+ submit.dataset.busy = '';
354
+ const original = label.textContent;
355
+ label.textContent = 'Sending…';
356
+
357
+ try {
358
+ const response = await fetch(form.action, {
359
+ method: 'POST',
360
+ headers: { accept: 'application/json' },
361
+ body: new FormData(form),
362
+ });
363
+ const data = (await response.json().catch(() => ({}))) as {
364
+ ok?: boolean;
365
+ errors?: Record<string, string>;
366
+ error?: string;
367
+ };
368
+
369
+ if (response.status === 422 && data.errors) {
370
+ showErrors(data.errors);
371
+ say('Please check the highlighted fields.', 'error');
372
+ return;
373
+ }
374
+
375
+ if (!response.ok || !data.ok) {
376
+ say(data.error ?? `Something went wrong.${callUs}`, 'error');
377
+ return;
378
+ }
379
+
380
+ form.reset();
381
+ say('Thank you — your message is in. We will come back to you shortly.', 'success');
382
+ announceLead();
383
+ } catch {
384
+ say(`We could not reach the server.${callUs}`, 'error');
385
+ } finally {
386
+ delete submit.dataset.busy;
387
+ label.textContent = original;
388
+ }
389
+ });
390
+
391
+ // Clear a field's error state as soon as the visitor starts fixing it.
392
+ form.addEventListener('input', (event) => {
393
+ const field = (event.target as HTMLElement).closest('.field');
394
+ if (!field?.hasAttribute('data-invalid')) return;
395
+ field.removeAttribute('data-invalid');
396
+ field.querySelector<HTMLElement>('[data-error-for]')!.hidden = true;
397
+ (event.target as HTMLElement).removeAttribute('aria-invalid');
398
+ });
399
+ }
400
+
401
+ document.addEventListener('astro:page-load', () => {
402
+ setupContactForm();
403
+ announceNoScriptLead();
404
+ });
405
+ </script>
@@ -0,0 +1,82 @@
1
+ ---
2
+ import { business } from '../data/business';
3
+ import { primaryAction } from '../data/nav';
4
+
5
+ /**
6
+ * The conversion block, repeated at the bottom of a page.
7
+ *
8
+ * Structure only — a heading, a line of copy, and the one action that counts
9
+ * as a win plus the phone. Where a page's conversion sits, and what it looks
10
+ * like, is the archetype's decision and the design direction's respectively;
11
+ * neither belongs in the template.
12
+ *
13
+ * Pass a real title and body per page. A generic call to action is the
14
+ * cheapest thing on the page to get specific.
15
+ */
16
+
17
+ interface Props {
18
+ title?: string;
19
+ body?: string;
20
+ /** Extra classes for the section — the project's own treatment. */
21
+ class?: string;
22
+ }
23
+
24
+ const {
25
+ title = `Talk to ${business.name}`,
26
+ body = business.tagline,
27
+ class: className = '',
28
+ } = Astro.props;
29
+ ---
30
+
31
+ <section class:list={['section', className]} aria-labelledby="cta-title">
32
+ <div class="container cta__inner">
33
+ <div>
34
+ <h2 id="cta-title">{title}</h2>
35
+ <p class="cta__body">{body}</p>
36
+ </div>
37
+ <div class="cta__actions">
38
+ <a class="btn" href={business.phone.href}>Call {business.phone.display}</a>
39
+ <a class="btn btn--ghost" href={primaryAction.href}>{primaryAction.label}</a>
40
+ <p class="cta__note">{business.hours.display}</p>
41
+ </div>
42
+ </div>
43
+ </section>
44
+
45
+ <style>
46
+ .cta__inner {
47
+ display: grid;
48
+ gap: var(--space-l);
49
+ align-items: start;
50
+ }
51
+
52
+ @media (min-width: 55rem) {
53
+ .cta__inner {
54
+ grid-template-columns: 1fr auto;
55
+ gap: var(--space-2xl);
56
+ }
57
+ }
58
+
59
+ .cta__body {
60
+ margin-block-start: var(--space-s);
61
+ color: var(--text-muted);
62
+ max-inline-size: var(--measure);
63
+ }
64
+
65
+ .cta__actions {
66
+ display: grid;
67
+ gap: var(--space-2xs);
68
+ justify-items: stretch;
69
+ }
70
+
71
+ @media (min-width: 30rem) {
72
+ .cta__actions {
73
+ justify-items: start;
74
+ }
75
+ }
76
+
77
+ .cta__note {
78
+ margin-block-start: var(--space-2xs);
79
+ font-size: var(--step--1);
80
+ color: var(--text-muted);
81
+ }
82
+ </style>
@@ -0,0 +1,146 @@
1
+ ---
2
+ /**
3
+ * A standing marker that this is not production.
4
+ *
5
+ * Renders on every non-production build and on none of production, from
6
+ * `site.indexable` — the one environment switch. There is no second flag and no
7
+ * way to leave it on by accident, because nothing turns it on by hand.
8
+ *
9
+ * ── IT VERIFIES, IT DOES NOT ASSERT ────────────────────────────────────────
10
+ * A badge that printed "STAGING · NOINDEX" from a build variable would tell you
11
+ * what the build *intended*. That is the thing already known. What is worth
12
+ * showing is what a crawler would actually find, so the script below reads the
13
+ * live DOM — the real `<meta name="robots">`, the real analytics tags — and
14
+ * says **NOT NOINDEX** in an alarm state when the two disagree.
15
+ *
16
+ * That is the case this exists for: a staging site that is quietly indexable
17
+ * competes with production for the client's own terms, and nothing on the page
18
+ * looks wrong.
19
+ *
20
+ * ── WHY IT CANNOT BE CLICKED ───────────────────────────────────────────────
21
+ * `pointer-events: none`. A fixed overlay that intercepts a click is a bug you
22
+ * find while demoing to a client. Hide it for a screenshot with `?nobadge=1`,
23
+ * which lasts the session — never a dismissal that persists, because a badge
24
+ * you dismissed on Tuesday is not there to warn you on Friday.
25
+ */
26
+ import { site } from '../data/site';
27
+ ---
28
+
29
+ {
30
+ !site.indexable && (
31
+ <div id="env-badge" data-env={site.env} hidden>
32
+ <span data-env-label>{site.env}</span>
33
+ <span data-env-state />
34
+ </div>
35
+ )
36
+ }
37
+
38
+ {
39
+ !site.indexable && (
40
+ <script is:inline>
41
+ (function () {
42
+ var el = document.getElementById('env-badge');
43
+ if (!el) return;
44
+
45
+ /* ?nobadge=1 for screenshots. sessionStorage, so it comes back — a
46
+ dismissal that outlives the session is a warning that is not there
47
+ the day it matters. */
48
+ try {
49
+ if (new URLSearchParams(location.search).has('nobadge')) {
50
+ sessionStorage.setItem('nobadge', '1');
51
+ }
52
+ if (sessionStorage.getItem('nobadge')) return;
53
+ } catch (e) {
54
+ /* private mode: show the badge rather than fail closed */
55
+ }
56
+
57
+ var robots = document.querySelector('meta[name="robots"]');
58
+ var noindex = !!robots && /noindex/i.test(robots.getAttribute('content') || '');
59
+
60
+ /* The needle is assembled rather than written out. A detector that
61
+ contains the literal it looks for shows up in every grep-based scan
62
+ for that literal — including this kit's own staging check, which
63
+ asserts a staging page carries ZERO analytics references. Spelling it
64
+ out here would have made the badge the thing that failed it. */
65
+ var tagHost = 'google' + 'tagmanager';
66
+ var analytics = !!(
67
+ document.querySelector('script[src*="' + tagHost + '"]') ||
68
+ window.dataLayer ||
69
+ window.gtag
70
+ );
71
+
72
+ var state = el.querySelector('[data-env-state]');
73
+ var problems = [];
74
+ if (!noindex) problems.push('NOT NOINDEX');
75
+ if (analytics) problems.push('ANALYTICS LIVE');
76
+
77
+ if (problems.length) {
78
+ el.setAttribute('data-alarm', '');
79
+ state.textContent = ' · ' + problems.join(' · ');
80
+ } else {
81
+ state.textContent = ' · noindex';
82
+ }
83
+
84
+ el.hidden = false;
85
+ })();
86
+ </script>
87
+ )
88
+ }
89
+
90
+ <style>
91
+ /*
92
+ * Structure and state, not a look — the same rule as everything else in the
93
+ * shared layer. It uses `--danger`, which is a semantic token every project
94
+ * defines, never a brand colour, so it stays legible before the ramp is set
95
+ * and does not become a design decision the template made for you.
96
+ */
97
+ #env-badge {
98
+ position: fixed;
99
+ inset-block-end: 1rem;
100
+ inset-inline-start: 1rem;
101
+ z-index: 2147483647;
102
+
103
+ /* Never intercepts a click, a tap, a hover or a drag. */
104
+ pointer-events: none;
105
+ user-select: none;
106
+
107
+ padding: 0.4rem 0.9rem;
108
+ border-radius: 999px;
109
+ font: 600 var(--step--2, 0.72rem) / 1 var(--font-body);
110
+ letter-spacing: 0.08em;
111
+ text-transform: uppercase;
112
+ color: var(--white);
113
+ background: var(--danger);
114
+ box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.3));
115
+ }
116
+
117
+ /* Something disagrees with the environment — make it impossible to miss. */
118
+ /* Darker than --danger, derived from it rather than picked — no second
119
+ colour for a project to keep in step, and no hex in a component. */
120
+ #env-badge[data-alarm] {
121
+ background: color-mix(in srgb, var(--danger) 78%, black);
122
+ animation: env-badge-pulse 1.6s ease-in-out infinite;
123
+ }
124
+
125
+ @keyframes env-badge-pulse {
126
+ 50% {
127
+ opacity: 0.55;
128
+ }
129
+ }
130
+
131
+ /* The alarm still reads without motion; only the pulse goes. */
132
+ @media (prefers-reduced-motion: reduce) {
133
+ #env-badge[data-alarm] {
134
+ animation: none;
135
+ outline: 2px solid var(--white);
136
+ outline-offset: 2px;
137
+ }
138
+ }
139
+
140
+ /* A PDF of a staging page should not carry it. */
141
+ @media print {
142
+ #env-badge {
143
+ display: none;
144
+ }
145
+ }
146
+ </style>