@hubpav/hwio-web-runtime 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.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @hubpav/hwio-web-runtime
2
+
3
+ Framework-free browser behaviours for the HARDWARIO websites, one ES module per concern
4
+ (`/consent`, `/forms`, `/turnstile`, `/attribution`, `/theme`, `/track`, `/reveal`,
5
+ `/counter`, `/nav`, `/accordion`) plus `/inline` (static bootstrap scripts with their
6
+ SHA-256 hashes for CSP). Every behaviour registers through `hwioMount()`, so it binds on
7
+ `astro:page-load` and cleans up on `astro:before-swap` when Astro's ClientRouter is present
8
+ and binds once otherwise. Pure logic lives in `*-core` modules and is unit-tested with
9
+ `node --test`.
@@ -0,0 +1,2 @@
1
+ /** Exclusive accordion fallback for browsers without <details name="…"> support. */
2
+ export declare function hwioAccordionRuntime(): import("./mount.js").HWioBinder;
@@ -0,0 +1,21 @@
1
+ import { hwioMount } from './mount.js';
2
+ /** Exclusive accordion fallback for browsers without <details name="…"> support. */
3
+ export function hwioAccordionRuntime() {
4
+ let controller = null;
5
+ function bind() {
6
+ if ('name' in document.createElement('details'))
7
+ return; // native exclusive accordion
8
+ controller ??= new AbortController();
9
+ document.querySelectorAll('details[name]:not([data-hwio-accordion-bound])').forEach((details) => {
10
+ details.dataset.hwioAccordionBound = 'true';
11
+ details.addEventListener('toggle', () => {
12
+ if (!details.open)
13
+ return;
14
+ const name = details.getAttribute('name');
15
+ document.querySelectorAll(`details[name="${name}"]`).forEach((other) => { if (other !== details)
16
+ other.open = false; });
17
+ }, { signal: controller.signal });
18
+ });
19
+ }
20
+ return hwioMount('accordion', { bind, cleanup: () => { controller?.abort(); controller = null; } });
21
+ }
@@ -0,0 +1,13 @@
1
+ export declare const HWIO_ATTRIBUTION_KEY = "hwio_attribution";
2
+ /** Click identifiers + UTM params; lowercase keys map onto HubSpot contact properties of the same name. */
3
+ export declare const HWIO_ATTRIBUTION_PARAMS: readonly ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "gclid", "gbraid", "wbraid", "fbclid", "msclkid", "li_fat_id"];
4
+ export type HWioAttribution = Record<string, string>;
5
+ /** Pure: click identifiers present in a query string (read at submit time, never stored). */
6
+ export declare function hwioAttributionFromSearch(search: string): HWioAttribution;
7
+ /**
8
+ * First-touch capture into sessionStorage (marketing storage: call only with marketing consent).
9
+ * Keeps the first value seen in the session so later navigations cannot clobber the campaign.
10
+ */
11
+ export declare function hwioCaptureAttribution(): void;
12
+ export declare function hwioGetAttribution(): HWioAttribution;
13
+ export declare function hwioGetPageAttribution(): HWioAttribution;
@@ -0,0 +1,65 @@
1
+ export const HWIO_ATTRIBUTION_KEY = 'hwio_attribution';
2
+ const LEGACY_ATTRIBUTION_KEY = 'hw_attribution'; // legacy-ok
3
+ const MAX_LEN = 512;
4
+ /** Click identifiers + UTM params; lowercase keys map onto HubSpot contact properties of the same name. */
5
+ export const HWIO_ATTRIBUTION_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'gbraid', 'wbraid', 'fbclid', 'msclkid', 'li_fat_id'];
6
+ /** Pure: click identifiers present in a query string (read at submit time, never stored). */
7
+ export function hwioAttributionFromSearch(search) {
8
+ const out = {};
9
+ const params = new URLSearchParams(search);
10
+ for (const key of HWIO_ATTRIBUTION_PARAMS) {
11
+ const value = params.get(key);
12
+ if (value)
13
+ out[key] = value.slice(0, MAX_LEN);
14
+ }
15
+ return out;
16
+ }
17
+ function read() {
18
+ try {
19
+ const raw = sessionStorage.getItem(HWIO_ATTRIBUTION_KEY) ?? sessionStorage.getItem(LEGACY_ATTRIBUTION_KEY);
20
+ return raw ? JSON.parse(raw) : {};
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ /**
27
+ * First-touch capture into sessionStorage (marketing storage: call only with marketing consent).
28
+ * Keeps the first value seen in the session so later navigations cannot clobber the campaign.
29
+ */
30
+ export function hwioCaptureAttribution() {
31
+ const stored = read();
32
+ let changed = false;
33
+ for (const [key, value] of Object.entries(hwioAttributionFromSearch(window.location.search))) {
34
+ if (!stored[key]) {
35
+ stored[key] = value;
36
+ changed = true;
37
+ }
38
+ }
39
+ if (!stored.referrer && document.referrer) {
40
+ try {
41
+ if (new URL(document.referrer).host !== window.location.host) {
42
+ stored.referrer = document.referrer.slice(0, MAX_LEN);
43
+ changed = true;
44
+ }
45
+ }
46
+ catch {
47
+ // malformed referrer
48
+ }
49
+ }
50
+ if (changed) {
51
+ try {
52
+ sessionStorage.setItem(HWIO_ATTRIBUTION_KEY, JSON.stringify(stored));
53
+ sessionStorage.removeItem(LEGACY_ATTRIBUTION_KEY);
54
+ }
55
+ catch {
56
+ // best effort
57
+ }
58
+ }
59
+ }
60
+ export function hwioGetAttribution() {
61
+ return read();
62
+ }
63
+ export function hwioGetPageAttribution() {
64
+ return hwioAttributionFromSearch(window.location.search);
65
+ }
@@ -0,0 +1,26 @@
1
+ export type HWioConsentCategory = 'necessary' | 'preferences' | 'statistics' | 'marketing';
2
+ export type HWioConsentCategories = Record<HWioConsentCategory, boolean>;
3
+ export interface HWioConsentReceipt {
4
+ version: string;
5
+ method: string;
6
+ locale: string;
7
+ timestamp: string;
8
+ categories: HWioConsentCategories;
9
+ }
10
+ export declare const HWIO_CONSENT_COOKIE = "hwio_cookie_consent";
11
+ /** Pre-design-system cookie written by www and the Zola sites; migrated in place, never re-prompted. */
12
+ export declare const LEGACY_CONSENT_COOKIE = "hw_cookie_consent";
13
+ export declare const HWIO_CONSENT_MAX_AGE: number;
14
+ export declare function hwioBaseCategories(overrides?: Partial<Record<string, boolean>> | null): HWioConsentCategories;
15
+ /** Parses a raw cookie value; returns null unless it is a receipt of the expected version. */
16
+ export declare function hwioParseReceipt(raw: string | null | undefined, version: string): HWioConsentReceipt | null;
17
+ export declare function hwioMakeReceipt(categories: Partial<Record<string, boolean>>, method: string, version: string, locale: string, now?: Date): HWioConsentReceipt;
18
+ /** Serialises a receipt into the Set-Cookie string (document.cookie assignment form). */
19
+ export declare function hwioCookieString(name: string, receipt: HWioConsentReceipt, secure: boolean): string;
20
+ export declare function hwioExpireCookieString(name: string, domain?: string): string;
21
+ export declare function hwioReadCookie(cookieHeader: string, name: string): string | null;
22
+ export type HWioGoogleConsent = Record<'ad_storage' | 'ad_user_data' | 'ad_personalization' | 'analytics_storage' | 'functionality_storage' | 'personalization_storage' | 'security_storage', 'granted' | 'denied'>;
23
+ export declare function hwioConsentToGoogle(receipt: HWioConsentReceipt | null): HWioGoogleConsent;
24
+ export declare function hwioHasTrackingConsent(receipt: HWioConsentReceipt | null): boolean;
25
+ /** Cookie names that a withdrawn category must delete (GA4, Google Ads, Meta, HubSpot). */
26
+ export declare function hwioDisallowedCookieNames(names: string[], categories: HWioConsentCategories): string[];
@@ -0,0 +1,85 @@
1
+ export const HWIO_CONSENT_COOKIE = 'hwio_cookie_consent';
2
+ /** Pre-design-system cookie written by www and the Zola sites; migrated in place, never re-prompted. */
3
+ export const LEGACY_CONSENT_COOKIE = 'hw_cookie_consent'; // legacy-ok
4
+ export const HWIO_CONSENT_MAX_AGE = 60 * 60 * 24 * 180;
5
+ export function hwioBaseCategories(overrides) {
6
+ return {
7
+ necessary: true,
8
+ preferences: !!overrides?.preferences,
9
+ statistics: !!overrides?.statistics,
10
+ marketing: !!overrides?.marketing,
11
+ };
12
+ }
13
+ function safeDecode(input) {
14
+ try {
15
+ return decodeURIComponent(input);
16
+ }
17
+ catch {
18
+ return input;
19
+ }
20
+ }
21
+ /** Parses a raw cookie value; returns null unless it is a receipt of the expected version. */
22
+ export function hwioParseReceipt(raw, version) {
23
+ if (!raw)
24
+ return null;
25
+ const attempts = [raw];
26
+ const decoded = safeDecode(raw);
27
+ if (decoded !== raw)
28
+ attempts.push(decoded);
29
+ for (const attempt of attempts) {
30
+ try {
31
+ const parsed = JSON.parse(attempt);
32
+ if (parsed && parsed.version === version) {
33
+ return {
34
+ version,
35
+ method: parsed.method ?? 'unknown',
36
+ locale: parsed.locale ?? 'en',
37
+ timestamp: parsed.timestamp ?? new Date(0).toISOString(),
38
+ categories: hwioBaseCategories(parsed.categories),
39
+ };
40
+ }
41
+ }
42
+ catch {
43
+ // not JSON, try the next form
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+ export function hwioMakeReceipt(categories, method, version, locale, now = new Date()) {
49
+ return { version, method, locale, timestamp: now.toISOString(), categories: hwioBaseCategories(categories) };
50
+ }
51
+ /** Serialises a receipt into the Set-Cookie string (document.cookie assignment form). */
52
+ export function hwioCookieString(name, receipt, secure) {
53
+ return `${name}=${encodeURIComponent(JSON.stringify(receipt))}; Max-Age=${HWIO_CONSENT_MAX_AGE}; Path=/; SameSite=Lax${secure ? '; Secure' : ''}`;
54
+ }
55
+ export function hwioExpireCookieString(name, domain) {
56
+ return `${name}=; Max-Age=0; Path=/; SameSite=Lax${domain ? `; Domain=${domain}` : ''}`;
57
+ }
58
+ export function hwioReadCookie(cookieHeader, name) {
59
+ const match = cookieHeader.match(new RegExp('(?:^|;\\s*)' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'));
60
+ return match ? match[1] : null;
61
+ }
62
+ export function hwioConsentToGoogle(receipt) {
63
+ const c = hwioBaseCategories(receipt?.categories);
64
+ const g = (v) => (v ? 'granted' : 'denied');
65
+ return {
66
+ ad_storage: g(c.marketing),
67
+ ad_user_data: g(c.marketing),
68
+ ad_personalization: g(c.marketing),
69
+ analytics_storage: g(c.statistics),
70
+ functionality_storage: g(c.preferences),
71
+ personalization_storage: g(c.preferences),
72
+ security_storage: 'granted',
73
+ };
74
+ }
75
+ export function hwioHasTrackingConsent(receipt) {
76
+ return !!receipt && (receipt.categories.statistics || receipt.categories.marketing);
77
+ }
78
+ /** Cookie names that a withdrawn category must delete (GA4, Google Ads, Meta, HubSpot). */
79
+ export function hwioDisallowedCookieNames(names, categories) {
80
+ return names.filter((name) => {
81
+ const isStatistics = name === '_ga' || name.startsWith('_ga_') || name === '_gid' || name.startsWith('_gat');
82
+ const isMarketing = name.startsWith('_gcl_') || name === '_fbp' || name === '_fbc' || name === 'hubspotutk' || name === '__hstc' || name === '__hssc' || name === '__hssrc';
83
+ return (!categories.statistics && isStatistics) || (!categories.marketing && isMarketing);
84
+ });
85
+ }
@@ -0,0 +1,28 @@
1
+ import { type HWioConsentCategory, type HWioConsentReceipt } from './consent-core.js';
2
+ export interface HWioConsentApi {
3
+ getConsent: () => HWioConsentReceipt | null;
4
+ allows: (category: HWioConsentCategory | string) => boolean;
5
+ canTrack: () => boolean;
6
+ open: () => void;
7
+ acceptAll: () => void;
8
+ rejectAll: () => void;
9
+ }
10
+ declare global {
11
+ interface Window {
12
+ hwioConsent?: HWioConsentApi;
13
+ /** Deprecated alias kept while www's local call sites migrate (P2). */
14
+ hwConsent?: HWioConsentApi;
15
+ dataLayer?: unknown[];
16
+ gtag?: (...args: unknown[]) => void;
17
+ __hwioGtmLoaded?: boolean;
18
+ }
19
+ }
20
+ /**
21
+ * Consent runtime. Markup contract (rendered by HWioCookieConsent):
22
+ * [data-hwio-consent data-version data-gtm? data-locale] banner root (hidden attr toggles it)
23
+ * [data-hwio-consent-modal] settings dialog wrapper (hidden)
24
+ * [data-hwio-consent-accept|reject|customize|save|close] buttons (may appear several times)
25
+ * [data-hwio-consent-toggle="preferences|statistics|marketing"] checkboxes
26
+ * [data-hwio-cookie-settings] anywhere (footer) reopens the dialog
27
+ */
28
+ export declare function hwioConsentRuntime(): HWioConsentApi;
@@ -0,0 +1,207 @@
1
+ import { hwioMount } from './mount.js';
2
+ import { HWIO_CONSENT_COOKIE, LEGACY_CONSENT_COOKIE, hwioBaseCategories, hwioConsentToGoogle, hwioCookieString, hwioDisallowedCookieNames, hwioExpireCookieString, hwioHasTrackingConsent, hwioMakeReceipt, hwioParseReceipt, hwioReadCookie, } from './consent-core.js';
3
+ const ATTRIBUTION_KEYS = ['hwio_attribution', 'hw_attribution']; // legacy-ok
4
+ const THEME_KEYS = ['hwio_theme', 'hwio_theme_consent_version', 'theme', 'hw_theme_consent_version']; // legacy-ok
5
+ /**
6
+ * Consent runtime. Markup contract (rendered by HWioCookieConsent):
7
+ * [data-hwio-consent data-version data-gtm? data-locale] banner root (hidden attr toggles it)
8
+ * [data-hwio-consent-modal] settings dialog wrapper (hidden)
9
+ * [data-hwio-consent-accept|reject|customize|save|close] buttons (may appear several times)
10
+ * [data-hwio-consent-toggle="preferences|statistics|marketing"] checkboxes
11
+ * [data-hwio-cookie-settings] anywhere (footer) reopens the dialog
12
+ */
13
+ export function hwioConsentRuntime() {
14
+ let version = '';
15
+ let gtmId = null;
16
+ let locale = 'en';
17
+ let current = null;
18
+ let resolved = false;
19
+ let root = null;
20
+ let modal = null;
21
+ let toggles = [];
22
+ let controller = null;
23
+ const secure = () => window.location.protocol === 'https:';
24
+ function readStored() {
25
+ const fresh = hwioParseReceipt(hwioReadCookie(document.cookie, HWIO_CONSENT_COOKIE), version);
26
+ if (fresh)
27
+ return fresh;
28
+ const legacy = hwioParseReceipt(hwioReadCookie(document.cookie, LEGACY_CONSENT_COOKIE), version);
29
+ if (legacy) {
30
+ // Migrate in place so returning visitors are not asked again.
31
+ document.cookie = hwioCookieString(HWIO_CONSENT_COOKIE, legacy, secure());
32
+ document.cookie = hwioExpireCookieString(LEGACY_CONSENT_COOKIE);
33
+ return legacy;
34
+ }
35
+ return null;
36
+ }
37
+ function write(categories, method) {
38
+ const receipt = hwioMakeReceipt(categories, method, version, locale);
39
+ document.cookie = hwioCookieString(HWIO_CONSENT_COOKIE, receipt, secure());
40
+ return receipt;
41
+ }
42
+ function updateGoogle(receipt) {
43
+ window.dataLayer = window.dataLayer || [];
44
+ window.gtag =
45
+ window.gtag ||
46
+ function (...args) {
47
+ window.dataLayer.push(args);
48
+ };
49
+ window.gtag('consent', 'update', hwioConsentToGoogle(receipt));
50
+ }
51
+ function loadGtm(receipt) {
52
+ if (!gtmId || window.__hwioGtmLoaded || !hwioHasTrackingConsent(receipt))
53
+ return;
54
+ if (document.querySelector('script[src*="googletagmanager.com/gtm.js"]')) {
55
+ window.__hwioGtmLoaded = true;
56
+ return;
57
+ }
58
+ window.__hwioGtmLoaded = true;
59
+ window.dataLayer = window.dataLayer || [];
60
+ window.dataLayer.push({ 'gtm.start': Date.now(), event: 'gtm.js' });
61
+ const script = document.createElement('script');
62
+ script.async = true;
63
+ script.src = `https://www.googletagmanager.com/gtm.js?id=${gtmId}`;
64
+ script.addEventListener('error', () => { window.__hwioGtmLoaded = false; }, { once: true });
65
+ document.head.appendChild(script);
66
+ }
67
+ function clearDisallowed(receipt) {
68
+ const categories = hwioBaseCategories(receipt?.categories);
69
+ const host = window.location.hostname;
70
+ const parts = host.split('.');
71
+ const domains = Array.from(new Set(['', host, ...(parts.length >= 2 ? ['.' + host] : []), ...(parts.length > 2 ? ['.' + parts.slice(-2).join('.')] : [])]));
72
+ const names = document.cookie.split(';').map((e) => e.split('=')[0].trim()).filter(Boolean);
73
+ for (const name of hwioDisallowedCookieNames(names, categories)) {
74
+ for (const domain of domains)
75
+ document.cookie = hwioExpireCookieString(name, domain || undefined);
76
+ }
77
+ try {
78
+ if (!categories.marketing)
79
+ ATTRIBUTION_KEYS.forEach((k) => sessionStorage.removeItem(k));
80
+ if (!categories.preferences)
81
+ THEME_KEYS.forEach((k) => localStorage.removeItem(k));
82
+ else {
83
+ if (localStorage.getItem('hwio_theme'))
84
+ localStorage.setItem('hwio_theme_consent_version', version);
85
+ if (localStorage.getItem('theme'))
86
+ localStorage.setItem('hw_theme_consent_version', version); // legacy-ok
87
+ }
88
+ }
89
+ catch {
90
+ // storage unavailable
91
+ }
92
+ }
93
+ function setToggles(receipt) {
94
+ const categories = hwioBaseCategories(receipt?.categories);
95
+ toggles.forEach((input) => {
96
+ const key = input.dataset.hwioConsentToggle;
97
+ input.checked = !!key && categories[key];
98
+ });
99
+ }
100
+ function setModal(open) {
101
+ if (!modal)
102
+ return;
103
+ modal.hidden = !open;
104
+ if (open) {
105
+ const first = modal.querySelector('input:not([disabled]), button');
106
+ first?.focus();
107
+ }
108
+ }
109
+ function render() {
110
+ if (!resolved || !root)
111
+ return;
112
+ root.hidden = !!current;
113
+ if (current)
114
+ setToggles(current);
115
+ }
116
+ function dispatch() {
117
+ window.dispatchEvent(new CustomEvent('hwio:consentchange', { detail: current }));
118
+ window.dispatchEvent(new CustomEvent('hw:consentchange', { detail: current })); // legacy-ok
119
+ }
120
+ function apply(categories, method) {
121
+ current = write(categories, method);
122
+ resolved = true;
123
+ updateGoogle(current);
124
+ clearDisallowed(current);
125
+ loadGtm(current);
126
+ render();
127
+ setModal(false);
128
+ dispatch();
129
+ }
130
+ const api = {
131
+ getConsent: () => current,
132
+ allows: (category) => category === 'necessary' || !!current?.categories[category],
133
+ canTrack: () => hwioHasTrackingConsent(current),
134
+ open: () => { setToggles(current); setModal(true); },
135
+ acceptAll: () => apply({ preferences: true, statistics: true, marketing: true }, 'accept_all'),
136
+ rejectAll: () => apply({}, 'reject_all'),
137
+ };
138
+ function cleanup() {
139
+ controller?.abort();
140
+ controller = null;
141
+ setModal(false);
142
+ root = null;
143
+ modal = null;
144
+ toggles = [];
145
+ }
146
+ function bind() {
147
+ const nextRoot = document.querySelector('[data-hwio-consent]');
148
+ if (!nextRoot)
149
+ return;
150
+ if (root === nextRoot && controller && !controller.signal.aborted)
151
+ return;
152
+ cleanup();
153
+ root = nextRoot;
154
+ version = root.dataset.version ?? document.documentElement.dataset.hwioConsentVersion ?? '';
155
+ gtmId = root.dataset.gtm || null;
156
+ locale = root.dataset.locale ?? document.documentElement.lang ?? 'en';
157
+ modal = document.querySelector('[data-hwio-consent-modal]');
158
+ toggles = Array.from(document.querySelectorAll('[data-hwio-consent-toggle]'));
159
+ controller = new AbortController();
160
+ const { signal } = controller;
161
+ const on = (selector, handler) => document.querySelectorAll(selector).forEach((el) => el.addEventListener('click', handler, { signal }));
162
+ on('[data-hwio-consent-accept]', api.acceptAll);
163
+ on('[data-hwio-consent-reject]', api.rejectAll);
164
+ on('[data-hwio-consent-customize]', api.open);
165
+ on('[data-hwio-consent-close]', () => setModal(false));
166
+ on('[data-hwio-consent-save]', () => {
167
+ const categories = {};
168
+ toggles.forEach((input) => { categories[input.dataset.hwioConsentToggle ?? ''] = input.checked; });
169
+ apply(categories, 'granular');
170
+ });
171
+ on('[data-hwio-cookie-settings], [data-cookie-settings]', api.open); // legacy-ok: footer buttons of the current sites
172
+ modal?.addEventListener('click', (event) => { if (event.target === modal)
173
+ setModal(false); }, { signal });
174
+ document.addEventListener('keydown', (event) => {
175
+ if (event.key === 'Escape')
176
+ setModal(false);
177
+ if (event.key !== 'Tab' || !modal || modal.hidden)
178
+ return;
179
+ const focusables = Array.from(modal.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((el) => !el.hidden && el.offsetParent !== null);
180
+ if (!focusables.length)
181
+ return;
182
+ const first = focusables[0], last = focusables[focusables.length - 1], active = document.activeElement;
183
+ if (event.shiftKey && (active === first || !modal.contains(active))) {
184
+ event.preventDefault();
185
+ last.focus();
186
+ }
187
+ else if (!event.shiftKey && active === last) {
188
+ event.preventDefault();
189
+ first.focus();
190
+ }
191
+ }, { signal });
192
+ if (!resolved) {
193
+ current = readStored();
194
+ resolved = true;
195
+ if (current) {
196
+ updateGoogle(current);
197
+ clearDisallowed(current);
198
+ loadGtm(current);
199
+ }
200
+ }
201
+ render();
202
+ }
203
+ window.hwioConsent = api;
204
+ window.hwConsent = api; // legacy-ok
205
+ hwioMount('consent', { bind, cleanup });
206
+ return api;
207
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Count-up for [data-hwio-counter data-target="120" data-suffix="+"]. Renders the final value
3
+ * without animation under reduced motion; otherwise eases from 0 when the first counter enters view.
4
+ */
5
+ export declare function hwioCounterRuntime(): import("./mount.js").HWioBinder;
@@ -0,0 +1,52 @@
1
+ import { hwioMount } from './mount.js';
2
+ /**
3
+ * Count-up for [data-hwio-counter data-target="120" data-suffix="+"]. Renders the final value
4
+ * without animation under reduced motion; otherwise eases from 0 when the first counter enters view.
5
+ */
6
+ export function hwioCounterRuntime() {
7
+ let observer = null;
8
+ let frame = 0;
9
+ function cleanup() {
10
+ observer?.disconnect();
11
+ observer = null;
12
+ if (frame)
13
+ cancelAnimationFrame(frame);
14
+ frame = 0;
15
+ }
16
+ function bind() {
17
+ const counters = Array.from(document.querySelectorAll('[data-hwio-counter]:not([data-hwio-counter-bound])'));
18
+ if (!counters.length)
19
+ return;
20
+ counters.forEach((c) => (c.dataset.hwioCounterBound = 'true'));
21
+ const finish = () => counters.forEach((c) => { c.textContent = `${parseInt(c.dataset.target ?? '0', 10)}${c.dataset.suffix ?? ''}`; c.dataset.hwioCounterActive = 'true'; });
22
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches)
23
+ return finish();
24
+ const animate = () => {
25
+ const start = performance.now();
26
+ counters.forEach((c) => { c.textContent = `0${c.dataset.suffix ?? ''}`; c.dataset.hwioCounterActive = 'true'; });
27
+ const step = (now) => {
28
+ const progress = Math.min((now - start) / 1200, 1);
29
+ const eased = 1 - Math.pow(1 - progress, 3);
30
+ counters.forEach((c) => { c.textContent = `${Math.floor(eased * parseInt(c.dataset.target ?? '0', 10))}${c.dataset.suffix ?? ''}`; });
31
+ if (progress < 1)
32
+ frame = requestAnimationFrame(step);
33
+ else {
34
+ frame = 0;
35
+ finish();
36
+ }
37
+ };
38
+ frame = requestAnimationFrame(step);
39
+ };
40
+ if (!('IntersectionObserver' in window))
41
+ return animate();
42
+ observer = new IntersectionObserver((entries) => {
43
+ if (entries.some((e) => e.isIntersecting)) {
44
+ observer?.disconnect();
45
+ observer = null;
46
+ animate();
47
+ }
48
+ }, { threshold: 0.3 });
49
+ observer.observe(counters[0]);
50
+ }
51
+ return hwioMount('counter', { bind, cleanup });
52
+ }
@@ -0,0 +1,53 @@
1
+ export interface HWioSubmitInput {
2
+ formId: string;
3
+ locale: string;
4
+ turnstileToken?: string;
5
+ hutk?: string;
6
+ pageUri: string;
7
+ pageName: string;
8
+ fields: Record<string, string>;
9
+ attribution?: Record<string, string>;
10
+ }
11
+ /** The JSON body accepted by the website-forms Worker (submit.hardwario.com / submit.enerooo.cz). */
12
+ export declare function hwioBuildSubmitPayload(input: HWioSubmitInput): {
13
+ formId: string;
14
+ locale: string;
15
+ turnstileToken: string | undefined;
16
+ hutk: string | undefined;
17
+ pageUri: string;
18
+ pageName: string;
19
+ fields: Record<string, string>;
20
+ attribution: Record<string, string> | undefined;
21
+ };
22
+ /** Origin + pathname only: keeps raw query-string click ids out of HubSpot's hs_context. */
23
+ export declare function hwioPageUri(location: {
24
+ origin: string;
25
+ pathname: string;
26
+ }): string;
27
+ /** GA4 client id from the _ga cookie value (GA1.1.<client id> -> last two dot groups). */
28
+ export declare function hwioGaClientId(cookieHeader: string): string | undefined;
29
+ /**
30
+ * Folds UI-only qualification fields into the message field ("Label: value" lines) so small
31
+ * HubSpot forms need no extra properties. Returns the new fields object.
32
+ */
33
+ export declare function hwioFoldMessageContext(fields: Record<string, string>, context: {
34
+ name: string;
35
+ label: string;
36
+ value: string;
37
+ }[]): Record<string, string>;
38
+ export type HWioFormEvent = 'generate_lead' | 'newsletter_signup';
39
+ export declare function hwioFormEventPayload(input: {
40
+ event: HWioFormEvent;
41
+ formId: string;
42
+ formName: string;
43
+ formKind?: string;
44
+ triggerLocation?: string;
45
+ pagePath: string;
46
+ }): {
47
+ page_path: string;
48
+ trigger_location?: string | undefined;
49
+ event: HWioFormEvent;
50
+ form_id: string;
51
+ form_name: string;
52
+ form_kind: string;
53
+ };
@@ -0,0 +1,53 @@
1
+ /** The JSON body accepted by the website-forms Worker (submit.hardwario.com / submit.enerooo.cz). */
2
+ export function hwioBuildSubmitPayload(input) {
3
+ const attribution = input.attribution && Object.keys(input.attribution).length ? input.attribution : undefined;
4
+ return {
5
+ formId: input.formId,
6
+ locale: input.locale,
7
+ turnstileToken: input.turnstileToken,
8
+ hutk: input.hutk,
9
+ pageUri: input.pageUri,
10
+ pageName: input.pageName,
11
+ fields: input.fields,
12
+ attribution,
13
+ };
14
+ }
15
+ /** Origin + pathname only: keeps raw query-string click ids out of HubSpot's hs_context. */
16
+ export function hwioPageUri(location) {
17
+ return location.origin + location.pathname;
18
+ }
19
+ /** GA4 client id from the _ga cookie value (GA1.1.<client id> -> last two dot groups). */
20
+ export function hwioGaClientId(cookieHeader) {
21
+ const ga = cookieHeader.match(/(?:^|;\s*)_ga=([^;]+)/)?.[1];
22
+ const clientId = ga ? ga.split('.').slice(-2).join('.') : '';
23
+ return /^\d+\.\d+$/.test(clientId) ? clientId : undefined;
24
+ }
25
+ /**
26
+ * Folds UI-only qualification fields into the message field ("Label: value" lines) so small
27
+ * HubSpot forms need no extra properties. Returns the new fields object.
28
+ */
29
+ export function hwioFoldMessageContext(fields, context) {
30
+ const out = { ...fields };
31
+ const lines = [];
32
+ for (const item of context) {
33
+ if (item.value.trim())
34
+ lines.push(`${item.label || item.name}: ${item.value.trim()}`);
35
+ if (item.name)
36
+ delete out[item.name];
37
+ }
38
+ if (lines.length) {
39
+ const original = out.message?.trim();
40
+ out.message = `${lines.join('\n')}${original ? `\n\n${original}` : ''}`.slice(0, 5000);
41
+ }
42
+ return out;
43
+ }
44
+ export function hwioFormEventPayload(input) {
45
+ return {
46
+ event: input.event,
47
+ form_id: input.formId,
48
+ form_name: input.formName,
49
+ form_kind: input.formKind ?? input.formName,
50
+ ...(input.triggerLocation ? { trigger_location: input.triggerLocation } : {}),
51
+ page_path: input.pagePath,
52
+ };
53
+ }
@@ -0,0 +1,7 @@
1
+ export interface HWioSubmitResult {
2
+ ok: boolean;
3
+ status?: number;
4
+ error?: string;
5
+ }
6
+ export declare function hwioSubmitForm(form: HTMLFormElement, fields: Record<string, string>): Promise<HWioSubmitResult>;
7
+ export declare function hwioFormsRuntime(): import("./mount.js").HWioBinder;
package/dist/forms.js ADDED
@@ -0,0 +1,135 @@
1
+ import { hwioMount } from './mount.js';
2
+ import { hwioGetAttribution, hwioGetPageAttribution } from './attribution.js';
3
+ import { hwioBuildSubmitPayload, hwioFoldMessageContext, hwioFormEventPayload, hwioGaClientId, hwioPageUri } from './forms-core.js';
4
+ import { hwioEnsureTurnstileToken, hwioResetTurnstile } from './turnstile.js';
5
+ /**
6
+ * Self-describing form contract (attributes stay compatible with the current sites):
7
+ * <form data-hs-form="contact" data-hs-form-guid="<HubSpot GUID>" data-hs-endpoint="https://submit.hardwario.com/api/submit"
8
+ * data-hs-event="generate_lead|newsletter_signup" data-hs-enrich="true"
9
+ * data-hs-sending-msg data-hs-success-msg data-hs-error-msg data-form-kind data-inquiry-source>
10
+ * inside: [data-hs-status] element, optional .cf-turnstile, optional [data-message-field data-message-label] controls.
11
+ * `data-hs-enrich="true"` sends the HubSpot cookie, the stored attribution and the GA4 client id,
12
+ * each gated on the matching consent category. Click ids in the current URL are always sent.
13
+ */
14
+ async function readHubspotCookie() {
15
+ const store = window.cookieStore;
16
+ if (store) {
17
+ try {
18
+ return (await store.get('hubspotutk'))?.value;
19
+ }
20
+ catch { /* fall through */ }
21
+ }
22
+ return document.cookie.match(/(?:^|;\s*)hubspotutk=([^;]+)/)?.[1];
23
+ }
24
+ export async function hwioSubmitForm(form, fields) {
25
+ const endpoint = form.dataset.hsEndpoint;
26
+ const formId = form.dataset.hsFormGuid;
27
+ if (!endpoint || !formId)
28
+ return { ok: false, error: 'form is missing data-hs-endpoint or data-hs-form-guid' };
29
+ const locale = fields.hs_language || document.documentElement.lang.slice(0, 2) || 'en';
30
+ const turnstileToken = fields['cf-turnstile-response'];
31
+ delete fields['cf-turnstile-response'];
32
+ const enrich = form.dataset.hsEnrich === 'true';
33
+ const canEnrich = enrich && window.hwioConsent?.allows('marketing') === true;
34
+ const canMeasure = enrich && window.hwioConsent?.allows('statistics') === true;
35
+ const attribution = { ...(canEnrich ? hwioGetAttribution() : {}), ...hwioGetPageAttribution() };
36
+ if (canMeasure) {
37
+ const clientId = hwioGaClientId(document.cookie);
38
+ if (clientId)
39
+ attribution.ga_client_id = clientId;
40
+ }
41
+ const payload = hwioBuildSubmitPayload({
42
+ formId,
43
+ locale,
44
+ turnstileToken,
45
+ hutk: canEnrich ? await readHubspotCookie() : undefined,
46
+ pageUri: hwioPageUri(window.location),
47
+ pageName: document.title,
48
+ fields,
49
+ attribution,
50
+ });
51
+ try {
52
+ const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
53
+ if (!res.ok) {
54
+ const detail = await res.text().catch(() => '');
55
+ return { ok: false, status: res.status, error: detail || res.statusText };
56
+ }
57
+ return { ok: true };
58
+ }
59
+ catch (err) {
60
+ return { ok: false, error: err instanceof Error ? err.message : 'Network error' };
61
+ }
62
+ }
63
+ function pushFormEvent(form) {
64
+ const event = form.dataset.hsEvent;
65
+ if (!event || !window.hwioConsent?.canTrack())
66
+ return;
67
+ window.dataLayer = window.dataLayer || [];
68
+ window.dataLayer.push(hwioFormEventPayload({
69
+ event,
70
+ formId: form.dataset.hsFormGuid ?? '',
71
+ formName: form.dataset.hsForm ?? '',
72
+ formKind: form.dataset.formKind,
73
+ triggerLocation: form.dataset.inquirySource,
74
+ pagePath: window.location.pathname,
75
+ }));
76
+ }
77
+ export function hwioFormsRuntime() {
78
+ function bind() {
79
+ document.querySelectorAll('form[data-hs-form]').forEach((form) => {
80
+ if (form.dataset.hsBound === 'true')
81
+ return;
82
+ form.dataset.hsBound = 'true';
83
+ const submitBtn = form.querySelector('button[type="submit"]');
84
+ if (submitBtn && !submitBtn.dataset.originalText)
85
+ submitBtn.dataset.originalText = submitBtn.textContent ?? '';
86
+ form.addEventListener('submit', async (e) => {
87
+ e.preventDefault();
88
+ const status = form.querySelector('[data-hs-status]');
89
+ const sending = form.dataset.hsSendingMsg ?? '';
90
+ const success = form.dataset.hsSuccessMsg ?? '';
91
+ const error = form.dataset.hsErrorMsg ?? '';
92
+ const original = submitBtn?.dataset.originalText ?? submitBtn?.textContent ?? '';
93
+ if (submitBtn) {
94
+ submitBtn.disabled = true;
95
+ if (sending)
96
+ submitBtn.textContent = sending;
97
+ }
98
+ if (status) {
99
+ status.textContent = '';
100
+ status.classList.remove('hs-status-success', 'hs-status-error');
101
+ status.hidden = true;
102
+ }
103
+ await hwioEnsureTurnstileToken(form);
104
+ let fields = {};
105
+ for (const [name, value] of new FormData(form).entries())
106
+ if (typeof value === 'string')
107
+ fields[name] = value;
108
+ const context = Array.from(form.querySelectorAll('[data-message-field]')).map((el) => ({
109
+ name: el.name,
110
+ label: el.dataset.messageLabel?.trim() ?? '',
111
+ value: el.value,
112
+ }));
113
+ fields = hwioFoldMessageContext(fields, context);
114
+ const result = await hwioSubmitForm(form, fields);
115
+ hwioResetTurnstile(form);
116
+ if (submitBtn) {
117
+ submitBtn.disabled = false;
118
+ submitBtn.textContent = original;
119
+ }
120
+ if (status) {
121
+ status.hidden = false;
122
+ status.textContent = result.ok ? success : error;
123
+ status.classList.add(result.ok ? 'hs-status-success' : 'hs-status-error');
124
+ }
125
+ if (result.ok) {
126
+ pushFormEvent(form);
127
+ form.reset();
128
+ }
129
+ else
130
+ console.error('form submission failed', result);
131
+ });
132
+ });
133
+ }
134
+ return hwioMount('forms', { bind, cleanup: () => { } });
135
+ }
@@ -0,0 +1,8 @@
1
+ export interface HWioInlineScript {
2
+ code: string;
3
+ hash: string;
4
+ }
5
+ export declare const consentDefaults: HWioInlineScript;
6
+ export declare const fontGate: HWioInlineScript;
7
+ export declare const themeBootstrap: HWioInlineScript;
8
+ export declare const hwioInlineHashes: string[];
package/dist/inline.js ADDED
@@ -0,0 +1,4 @@
1
+ export const consentDefaults = { "code": "(function(){window.dataLayer=window.dataLayer||[];window.gtag=window.gtag||function(){window.dataLayer.push(arguments)};window.gtag('consent','default',{ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied',analytics_storage:'denied',functionality_storage:'denied',personalization_storage:'denied',security_storage:'granted'})})();", "hash": "sha256-8ypBBYlrPDUYAWyidrG8ZMBi91IYR2VYNNXABFu2aI8=" };
2
+ export const fontGate = { "code": "(function(){var h=document.documentElement,d=h.dataset,fam=d.hwioFontFamily;if(!fam||!('fonts' in document)){h.classList.remove('fonts-loading');h.classList.add('fonts-ready');return}var T=5000;function load(s){return new Promise(function(res,rej){var id=setTimeout(function(){rej(new Error('font timeout'))},T);document.fonts.load('400 1em \"'+fam+'\"',s).then(function(f){clearTimeout(id);res(f)},function(e){clearTimeout(id);rej(e)})})}if(!window.__hwioFontGate){window.__hwioFontGate={state:'loading',ready:Promise.all([load(d.hwioFontSample||'HARDWARIO'),load(d.hwioFontSampleExt||'Řešení Vývojáři Odwiedź')])};document.addEventListener('astro:before-swap',function(ev){var n=ev.newDocument.documentElement;n.classList.add('js');n.classList.remove('fonts-loading');n.classList.toggle('fonts-failed',window.__hwioFontGate.state==='failed');n.classList.add('fonts-ready');var cur=h.getAttribute('data-theme');if(cur)n.setAttribute('data-theme',cur);else n.removeAttribute('data-theme')})}window.__hwioFontGate.ready.then(function(){window.__hwioFontGate.state='ready';h.classList.remove('fonts-loading');h.classList.add('fonts-ready')},function(){window.__hwioFontGate.state='failed';h.classList.remove('fonts-loading');h.classList.add('fonts-failed','fonts-ready')})})();", "hash": "sha256-yZ5zwDTjvBj25m7DRmqzRrznrnc2PqP3T7FWx0ftKO8=" };
3
+ export const themeBootstrap = { "code": "(function(){var h=document.documentElement,d=h.dataset;h.classList.add('js');try{var v=d.hwioConsentVersion;var sv=localStorage.getItem('hwio_theme_consent_version')||localStorage.getItem('hw_theme_consent_version');if(!v||sv!==v)return;var t=localStorage.getItem('hwio_theme')||localStorage.getItem('theme');if(t==='dark'&&d.hwioThemeDark)h.setAttribute('data-theme',d.hwioThemeDark);else if(t==='light'&&d.hwioThemeLight)h.setAttribute('data-theme',d.hwioThemeLight)}catch(e){}})();", "hash": "sha256-JW1fCR5ZsiIO72q9E9VB79Buasr9rvLssoILZij91Yk=" }; // legacy-ok: reads the pre-design-system storage keys once
4
+ export const hwioInlineHashes = [consentDefaults.hash, fontGate.hash, themeBootstrap.hash];
@@ -0,0 +1,21 @@
1
+ /** A behaviour that can attach to the current document and detach before a view-transition swap. */
2
+ export interface HWioBinder {
3
+ bind: () => void;
4
+ cleanup: () => void;
5
+ }
6
+ declare global {
7
+ interface Window {
8
+ __hwioRuntimes?: Record<string, HWioBinder>;
9
+ }
10
+ }
11
+ /**
12
+ * Registers a behaviour once per page session. With Astro's ClientRouter the module script runs
13
+ * once, so `bind` re-runs on every `astro:page-load` and `cleanup` before each swap. Without the
14
+ * router it binds immediately. Binders must be idempotent (mark bound nodes with data-hwio-*-bound).
15
+ */
16
+ export declare function hwioMount(name: string, binder: HWioBinder): HWioBinder;
17
+ /** Shorthand for behaviours that use one AbortController per page. */
18
+ export declare function hwioPageScope(): {
19
+ signal: AbortSignal;
20
+ abort: () => void;
21
+ };
package/dist/mount.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Registers a behaviour once per page session. With Astro's ClientRouter the module script runs
3
+ * once, so `bind` re-runs on every `astro:page-load` and `cleanup` before each swap. Without the
4
+ * router it binds immediately. Binders must be idempotent (mark bound nodes with data-hwio-*-bound).
5
+ */
6
+ export function hwioMount(name, binder) {
7
+ const registry = (window.__hwioRuntimes ??= {});
8
+ const existing = registry[name];
9
+ if (existing) {
10
+ existing.bind();
11
+ return existing;
12
+ }
13
+ registry[name] = binder;
14
+ document.addEventListener('astro:before-swap', () => binder.cleanup());
15
+ document.addEventListener('astro:page-load', () => binder.bind());
16
+ if (document.readyState === 'loading') {
17
+ document.addEventListener('DOMContentLoaded', () => binder.bind(), { once: true });
18
+ }
19
+ else {
20
+ binder.bind();
21
+ }
22
+ return binder;
23
+ }
24
+ /** Shorthand for behaviours that use one AbortController per page. */
25
+ export function hwioPageScope() {
26
+ const controller = new AbortController();
27
+ return { signal: controller.signal, abort: () => controller.abort() };
28
+ }
package/dist/nav.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Header behaviours: keeps aria-expanded on the DaisyUI drawer label in sync with its checkbox,
3
+ * closes the drawer when a link inside it is followed, and marks the nav link whose section is
4
+ * in view ([data-hwio-scrollspy] nav with same-page anchors) with aria-current="location".
5
+ */
6
+ export declare function hwioNavRuntime(): import("./mount.js").HWioBinder;
package/dist/nav.js ADDED
@@ -0,0 +1,51 @@
1
+ import { hwioMount } from './mount.js';
2
+ /**
3
+ * Header behaviours: keeps aria-expanded on the DaisyUI drawer label in sync with its checkbox,
4
+ * closes the drawer when a link inside it is followed, and marks the nav link whose section is
5
+ * in view ([data-hwio-scrollspy] nav with same-page anchors) with aria-current="location".
6
+ */
7
+ export function hwioNavRuntime() {
8
+ let controller = null;
9
+ let observer = null;
10
+ function cleanup() {
11
+ controller?.abort();
12
+ controller = null;
13
+ observer?.disconnect();
14
+ observer = null;
15
+ }
16
+ function bind() {
17
+ cleanup();
18
+ controller = new AbortController();
19
+ const { signal } = controller;
20
+ document.querySelectorAll('input.drawer-toggle').forEach((input) => {
21
+ const labels = Array.from(document.querySelectorAll(`label[for="${input.id}"]`));
22
+ const sync = () => labels.forEach((l) => l.setAttribute('aria-expanded', String(input.checked)));
23
+ sync();
24
+ input.addEventListener('change', sync, { signal });
25
+ const side = input.parentElement?.querySelector('.drawer-side');
26
+ side?.querySelectorAll('a[href]').forEach((a) => a.addEventListener('click', () => { input.checked = false; sync(); }, { signal }));
27
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && input.checked) {
28
+ input.checked = false;
29
+ sync();
30
+ } }, { signal });
31
+ });
32
+ const nav = document.querySelector('[data-hwio-scrollspy]');
33
+ if (!nav || !('IntersectionObserver' in window))
34
+ return;
35
+ const links = Array.from(nav.querySelectorAll('a[href^="#"]'));
36
+ const sections = links.map((a) => document.getElementById(a.hash.slice(1))).filter((s) => !!s);
37
+ if (!sections.length)
38
+ return;
39
+ observer = new IntersectionObserver((entries) => {
40
+ const visible = entries.filter((e) => e.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
41
+ if (!visible)
42
+ return;
43
+ links.forEach((a) => { if (a.hash.slice(1) === visible.target.id)
44
+ a.setAttribute('aria-current', 'location');
45
+ else
46
+ a.removeAttribute('aria-current'); });
47
+ }, { rootMargin: '-40% 0px -50% 0px', threshold: [0, 0.25, 0.5] });
48
+ sections.forEach((s) => observer.observe(s));
49
+ }
50
+ return hwioMount('nav', { bind, cleanup });
51
+ }
@@ -0,0 +1,2 @@
1
+ /** Scroll reveal for [data-hwio-reveal]: sets data-hwio-revealed once in view; reduced motion reveals immediately. */
2
+ export declare function hwioRevealRuntime(): import("./mount.js").HWioBinder;
package/dist/reveal.js ADDED
@@ -0,0 +1,24 @@
1
+ import { hwioMount } from './mount.js';
2
+ /** Scroll reveal for [data-hwio-reveal]: sets data-hwio-revealed once in view; reduced motion reveals immediately. */
3
+ export function hwioRevealRuntime() {
4
+ let observer = null;
5
+ function bind() {
6
+ const targets = Array.from(document.querySelectorAll('[data-hwio-reveal]:not([data-hwio-revealed])'));
7
+ if (!targets.length)
8
+ return;
9
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || !('IntersectionObserver' in window)) {
10
+ targets.forEach((el) => (el.dataset.hwioRevealed = 'true'));
11
+ return;
12
+ }
13
+ observer ??= new IntersectionObserver((entries) => {
14
+ entries.forEach((entry) => {
15
+ if (!entry.isIntersecting)
16
+ return;
17
+ entry.target.dataset.hwioRevealed = 'true';
18
+ observer?.unobserve(entry.target);
19
+ });
20
+ }, { threshold: 0.1, rootMargin: '0px 0px -50px 0px' });
21
+ targets.forEach((el) => observer.observe(el));
22
+ }
23
+ return hwioMount('reveal', { bind, cleanup: () => { observer?.disconnect(); observer = null; } });
24
+ }
@@ -0,0 +1,10 @@
1
+ export type HWioScheme = 'light' | 'dark';
2
+ export type HWioThemeChoice = HWioScheme | 'system';
3
+ /** The scheme actually in effect: explicit data-theme ending in "-dark", else the OS preference. */
4
+ export declare function hwioResolvedScheme(): HWioScheme;
5
+ export declare function hwioApplyTheme(choice: HWioThemeChoice): void;
6
+ /**
7
+ * Binds [data-hwio-theme-toggle] buttons (two-state light/dark) and keeps the runtime-only
8
+ * data-theme across ClientRouter swaps (Astro restores root attributes from server HTML).
9
+ */
10
+ export declare function hwioThemeRuntime(): import("./mount.js").HWioBinder;
package/dist/theme.js ADDED
@@ -0,0 +1,79 @@
1
+ import { hwioMount } from './mount.js';
2
+ const THEME_KEY = 'hwio_theme';
3
+ const THEME_VERSION_KEY = 'hwio_theme_consent_version';
4
+ /** The scheme actually in effect: explicit data-theme ending in "-dark", else the OS preference. */
5
+ export function hwioResolvedScheme() {
6
+ const theme = document.documentElement.getAttribute('data-theme');
7
+ if (theme)
8
+ return theme.endsWith('-dark') ? 'dark' : 'light';
9
+ if (document.documentElement.classList.contains('dark'))
10
+ return 'dark'; // legacy-ok: www's class toggle until P2
11
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
12
+ }
13
+ function themeNames() {
14
+ const d = document.documentElement.dataset;
15
+ return { light: d.hwioThemeLight ?? 'hwio', dark: d.hwioThemeDark ?? 'hwio-dark' };
16
+ }
17
+ export function hwioApplyTheme(choice) {
18
+ const html = document.documentElement;
19
+ const names = themeNames();
20
+ if (choice === 'system')
21
+ html.removeAttribute('data-theme');
22
+ else
23
+ html.setAttribute('data-theme', choice === 'dark' ? names.dark : names.light);
24
+ html.classList.add('hwio-theme-transition');
25
+ window.setTimeout(() => html.classList.remove('hwio-theme-transition'), 300);
26
+ try {
27
+ if (window.hwioConsent?.allows('preferences')) {
28
+ if (choice === 'system') {
29
+ localStorage.removeItem(THEME_KEY);
30
+ localStorage.removeItem(THEME_VERSION_KEY);
31
+ }
32
+ else {
33
+ localStorage.setItem(THEME_KEY, choice);
34
+ const version = html.dataset.hwioConsentVersion;
35
+ if (version)
36
+ localStorage.setItem(THEME_VERSION_KEY, version);
37
+ }
38
+ }
39
+ }
40
+ catch {
41
+ // storage unavailable
42
+ }
43
+ window.dispatchEvent(new CustomEvent('hwio:themechange', { detail: { choice, scheme: hwioResolvedScheme() } }));
44
+ }
45
+ /**
46
+ * Binds [data-hwio-theme-toggle] buttons (two-state light/dark) and keeps the runtime-only
47
+ * data-theme across ClientRouter swaps (Astro restores root attributes from server HTML).
48
+ */
49
+ export function hwioThemeRuntime() {
50
+ let controller = null;
51
+ let themeBeforeSwap = null;
52
+ document.addEventListener('astro:before-swap', () => { themeBeforeSwap = document.documentElement.getAttribute('data-theme'); });
53
+ document.addEventListener('astro:after-swap', () => {
54
+ document.documentElement.classList.add('js');
55
+ if (themeBeforeSwap)
56
+ document.documentElement.setAttribute('data-theme', themeBeforeSwap);
57
+ else
58
+ document.documentElement.removeAttribute('data-theme');
59
+ });
60
+ function syncPressed() {
61
+ const dark = hwioResolvedScheme() === 'dark';
62
+ document.querySelectorAll('[data-hwio-theme-toggle]').forEach((el) => el.setAttribute('aria-pressed', String(dark)));
63
+ }
64
+ function bind() {
65
+ const toggles = Array.from(document.querySelectorAll('[data-hwio-theme-toggle]:not([data-hwio-theme-bound])'));
66
+ syncPressed();
67
+ if (!toggles.length)
68
+ return;
69
+ controller ??= new AbortController();
70
+ toggles.forEach((toggle) => {
71
+ toggle.dataset.hwioThemeBound = 'true';
72
+ toggle.addEventListener('click', () => {
73
+ hwioApplyTheme(hwioResolvedScheme() === 'dark' ? 'light' : 'dark');
74
+ syncPressed();
75
+ }, { signal: controller.signal });
76
+ });
77
+ }
78
+ return hwioMount('theme', { bind, cleanup: () => { controller?.abort(); controller = null; } });
79
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * CTA click events for GTM: any [data-track] element pushes cta_click with its id and location.
3
+ * Consent-gated like the form events. Attribute names are the measurement contract shared with
4
+ * the GTM containers and CONVERSION.md; do not rename them.
5
+ */
6
+ export declare function hwioTrackRuntime(): void;
package/dist/track.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * CTA click events for GTM: any [data-track] element pushes cta_click with its id and location.
3
+ * Consent-gated like the form events. Attribute names are the measurement contract shared with
4
+ * the GTM containers and CONVERSION.md; do not rename them.
5
+ */
6
+ export function hwioTrackRuntime() {
7
+ if (window.__hwioTrackBound)
8
+ return;
9
+ window.__hwioTrackBound = true;
10
+ document.addEventListener('click', (event) => {
11
+ const el = event.target?.closest?.('[data-track]');
12
+ if (!el || !window.hwioConsent?.canTrack())
13
+ return;
14
+ window.dataLayer = window.dataLayer || [];
15
+ window.dataLayer.push({
16
+ event: 'cta_click',
17
+ cta_id: el.getAttribute('data-track'),
18
+ trigger_location: el.getAttribute('data-track-location') || undefined,
19
+ page_path: window.location.pathname,
20
+ });
21
+ });
22
+ }
@@ -0,0 +1,30 @@
1
+ interface TurnstileApi {
2
+ render: (el: HTMLElement, opts: {
3
+ sitekey: string;
4
+ theme?: 'light' | 'dark' | 'auto';
5
+ }) => string | undefined;
6
+ reset: (id?: string) => void;
7
+ remove: (id: string) => void;
8
+ }
9
+ export type HWioTurnstileForm = HTMLFormElement & {
10
+ hwioLoadTurnstile?: () => void;
11
+ hwioTurnstileWidgetId?: string;
12
+ hwLoadTurnstile?: () => void;
13
+ hwTurnstileWidgetId?: string;
14
+ };
15
+ declare global {
16
+ interface Window {
17
+ turnstile?: TurnstileApi;
18
+ __hwioTurnstileLoader?: Promise<TurnstileApi>;
19
+ }
20
+ }
21
+ /**
22
+ * Lazy explicit Turnstile: the third-party script loads on first form interaction (focus or
23
+ * pointer-enter) and the widget id is stored on its form so the submit handler resets exactly
24
+ * the token it consumed. Markup: <div class="cf-turnstile" data-sitekey="..."> inside a <form>.
25
+ */
26
+ export declare function hwioTurnstileRuntime(): import("./mount.js").HWioBinder;
27
+ /** Waits (bounded) for the lazily rendered widget to produce a token before the form is snapshotted. */
28
+ export declare function hwioEnsureTurnstileToken(form: HWioTurnstileForm, timeoutMs?: number): Promise<void>;
29
+ export declare function hwioResetTurnstile(form: HWioTurnstileForm): void;
30
+ export {};
@@ -0,0 +1,92 @@
1
+ import { hwioMount } from './mount.js';
2
+ import { hwioResolvedScheme } from './theme.js';
3
+ const SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
4
+ function loadApi() {
5
+ if (window.turnstile)
6
+ return Promise.resolve(window.turnstile);
7
+ if (window.__hwioTurnstileLoader)
8
+ return window.__hwioTurnstileLoader;
9
+ window.__hwioTurnstileLoader = new Promise((resolve, reject) => {
10
+ const existing = document.querySelector(`script[src="${SRC}"]`);
11
+ const script = existing ?? document.createElement('script');
12
+ script.addEventListener('load', () => (window.turnstile ? resolve(window.turnstile) : reject(new Error('Turnstile loaded without its API'))), { once: true });
13
+ script.addEventListener('error', () => { delete window.__hwioTurnstileLoader; reject(new Error('Turnstile failed to load')); }, { once: true });
14
+ if (!existing) {
15
+ script.src = SRC;
16
+ script.async = true;
17
+ document.head.appendChild(script);
18
+ }
19
+ });
20
+ return window.__hwioTurnstileLoader;
21
+ }
22
+ /**
23
+ * Lazy explicit Turnstile: the third-party script loads on first form interaction (focus or
24
+ * pointer-enter) and the widget id is stored on its form so the submit handler resets exactly
25
+ * the token it consumed. Markup: <div class="cf-turnstile" data-sitekey="..."> inside a <form>.
26
+ */
27
+ export function hwioTurnstileRuntime() {
28
+ let controller = null;
29
+ let widgetIds = [];
30
+ function cleanup() {
31
+ controller?.abort();
32
+ controller = null;
33
+ widgetIds.forEach((id) => { try {
34
+ window.turnstile?.remove(id);
35
+ }
36
+ catch { /* gone */ } });
37
+ widgetIds = [];
38
+ }
39
+ function bind() {
40
+ const containers = Array.from(document.querySelectorAll('.cf-turnstile:not([data-hwio-turnstile-bound])'));
41
+ if (!containers.length)
42
+ return;
43
+ controller ??= new AbortController();
44
+ const { signal } = controller;
45
+ containers.forEach((container) => {
46
+ const form = container.closest('form');
47
+ if (!form)
48
+ return;
49
+ container.dataset.hwioTurnstileBound = 'true';
50
+ let state = 'idle';
51
+ const render = (api) => {
52
+ if (signal.aborted || !container.isConnected || state === 'rendered')
53
+ return;
54
+ state = 'rendered';
55
+ const id = api.render(container, { sitekey: container.dataset.sitekey ?? '', theme: hwioResolvedScheme() });
56
+ if (id !== undefined) {
57
+ widgetIds.push(id);
58
+ form.hwioTurnstileWidgetId = id;
59
+ form.hwTurnstileWidgetId = id; // legacy-ok
60
+ }
61
+ };
62
+ const load = () => {
63
+ if (state !== 'idle')
64
+ return;
65
+ state = 'loading';
66
+ loadApi().then(render).catch(() => { if (!signal.aborted)
67
+ state = 'idle'; });
68
+ };
69
+ form.hwioLoadTurnstile = load;
70
+ form.hwLoadTurnstile = load; // legacy-ok
71
+ form.addEventListener('focusin', load, { once: true, signal });
72
+ form.addEventListener('pointerenter', load, { once: true, signal });
73
+ });
74
+ }
75
+ return hwioMount('turnstile', { bind, cleanup });
76
+ }
77
+ /** Waits (bounded) for the lazily rendered widget to produce a token before the form is snapshotted. */
78
+ export async function hwioEnsureTurnstileToken(form, timeoutMs = 4000) {
79
+ const token = () => form.querySelector('input[name="cf-turnstile-response"]')?.value ?? '';
80
+ if (!form.querySelector('.cf-turnstile') || token())
81
+ return;
82
+ form.hwioLoadTurnstile?.();
83
+ const start = performance.now();
84
+ while (!token() && performance.now() - start < timeoutMs) {
85
+ await new Promise((r) => setTimeout(r, 100));
86
+ }
87
+ }
88
+ export function hwioResetTurnstile(form) {
89
+ const id = form.hwioTurnstileWidgetId;
90
+ if (id)
91
+ window.turnstile?.reset(id);
92
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@hubpav/hwio-web-runtime",
3
+ "version": "0.1.0",
4
+ "description": "Framework-free browser runtime for the HARDWARIO websites: consent (Consent Mode v2 + GTM), HubSpot forms with Turnstile, attribution, theme, CTA tracking and small behaviours. View-transition safe.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/hardwario/website-ui.git",
10
+ "directory": "packages/web-runtime"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md"
15
+ ],
16
+ "exports": {
17
+ "./mount": "./dist/mount.js",
18
+ "./consent": "./dist/consent.js",
19
+ "./consent-core": "./dist/consent-core.js",
20
+ "./forms": "./dist/forms.js",
21
+ "./forms-core": "./dist/forms-core.js",
22
+ "./turnstile": "./dist/turnstile.js",
23
+ "./attribution": "./dist/attribution.js",
24
+ "./theme": "./dist/theme.js",
25
+ "./track": "./dist/track.js",
26
+ "./reveal": "./dist/reveal.js",
27
+ "./counter": "./dist/counter.js",
28
+ "./nav": "./dist/nav.js",
29
+ "./accordion": "./dist/accordion.js",
30
+ "./inline": "./dist/inline.js",
31
+ "./package.json": "./package.json"
32
+ },
33
+ "scripts": {
34
+ "build": "node scripts/build-inline.mjs && tsc -p tsconfig.json",
35
+ "test": "node --test \"test/**/*.test.mjs\""
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "^6.0.3"
39
+ },
40
+ "sideEffects": false
41
+ }