ab-nextjs-components 0.1.7 → 0.1.9

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.
@@ -0,0 +1,82 @@
1
+ /*
2
+ * @license MIT
3
+ * ~~~~~~~~~~~~
4
+ * ab-nextjs-components
5
+ * ~~~~~~~~~~~~
6
+ * Copyright (c) 2026 Abraham Ukachi. The abElements Project.
7
+ * (MIT license text: see LICENSE)
8
+ *
9
+ * @project: ab-nextjs-components
10
+ * @name: AbSplash - CSP hash helpers (COMPILED, do not edit — run `node scripts/build-csp.mjs`)
11
+ * @file: ab-splash/csp.mjs
12
+ * @authors: Abraham Ukachi <abrahamukachi@gmail.com>
13
+ */
14
+ // 🤖 generated from ab-splash/csp.ts + ab-splash/gate.ts (bundled, ESM, Node)
15
+
16
+ // ab-splash/csp.ts
17
+ import { createHash } from "node:crypto";
18
+
19
+ // ab-splash/gate.ts
20
+ var AB_SPLASH_ATTRIBUTE = "data-ab-splash";
21
+ var AB_SPLASH_VISITED_KEY = "ab-splash-visited";
22
+ var AB_SPLASH_SESSION_KEY = "ab-splash-session";
23
+ var AB_SPLASH_BOT_PATTERN = "bot|crawl|spider|slurp|bingpreview|facebookexternalhit|embedly|quora link preview|whatsapp|telegrambot|discordbot|headlesschrome|lighthouse|pagespeed|prerender";
24
+ var escapeRe = (value) => value.replace(/[.*+?^${}()|[\]\\/-]/g, "\\$&");
25
+ function buildAbSplashRootPathPattern(options = {}) {
26
+ const prefixed = (options.locales ?? []).filter((locale) => locale && locale !== options.defaultLocale).map(escapeRe);
27
+ const subPaths = (options.rootSubPaths ?? ["welcome"]).filter(Boolean).map(escapeRe);
28
+ const localePart = prefixed.length ? `(?:\\/(?:${prefixed.join("|")}))?` : "";
29
+ const subPart = subPaths.length ? `(?:\\/(?:${subPaths.join("|")}))?` : "";
30
+ return `^${localePart}${subPart}\\/?$`;
31
+ }
32
+ function buildAbSplashGateScript(options = {}) {
33
+ const pattern = JSON.stringify(buildAbSplashRootPathPattern(options));
34
+ const bots = JSON.stringify(AB_SPLASH_BOT_PATTERN);
35
+ return `(function () {
36
+ // \u{1F9ED} shortcuts
37
+ var w = window, d = document, nav = w.navigator || {};
38
+ // \u{1F916} skip automation and bots (no Splash for crawlers, previews or headless tests)
39
+ if (nav.webdriver || new RegExp(${bots}, 'i').test(nav.userAgent || '')) return;
40
+ // \u{1F4D6} safe storage read: a failing get counts as "already seen" ('1')
41
+ function get(store, key) { try { return w[store].getItem(key); } catch (e) { return '1'; } }
42
+ // \u270D\uFE0F safe storage write: a failing set is ignored
43
+ function set(store, key) { try { w[store].setItem(key, '1'); } catch (e) {} }
44
+ // \u{1F4F1} installed PWA? (matchMedia OR iOS navigator.standalone)
45
+ var mm = false;
46
+ try { mm = !!(w.matchMedia && w.matchMedia('(display-mode: standalone)').matches); } catch (e) {}
47
+ var isStandalone = mm || nav.standalone === true;
48
+ // \u{1F3E0} a root page? (/, /<locale>, /welcome, /<locale>/welcome \u2014 never the default locale prefix)
49
+ var isRootPage = false;
50
+ try { isRootPage = new RegExp(${pattern}).test(w.location.pathname); } catch (e) {}
51
+ // \u{1F6A6} decision
52
+ var show = false;
53
+ // \u{1F4F1} standalone: once per session
54
+ if (isStandalone) {
55
+ if (!get('sessionStorage', ${JSON.stringify(AB_SPLASH_SESSION_KEY)})) show = true;
56
+ set('sessionStorage', ${JSON.stringify(AB_SPLASH_SESSION_KEY)});
57
+ }
58
+ // \u{1F463} first ever visit, on a root page only
59
+ if (!get('localStorage', ${JSON.stringify(AB_SPLASH_VISITED_KEY)})) {
60
+ if (isRootPage) show = true;
61
+ set('localStorage', ${JSON.stringify(AB_SPLASH_VISITED_KEY)});
62
+ }
63
+ // \u{1F6A9} last step: turn the Splash on
64
+ if (show) { try { d.documentElement.setAttribute(${JSON.stringify(AB_SPLASH_ATTRIBUTE)}, 'on'); } catch (e) {} }
65
+ })();`;
66
+ }
67
+ var abSplashGateScript = buildAbSplashGateScript();
68
+
69
+ // ab-splash/csp.ts
70
+ function getAbSplashGateSha256(script) {
71
+ return `'sha256-${createHash("sha256").update(script, "utf8").digest("base64")}'`;
72
+ }
73
+ function buildAbSplashGate(options = {}) {
74
+ const script = buildAbSplashGateScript(options);
75
+ return { script, sha256: getAbSplashGateSha256(script) };
76
+ }
77
+ var AB_SPLASH_GATE_SHA256 = getAbSplashGateSha256(abSplashGateScript);
78
+ export {
79
+ AB_SPLASH_GATE_SHA256,
80
+ buildAbSplashGate,
81
+ getAbSplashGateSha256
82
+ };
@@ -0,0 +1,60 @@
1
+ /*
2
+ * @license MIT
3
+ * ~~~~~~~~~~~~
4
+ * ab-nextjs-components
5
+ * ~~~~~~~~~~~~
6
+ * Copyright (c) 2026 Abraham Ukachi. The abElements Project.
7
+ *
8
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ * of this software and associated documentation files (the 'Software'), to deal
10
+ * in the Software without restriction, including without limitation the rights
11
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ * copies of the Software, and to permit persons to whom the Software is
13
+ * furnished to do so, subject to the following conditions:
14
+ *
15
+ * The above copyright notice and this permission notice shall be included in all
16
+ * copies or substantial portions of the Software.
17
+ *
18
+ * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ * SOFTWARE.
25
+
26
+ *
27
+ * @project: ab-nextjs-components
28
+ * @name: AbSplash - CSP hash helpers
29
+ * @file: ab-splash/csp.ts
30
+ * @type: TypeScript (Node only: use it in next.config / proxy, never in the browser)
31
+ * @authors: Abraham Ukachi <abrahamukachi@gmail.com>
32
+ */
33
+
34
+ /*
35
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
36
+ * MOTTO: We'll always do more 😜!!!
37
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
38
+ */
39
+
40
+ // 🔐 Node's crypto (sync sha256 for build-time CSP)
41
+ import { createHash } from 'node:crypto';
42
+ // 🚦 the gate script builders
43
+ import { abSplashGateScript, buildAbSplashGateScript, type AbSplashGateOptions } from './gate';
44
+
45
+ // 🔐 CSP source for an inline script: `'sha256-<base64>'`
46
+ export function getAbSplashGateSha256(script: string): string {
47
+ // 🧮 hash the exact bytes that go into the <script> tag
48
+ return `'sha256-${createHash('sha256').update(script, 'utf8').digest('base64')}'`;
49
+ }
50
+
51
+ // 🔐 build the script AND its CSP hash in one go (keep both from the same options!)
52
+ export function buildAbSplashGate(options: AbSplashGateOptions = {}): { script: string; sha256: string } {
53
+ // 🚦 the script
54
+ const script = buildAbSplashGateScript(options);
55
+ // 📦 script + hash
56
+ return { script, sha256: getAbSplashGateSha256(script) };
57
+ }
58
+
59
+ // 🔐 hash of the default `abSplashGateScript` (static CSP)
60
+ export const AB_SPLASH_GATE_SHA256: string = getAbSplashGateSha256(abSplashGateScript);
@@ -0,0 +1,216 @@
1
+ /*
2
+ * @license MIT
3
+ * ~~~~~~~~~~~~
4
+ * ab-nextjs-components
5
+ * ~~~~~~~~~~~~
6
+ * Copyright (c) 2026 Abraham Ukachi. The abElements Project.
7
+ *
8
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ * of this software and associated documentation files (the 'Software'), to deal
10
+ * in the Software without restriction, including without limitation the rights
11
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ * copies of the Software, and to permit persons to whom the Software is
13
+ * furnished to do so, subject to the following conditions:
14
+ *
15
+ * The above copyright notice and this permission notice shall be included in all
16
+ * copies or substantial portions of the Software.
17
+ *
18
+ * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ * SOFTWARE.
25
+
26
+ *
27
+ * @project: ab-nextjs-components
28
+ * @name: AbSplash - Gate script & timing helpers
29
+ * @file: ab-splash/gate.ts
30
+ * @type: TypeScript (isomorphic, no Node or DOM imports)
31
+ * @authors: Abraham Ukachi <abrahamukachi@gmail.com>
32
+ */
33
+
34
+ /*
35
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
36
+ * MOTTO: We'll always do more 😜!!!
37
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
38
+ */
39
+
40
+ // 🚩 the `<html>` data attribute that turns the Splash on (set by the gate script, before the first paint ⚡️)
41
+ export const AB_SPLASH_ATTRIBUTE = 'data-ab-splash';
42
+ // 👣 the localStorage key that remembers the first ever visit
43
+ export const AB_SPLASH_VISITED_KEY = 'ab-splash-visited';
44
+ // 📱 the sessionStorage key that remembers the Splash was shown in this (standalone) session
45
+ export const AB_SPLASH_SESSION_KEY = 'ab-splash-session';
46
+ // ⏱️ the Splash never stays longer than this (in ms)
47
+ export const AB_SPLASH_MAX_MS = 1200;
48
+ // 🌫️ the fade-out duration (in ms), when motion is allowed
49
+ export const AB_SPLASH_FADE_MS = 200;
50
+ // 🤖 user agents that never get a Splash (crawlers, previews, headless tools)
51
+ export const AB_SPLASH_BOT_PATTERN = 'bot|crawl|spider|slurp|bingpreview|facebookexternalhit|embedly|quora link preview|whatsapp|telegrambot|discordbot|headlesschrome|lighthouse|pagespeed|prerender';
52
+
53
+ // ⚙️ gate options
54
+ export interface AbSplashGateOptions {
55
+ // 🌍 every configured locale (e.g. ['en', 'fr', 'es', 'ru'])
56
+ locales?: readonly string[];
57
+ // 🏠 the default locale (served without a prefix, so `/en` is NOT a root page)
58
+ defaultLocale?: string;
59
+ // 🚪 extra "root" sub-paths that also show the Splash on first visit (default: ['welcome'])
60
+ rootSubPaths?: readonly string[];
61
+ }
62
+
63
+ // 🛡️ escape a string for use inside a RegExp
64
+ const escapeRe = (value: string): string => value.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&');
65
+
66
+ /**
67
+ * 🧭 Build the root-path RegExp source from the configured locales minus the default.
68
+ * e.g. locales en/fr/es/ru + default en → `^(?:\/(?:fr|es|ru))?(?:\/(?:welcome))?\/?$`
69
+ */
70
+ export function buildAbSplashRootPathPattern(options: AbSplashGateOptions = {}): string {
71
+ // 🌍 prefixed locales only (the default one has no prefix)
72
+ const prefixed = (options.locales ?? []).filter((locale) => locale && locale !== options.defaultLocale).map(escapeRe);
73
+ // 🚪 sub-paths (welcome by default)
74
+ const subPaths = (options.rootSubPaths ?? ['welcome']).filter(Boolean).map(escapeRe);
75
+ // 🧱 optional locale prefix
76
+ const localePart = prefixed.length ? `(?:\\/(?:${prefixed.join('|')}))?` : '';
77
+ // 🧱 optional sub-path
78
+ const subPart = subPaths.length ? `(?:\\/(?:${subPaths.join('|')}))?` : '';
79
+ // 🏁 anchored pattern (optional trailing slash)
80
+ return `^${localePart}${subPart}\\/?$`;
81
+ }
82
+
83
+ /*
84
+ * 💡 Why the gate does its own storage + the CSS does its own fade/failsafe:
85
+ * this script runs inline in <head>, BEFORE React hydrates. Hooks (useAbLocalStorage)
86
+ * and ab-nextjs-animations can't run there yet, and waiting for them would flash the page.
87
+ * So it stays tiny, dependency-free ES5 with its own try-wrapped storage access.
88
+ */
89
+
90
+ /**
91
+ * 🚦 Build the inline gate script (runs in <head>, before the first paint).
92
+ * Inject with `<script dangerouslySetInnerHTML={{ __html: script }} />` and allow its hash in the CSP
93
+ * (see `getAbSplashGateSha256` in `ab-nextjs-components/ab-splash/csp`).
94
+ */
95
+ export function buildAbSplashGateScript(options: AbSplashGateOptions = {}): string {
96
+ // 🧭 root-path pattern, injected as a JSON string literal (never raw-concatenated)
97
+ const pattern = JSON.stringify(buildAbSplashRootPathPattern(options));
98
+ // 🤖 bot pattern, also JSON-injected
99
+ const bots = JSON.stringify(AB_SPLASH_BOT_PATTERN);
100
+ // 🧾 the script (plain ES5, comments allowed: newlines are kept)
101
+ return `(function () {
102
+ // 🧭 shortcuts
103
+ var w = window, d = document, nav = w.navigator || {};
104
+ // 🤖 skip automation and bots (no Splash for crawlers, previews or headless tests)
105
+ if (nav.webdriver || new RegExp(${bots}, 'i').test(nav.userAgent || '')) return;
106
+ // 📖 safe storage read: a failing get counts as "already seen" ('1')
107
+ function get(store, key) { try { return w[store].getItem(key); } catch (e) { return '1'; } }
108
+ // ✍️ safe storage write: a failing set is ignored
109
+ function set(store, key) { try { w[store].setItem(key, '1'); } catch (e) {} }
110
+ // 📱 installed PWA? (matchMedia OR iOS navigator.standalone)
111
+ var mm = false;
112
+ try { mm = !!(w.matchMedia && w.matchMedia('(display-mode: standalone)').matches); } catch (e) {}
113
+ var isStandalone = mm || nav.standalone === true;
114
+ // 🏠 a root page? (/, /<locale>, /welcome, /<locale>/welcome — never the default locale prefix)
115
+ var isRootPage = false;
116
+ try { isRootPage = new RegExp(${pattern}).test(w.location.pathname); } catch (e) {}
117
+ // 🚦 decision
118
+ var show = false;
119
+ // 📱 standalone: once per session
120
+ if (isStandalone) {
121
+ if (!get('sessionStorage', ${JSON.stringify(AB_SPLASH_SESSION_KEY)})) show = true;
122
+ set('sessionStorage', ${JSON.stringify(AB_SPLASH_SESSION_KEY)});
123
+ }
124
+ // 👣 first ever visit, on a root page only
125
+ if (!get('localStorage', ${JSON.stringify(AB_SPLASH_VISITED_KEY)})) {
126
+ if (isRootPage) show = true;
127
+ set('localStorage', ${JSON.stringify(AB_SPLASH_VISITED_KEY)});
128
+ }
129
+ // 🚩 last step: turn the Splash on
130
+ if (show) { try { d.documentElement.setAttribute(${JSON.stringify(AB_SPLASH_ATTRIBUTE)}, 'on'); } catch (e) {} }
131
+ })();`;
132
+ }
133
+
134
+ // 🚦 the default gate script (no locale prefixes; build your own with `buildAbSplashGateScript`)
135
+ export const abSplashGateScript: string = buildAbSplashGateScript();
136
+
137
+ // ⏳ time left before the cap (never below 0, never above the max)
138
+ export function getAbSplashRemainingMs(elapsedMs: number, maxMs: number = AB_SPLASH_MAX_MS): number {
139
+ return Math.min(maxMs, Math.max(0, maxMs - elapsedMs));
140
+ }
141
+
142
+ // 🌫️ fade duration (0 with reduced motion ♿)
143
+ export function getAbSplashFadeMs(prefersReducedMotion: boolean): number {
144
+ return prefersReducedMotion ? 0 : AB_SPLASH_FADE_MS;
145
+ }
146
+
147
+ // 🏁 resolve when fonts are ready or the timeout hits, whichever comes first
148
+ export function waitForAbSplashReady(fontsReady: Promise<unknown> | undefined, timeoutMs: number): Promise<'fonts' | 'timeout'> {
149
+ // ⏱️ the timeout racer
150
+ const timeout = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), timeoutMs));
151
+ // 🤷 no Font Loading API → timeout only
152
+ if (!fontsReady) return timeout;
153
+ // 🔤 a failing font load counts as ready too
154
+ const fonts = fontsReady.then(() => 'fonts' as const, () => 'fonts' as const);
155
+ // 🏁 first one wins
156
+ return Promise.race([fonts, timeout]);
157
+ }
158
+
159
+ // 🙈 minimal element shape used by the inert helper (keeps it testable without a DOM)
160
+ export interface AbInertNode {
161
+ parentElement: AbInertNode | null;
162
+ children: ArrayLike<AbInertNode>;
163
+ inert?: boolean;
164
+ tagName?: string;
165
+ }
166
+
167
+ /**
168
+ * 🙈 Make everything behind `overlay` inert (siblings on the path up to <body>).
169
+ * Returns a restore function that only un-inerts what it changed.
170
+ */
171
+ export function setAbSplashBackgroundInert(overlay: AbInertNode): () => void {
172
+ // 📝 nodes we changed
173
+ const changed: AbInertNode[] = [];
174
+ // ⬆️ walk up from the overlay to <body>
175
+ let node: AbInertNode | null = overlay;
176
+ while (node && node.parentElement) {
177
+ // 👪 the parent
178
+ const parent: AbInertNode = node.parentElement;
179
+ // 🔁 every sibling of the current node
180
+ for (const sibling of Array.from(parent.children)) {
181
+ // ✅ skip the overlay's own branch, scripts/styles and already-inert nodes
182
+ if (sibling === node || sibling.inert || /^(SCRIPT|STYLE|LINK|TEMPLATE)$/.test(sibling.tagName ?? '')) continue;
183
+ // 🙈 make it inert
184
+ sibling.inert = true;
185
+ // 📝 remember it
186
+ changed.push(sibling);
187
+ }
188
+ // 🛑 stop at <body>
189
+ if ((parent.tagName ?? '').toUpperCase() === 'BODY') break;
190
+ // ⬆️ go up
191
+ node = parent;
192
+ }
193
+ // ↩️ restore
194
+ return () => { for (const el of changed) el.inert = false; };
195
+ }
196
+
197
+ // ⏲️ pending teardown (module-level: survives React StrictMode's dev unmount → remount)
198
+ let abSplashPendingFinish: ReturnType<typeof setTimeout> | undefined
199
+
200
+ /**
201
+ * 🔁 Schedule the final teardown (flag off + inert restored) on the next task instead of running it
202
+ * synchronously in an effect cleanup. StrictMode (dev) unmounts and immediately remounts: the remount
203
+ * cancels this with `cancelAbSplashFinish()`, so the Splash isn't killed by its own cleanup.
204
+ */
205
+ export function scheduleAbSplashFinish(finish: () => void, delayMs: number = 0): void {
206
+ cancelAbSplashFinish() // 🧹 only one pending teardown
207
+ abSplashPendingFinish = setTimeout(() => { abSplashPendingFinish = undefined; finish() }, delayMs) // ⏲️
208
+ }
209
+
210
+ /** 🛑 Cancel a pending teardown; returns true when one was pending (i.e. this is a remount). */
211
+ export function cancelAbSplashFinish(): boolean {
212
+ if (abSplashPendingFinish === undefined) return false // 😴 nothing pending
213
+ clearTimeout(abSplashPendingFinish) // 🛑 cancel
214
+ abSplashPendingFinish = undefined // 🧹 reset
215
+ return true // 🔁 remount
216
+ }
@@ -0,0 +1,138 @@
1
+ /*
2
+ * @license MIT
3
+ * ~~~~~~~~~~~~
4
+ * ab-nextjs-components
5
+ * ~~~~~~~~~~~~
6
+ * Copyright (c) 2026 Abraham Ukachi. The abElements Project.
7
+ *
8
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ * of this software and associated documentation files (the 'Software'), to deal
10
+ * in the Software without restriction, including without limitation the rights
11
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ * copies of the Software, and to permit persons to whom the Software is
13
+ * furnished to do so, subject to the following conditions:
14
+ *
15
+ * The above copyright notice and this permission notice shall be included in all
16
+ * copies or substantial portions of the Software.
17
+ *
18
+ * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ * SOFTWARE.
25
+
26
+ *
27
+ * @project: ab-nextjs-components
28
+ * @name: AbSplash - Client
29
+ * @file: ab-splash/index.tsx
30
+ * @type: Client Component
31
+ * @authors: Abraham Ukachi <abrahamukachi@gmail.com>
32
+ */
33
+
34
+ /*
35
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
36
+ * MOTTO: We'll always do more 😜!!!
37
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
38
+ */
39
+
40
+ 'use client'; // 💧 hides itself after hydration
41
+
42
+ // ⚛️ React hooks
43
+ import { useEffect, useRef, useState, type ReactElement } from 'react';
44
+ // 🧵 class-name joiner
45
+ import clsx from 'clsx';
46
+ // 🅰️ the Ab mark + "Elements" wordmark (this package)
47
+ import AbLogo from '../ab-logo';
48
+ // 📶 the linear progress (this package)
49
+ import AbLinearProgress from '../ab-linear-progress';
50
+ // 🚦 gate constants & helpers
51
+ import {
52
+ AB_SPLASH_ATTRIBUTE,
53
+ getAbSplashFadeMs,
54
+ getAbSplashRemainingMs,
55
+ setAbSplashBackgroundInert,
56
+ scheduleAbSplashFinish,
57
+ cancelAbSplashFinish,
58
+ waitForAbSplashReady,
59
+ type AbInertNode,
60
+ } from './gate';
61
+ // 🎨 package-shipped styles
62
+ import styles from './styles.module.css';
63
+
64
+ // 📦 re-export the gate so `import { abSplashGateScript } from 'ab-nextjs-components/ab-splash'` works
65
+ export * from './gate';
66
+
67
+ // 📐 props
68
+ export interface AbSplashProps {
69
+ // 🏷️ consumer classes
70
+ className?: string;
71
+ // 🔤 wordmark text (default "Elements")
72
+ wordmark?: string;
73
+ // ⏱️ hard cap in ms (default 1200)
74
+ maxMs?: number;
75
+ }
76
+
77
+ /** 💦 AbSplash — brand overlay shown only when the gate script set `html[data-ab-splash]`. */
78
+ export default function AbSplash({ className, wordmark = 'Elements', maxMs }: AbSplashProps): ReactElement {
79
+ // 📶 progress (25% until hydration)
80
+ const [progress, setProgress] = useState<number>(25);
81
+ // 📌 the overlay element
82
+ const ref = useRef<HTMLDivElement>(null);
83
+
84
+ useEffect(() => {
85
+ // 🔁 StrictMode remount? cancel the teardown the previous cleanup scheduled (keeps the flag on)
86
+ cancelAbSplashFinish();
87
+ // 🌳 the <html> element
88
+ const root = document.documentElement;
89
+ // 😴 gate said no → nothing to do
90
+ if (root.getAttribute(AB_SPLASH_ATTRIBUTE) !== 'on') return;
91
+ // 🛟 unmount guard
92
+ let alive = true;
93
+ // ⏲️ fade timer
94
+ let fadeTimer: ReturnType<typeof setTimeout> | undefined;
95
+ // 🙈 make the page behind inert while we show
96
+ const restoreInert = ref.current ? setAbSplashBackgroundInert(ref.current as unknown as AbInertNode) : () => {};
97
+ // 👋 final cleanup: flag off + page interactive again
98
+ const finish = () => { root.removeAttribute(AB_SPLASH_ATTRIBUTE); restoreInert(); };
99
+ // ➡️ hydrated: move the progress on (next frame)
100
+ const frame = requestAnimationFrame(() => setProgress(60));
101
+ // ♿ reduced motion?
102
+ const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
103
+ // ⏳ time left before the cap
104
+ const remaining = getAbSplashRemainingMs(performance.now(), maxMs);
105
+ // 🔤 wait for fonts (or the cap), then hide
106
+ waitForAbSplashReady(document.fonts?.ready, remaining).then(() => {
107
+ // 🛟 unmounted meanwhile
108
+ if (!alive) return;
109
+ // ✅ fill the bar
110
+ setProgress(100);
111
+ // 🌫️ start the CSS fade
112
+ root.setAttribute(AB_SPLASH_ATTRIBUTE, 'leaving');
113
+ // 👋 remove for good (at once with reduced motion)
114
+ fadeTimer = setTimeout(finish, getAbSplashFadeMs(reduced));
115
+ });
116
+ // 🧹 cleanup
117
+ return () => {
118
+ alive = false; // 🛟 dead
119
+ cancelAnimationFrame(frame); // 🚫 frame
120
+ if (fadeTimer) clearTimeout(fadeTimer); // 🚫 timer
121
+ restoreInert(); // 🙉 page interactive again right away (a remount re-applies inert)
122
+ scheduleAbSplashFinish(finish); // 👋 remove data-ab-splash on the next task unless we remount (StrictMode)
123
+ };
124
+ }, [maxMs]);
125
+
126
+ return (
127
+ // 💦 the overlay (decorative → hidden from assistive tech)
128
+ <div ref={ref} className={clsx('AbSplash', styles.abSplash, className)} aria-hidden="true">
129
+ {/* 🅰️ mark + wordmark */}
130
+ <AbLogo wordmark={wordmark} alt="" className={styles.abSplash__brand} />
131
+ {/* 📶 progress */}
132
+ <AbLinearProgress className={styles.abSplash__progress} progress={progress} label="" />
133
+ </div>
134
+ );
135
+ }
136
+
137
+ // 📦 named export too
138
+ export { AbSplash };
@@ -0,0 +1,106 @@
1
+ /*
2
+ * @license MIT
3
+ * ~~~~~~~~~~~~
4
+ * ab-nextjs-components
5
+ * ~~~~~~~~~~~~
6
+ * Copyright (c) 2026 Abraham Ukachi. The abElements Project.
7
+ *
8
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ * of this software and associated documentation files (the 'Software'), to deal
10
+ * in the Software without restriction, including without limitation the rights
11
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ * copies of the Software, and to permit persons to whom the Software is
13
+ * furnished to do so, subject to the following conditions:
14
+ *
15
+ * The above copyright notice and this permission notice shall be included in all
16
+ * copies or substantial portions of the Software.
17
+ *
18
+ * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ * SOFTWARE.
25
+
26
+ *
27
+ * @project: ab-nextjs-components
28
+ * @name: AbSplash - Component Stylesheet (plain CSS, Tailwind-independent)
29
+ * @file: ab-splash/styles.module.css
30
+ * @type: CSS Module
31
+ * @authors: Abraham Ukachi <abrahamukachi@gmail.com>
32
+ */
33
+
34
+ /*
35
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
36
+ * MOTTO: We'll always do more 😜!!!
37
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
38
+ */
39
+
40
+ /* 💦 overlay: hidden by default (JS off = no Splash 🙈) */
41
+ .abSplash {
42
+ display: none; /* 🙈 off unless the gate turned it on */
43
+ position: fixed; /* 📌 cover the viewport */
44
+ inset: 0; /* 📐 edge to edge */
45
+ z-index: 1000; /* 🔝 above everything */
46
+ flex-direction: column; /* ⬇️ brand above progress */
47
+ align-items: center; /* ↔️ centered */
48
+ justify-content: center; /* ↕️ centered */
49
+ gap: 2.25rem; /* ↕️ brand ↔ bar */
50
+ background-color: #FFF8F3; /* ☀️ light surface (matches the splash mocks) */
51
+ color: #A67C52; /* 🎨 bronze brand */
52
+ transition: opacity 200ms ease; /* 🌫️ fade-out */
53
+ animation: abSplashFailsafe 0s linear 1.2s forwards; /* ⏱️ never longer than 1.2 s, even without hydration */
54
+ }
55
+
56
+ /* 🌙 dark */
57
+ :global(html.dark) .abSplash {
58
+ background-color: #1A1310; /* 🌙 dark surface (matches the splash mocks) */
59
+ color: #FFF8F5; /* 🤍 light brand */
60
+ }
61
+
62
+ /* 🚦 gate said "on" (or leaving) */
63
+ :global(html[data-ab-splash]) .abSplash {
64
+ display: flex;
65
+ }
66
+
67
+ /* 🔒 no scrolling behind the overlay — with the same 1.2 s failsafe as the overlay itself */
68
+ :global(html[data-ab-splash='on']) {
69
+ overflow: hidden; /* 🔒 lock */
70
+ animation: abSplashUnlock 0s linear 1.2s forwards; /* ⏱️ unlock even if hydration never happens */
71
+ }
72
+
73
+ /* ⏱️ failsafe unlock keyframes (overflow animates discretely) */
74
+ @keyframes abSplashUnlock {
75
+ to { overflow: visible; }
76
+ }
77
+
78
+ /* 👋 leaving: fade out */
79
+ :global(html[data-ab-splash='leaving']) .abSplash {
80
+ opacity: 0;
81
+ pointer-events: none;
82
+ }
83
+
84
+ /* 🅰️ brand lockup size */
85
+ .abSplash__brand {
86
+ font-size: clamp(2.5rem, 6vw, 3.25rem); /* 📏 as in the mock */
87
+ color: inherit; /* 🎨 follow light/dark */
88
+ --ab-logo-color: currentColor; /* 🎨 mark + wordmark follow the overlay color */
89
+ }
90
+
91
+ /* 📶 progress track */
92
+ .abSplash__progress {
93
+ width: min(18rem, 28vw); /* ↔️ mobile + laptop, as in the mock */
94
+ --ab-linear-progress-height: 0.375rem; /* ↕️ 6 px */
95
+ --ab-linear-progress-color: #A67C52; /* 🎨 bronze bar in both themes */
96
+ }
97
+
98
+ /* ♿ reduced motion: no fade */
99
+ @media (prefers-reduced-motion: reduce) {
100
+ .abSplash { transition: none; }
101
+ }
102
+
103
+ /* ⏱️ failsafe keyframes */
104
+ @keyframes abSplashFailsafe {
105
+ to { visibility: hidden; pointer-events: none; }
106
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 24 24"><path fill="#A67C52" d="m1.8 20.5 9.3-17v17zM22.2 7.7l-9.3-4.2v17l9.3-4.2-4.7-4.3z"/><path d="m12.9 20.5 9.3-4.2-9.3-8.6z" opacity=".1"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><style>@keyframes spinner_grm3{0%,50%{animation-timing-function:cubic-bezier(.27,.42,.37,.99);r:1px}25%{animation-timing-function:cubic-bezier(.53,0,.61,.73);r:2px}}@keyframes spinner_T3O6{0%{transform:rotate(360deg)}}.spinner_EUy1{animation:spinner_grm3 1.2s infinite}.spinner_f6oS{animation-delay:.1s}.spinner_g3nX{animation-delay:.2s}.spinner_nvEs{animation-delay:.3s}.spinner_MaNM{animation-delay:.4s}.spinner_4nle{animation-delay:.5s}.spinner_ZETM{animation-delay:.6s}.spinner_HXuO{animation-delay:.7s}.spinner_YaQo{animation-delay:.8s}.spinner_GOx1{animation-delay:.9s}.spinner_4vv9{animation-delay:1s}.spinner_NTs9{animation-delay:1.1s}.spinner_auJJ{transform-origin:center;animation:spinner_T3O6 6s linear infinite}</style><g class="spinner_auJJ"><circle cx="12" cy="3" r="1" class="spinner_EUy1"/><circle cx="16.5" cy="4.21" r="1" class="spinner_EUy1 spinner_f6oS"/><circle cx="7.5" cy="4.21" r="1" class="spinner_EUy1 spinner_NTs9"/><circle cx="19.79" cy="7.5" r="1" class="spinner_EUy1 spinner_g3nX"/><circle cx="4.21" cy="7.5" r="1" class="spinner_EUy1 spinner_4vv9"/><circle cx="21" cy="12" r="1" class="spinner_EUy1 spinner_nvEs"/><circle cx="3" cy="12" r="1" class="spinner_EUy1 spinner_GOx1"/><circle cx="19.79" cy="16.5" r="1" class="spinner_EUy1 spinner_MaNM"/><circle cx="4.21" cy="16.5" r="1" class="spinner_EUy1 spinner_YaQo"/><circle cx="16.5" cy="19.79" r="1" class="spinner_EUy1 spinner_4nle"/><circle cx="7.5" cy="19.79" r="1" class="spinner_EUy1 spinner_HXuO"/><circle cx="12" cy="21" r="1" class="spinner_EUy1 spinner_ZETM"/></g></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><style>@keyframes spinner_xe7Q{93.75%{r:3px}46.875%{r:.2px}}.spinner_b2T7{animation:spinner_xe7Q .8s linear infinite}.spinner_YRVV{animation-delay:-.65s}.spinner_c9oY{animation-delay:-.5s}</style><circle cx="4" cy="12" r="3" class="spinner_b2T7"/><circle cx="12" cy="12" r="3" class="spinner_b2T7 spinner_YRVV"/><circle cx="20" cy="12" r="3" class="spinner_b2T7 spinner_c9oY"/></svg>
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) Utkarsh Verma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.