react-theme-tide 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +198 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/options.d.ts +38 -0
- package/dist/options.js +53 -0
- package/dist/tide.d.ts +27 -0
- package/dist/tide.js +195 -0
- package/dist/use-theme-tide.d.ts +16 -0
- package/dist/use-theme-tide.js +18 -0
- package/dist/wave.d.ts +24 -0
- package/dist/wave.js +124 -0
- package/package.json +66 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alawonn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, 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,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,199 @@
|
|
|
1
|
-
#
|
|
1
|
+
# React Theme Tide
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Change your site's theme with a water fill that rises from the bottom of the screen. It stops at 60% for one second while the waves keep moving, then fills the rest of the screen.
|
|
4
|
+
|
|
5
|
+
What shows under the water is your real page in its new theme: text, backgrounds, borders and controls. It isn't a colored overlay. The reveal uses the browser's View Transitions API and a CSS mask, so it needs no screenshot library, no copy of your DOM and no React re-render on each frame.
|
|
6
|
+
|
|
7
|
+
React Theme Tide doesn't store your theme. You keep your own theme state and storage, and the library animates around the function that applies it.
|
|
8
|
+
|
|
9
|
+
> Status: first release (0.1). The API may still change before 1.0.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install react-theme-tide
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
React 18 or newer is a peer dependency. There's no stylesheet to import. While a tide runs, the library adds a small `<style>` element to `<head>` and removes it when the tide ends.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
Pass the function that changes your theme. It must update the DOM before it returns (or before its promise resolves), because the browser captures the new page right after it.
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
"use client";
|
|
25
|
+
import { useThemeTide } from "react-theme-tide";
|
|
26
|
+
|
|
27
|
+
export function ThemeToggle() {
|
|
28
|
+
const tide = useThemeTide();
|
|
29
|
+
const toggle = () =>
|
|
30
|
+
tide.run(() => {
|
|
31
|
+
const dark = document.documentElement.classList.toggle("dark");
|
|
32
|
+
localStorage.setItem("theme", dark ? "dark" : "light");
|
|
33
|
+
});
|
|
34
|
+
return (
|
|
35
|
+
<button type="button" onClick={toggle} aria-label="Toggle theme">
|
|
36
|
+
Toggle theme
|
|
37
|
+
</button>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Without React, call `themeTide` directly:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { themeTide } from "react-theme-tide";
|
|
46
|
+
|
|
47
|
+
themeTide(() => document.documentElement.classList.toggle("dark"));
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Wrap a theme setter you already have
|
|
51
|
+
|
|
52
|
+
If your site already has a function that applies and saves the theme, pass it in unchanged:
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
"use client";
|
|
56
|
+
import { useThemeTide } from "react-theme-tide";
|
|
57
|
+
import { setAppearance } from "@/lib/appearance"; // your existing setter
|
|
58
|
+
|
|
59
|
+
export function AppearanceButtons() {
|
|
60
|
+
const tide = useThemeTide();
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<button type="button" onClick={() => tide.run(() => setAppearance("light"))}>Light</button>
|
|
64
|
+
<button type="button" onClick={() => tide.run(() => setAppearance("dark"))}>Dark</button>
|
|
65
|
+
</>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Your storage key, your pre-paint script and your other controls keep working as before. Every control can use its own `useThemeTide()`; they all see the same `running` state.
|
|
71
|
+
|
|
72
|
+
### Theme held in React state
|
|
73
|
+
|
|
74
|
+
React state updates are asynchronous, so wrap them in `flushSync`. That way the DOM has the new theme when the callback returns:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
import { flushSync } from "react-dom";
|
|
78
|
+
|
|
79
|
+
tide.run(() => flushSync(() => setTheme("dark")));
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This applies to theme libraries that keep state in React context, too.
|
|
83
|
+
|
|
84
|
+
## Next.js App Router
|
|
85
|
+
|
|
86
|
+
Use the hook in a Client Component (a file that starts with `"use client"`). Only the hook's module is marked `"use client"`. The rest of the package doesn't touch `window` or `document` when it's imported, so Server Components can import the defaults and helpers. On the server, `supportsThemeTide()` returns `false`.
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
// app/theme-toggle.tsx
|
|
90
|
+
"use client";
|
|
91
|
+
import { useThemeTide } from "react-theme-tide";
|
|
92
|
+
|
|
93
|
+
export function ThemeToggle() {
|
|
94
|
+
const { run, running } = useThemeTide();
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
disabled={running}
|
|
99
|
+
onClick={() => run(() => document.documentElement.classList.toggle("dark"))}
|
|
100
|
+
>
|
|
101
|
+
Switch theme
|
|
102
|
+
</button>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
// app/layout.tsx (a Server Component)
|
|
109
|
+
import { ThemeToggle } from "./theme-toggle";
|
|
110
|
+
|
|
111
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
112
|
+
return (
|
|
113
|
+
<html lang="en" suppressHydrationWarning>
|
|
114
|
+
<body>
|
|
115
|
+
<ThemeToggle />
|
|
116
|
+
{children}
|
|
117
|
+
</body>
|
|
118
|
+
</html>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Apply the saved theme before paint the way you do now, for example with an inline script in `<head>`. The tide only runs when someone changes the theme, never on first load or during hydration.
|
|
124
|
+
|
|
125
|
+
## Options
|
|
126
|
+
|
|
127
|
+
All options are optional. Pass them to `useThemeTide(options)`, to `run(commit, options)` (which overrides the hook's options) or to `themeTide(commit, options)`.
|
|
128
|
+
|
|
129
|
+
| Option | Default | Range | What it does |
|
|
130
|
+
| --- | --- | --- | --- |
|
|
131
|
+
| `riseDuration` | `900` | 0 to 10000 ms | Time to rise from the bottom edge to the hold level. |
|
|
132
|
+
| `holdDuration` | `1000` | 0 to 10000 ms | Time the water stays at the hold level. The waves keep moving. |
|
|
133
|
+
| `finishDuration` | `700` | 0 to 10000 ms | Time to rise from the hold level past the top edge. |
|
|
134
|
+
| `holdLevel` | `0.6` | 0 to 1 | How full the screen is during the hold. `0.6` puts the mean waterline 40% down from the top. |
|
|
135
|
+
| `waveAmplitude` | `12` | 0 to 80 px | Height of a crest above the mean waterline. `0` gives a flat line. |
|
|
136
|
+
| `waveLength` | `240` | 40 to 4000 px | Distance between crests. |
|
|
137
|
+
| `waveSpeed` | `1` | 0 to 5 | Crests that pass a fixed point each second. |
|
|
138
|
+
| `respectReducedMotion` | `true` | boolean | Change the theme instantly when the user's system asks for reduced motion. |
|
|
139
|
+
| `nonce` | none | string | Nonce for the temporary `<style>` element, for pages with a Content Security Policy. |
|
|
140
|
+
|
|
141
|
+
A value outside its range, or of the wrong type, makes the call reject with a `RangeError` or `TypeError`. The theme still changes, instantly, so a bad option never breaks the toggle. To check options ahead of time, call `resolveThemeTideOptions(options)`, which throws the same errors.
|
|
142
|
+
|
|
143
|
+
## How it behaves
|
|
144
|
+
|
|
145
|
+
- **Coordinates.** The water is measured against the visible screen, not the document. It always starts at the bottom edge of the screen, however long the page is or however far it's scrolled. Scroll position and focus stay as they were.
|
|
146
|
+
- **Both directions.** Light to dark and dark to light both fill upward.
|
|
147
|
+
- **Completion.** At the end, the troughs pass above the top edge and the fill reaches past both sides, so none of the old theme is left showing.
|
|
148
|
+
- **Your commit runs once.** Every call runs your function exactly once, whether the tide animates, falls back, finishes early or fails.
|
|
149
|
+
- **Another change during a tide.** The running tide jumps to its end, then the new theme applies instantly. Changes always apply in the order they were made.
|
|
150
|
+
- **Scrolling during a tide.** The water rushes to the top in about 0.2 s, with the waves still moving, and the page ends up where you scrolled. The old theme is a still snapshot, so it can't scroll along with the page.
|
|
151
|
+
- **Resizing during a tide.** The tide ends right away, because the snapshot no longer fits the window.
|
|
152
|
+
- **Input.** Keyboard input reaches the focused control during a tide. A mouse or touch press makes the water rush to the top, the same as scrolling. In Firefox the press also reaches the control under it. In Chromium it doesn't, because the browser sends pointer events to the root element during any View Transition, so the next press works as usual.
|
|
153
|
+
- **Instant fallback.** The theme changes with no animation when the browser lacks View Transitions or CSS masks, when reduced motion applies, when the tab is hidden, when every duration is `0`, or when another View Transition is already running (in browsers that report `document.activeViewTransition`).
|
|
154
|
+
- **Errors.** If your function throws or rejects, the returned promise rejects with that error. The animation and its temporary style are removed either way.
|
|
155
|
+
- **Unmounting.** Unmounting a component that started a tide doesn't stop the tide. Nothing stays behind once it ends.
|
|
156
|
+
|
|
157
|
+
The returned promise resolves to `{ animated: true }`, or to `{ animated: false, reason }`, where `reason` is one of `"reduced-motion"`, `"unsupported"`, `"hidden"`, `"zero-duration"`, `"busy"` or `"interrupted"`.
|
|
158
|
+
|
|
159
|
+
## API
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
themeTide(commit: () => void | Promise<void>, options?: ThemeTideOptions): Promise<ThemeTideResult>
|
|
163
|
+
useThemeTide(options?: ThemeTideOptions): { run, running, skip }
|
|
164
|
+
skipThemeTide(): void // jump a running tide to its end
|
|
165
|
+
isThemeTideRunning(): boolean
|
|
166
|
+
subscribeThemeTide(listener: () => void): () => void
|
|
167
|
+
supportsThemeTide(): boolean // false on the server
|
|
168
|
+
resolveThemeTideOptions(options?): ResolvedThemeTideOptions
|
|
169
|
+
themeTideDefaults, themeTideRanges
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Working with other View Transitions
|
|
173
|
+
|
|
174
|
+
The tide animates the page's root snapshot (`::view-transition-new(root)`). If your page gives other elements their own `view-transition-name`, those elements cross-fade on their own during a tide and aren't covered by the water. Starting another View Transition while a tide runs ends the tide early; the theme still changes.
|
|
175
|
+
|
|
176
|
+
## Browser support
|
|
177
|
+
|
|
178
|
+
The animation needs `document.startViewTransition`, `Element.animate` and CSS `mask-image`. Other browsers get the instant fallback.
|
|
179
|
+
|
|
180
|
+
Tested so far, through Playwright automation on Linux:
|
|
181
|
+
|
|
182
|
+
- **Chromium 156:** animation checked frame by frame from screenshots, at 1280 × 800 and in Pixel 7 emulation (412 × 839, device pixel ratio 2.625).
|
|
183
|
+
- **Firefox 157:** animation timing and mask geometry checked through computed styles, at 1280 × 800 and 390 × 844. Firefox screenshots taken through Playwright don't include the transition layer, so frames haven't been checked by eye in automation yet.
|
|
184
|
+
- **Safari and WebKit:** not tested yet.
|
|
185
|
+
|
|
186
|
+
Known limitation: on screens with a fractional device pixel ratio, Chromium sometimes leaves the top-right corner device pixel in the old theme during the last frames.
|
|
187
|
+
|
|
188
|
+
## Demo
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
npm install
|
|
192
|
+
npm run demo
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Then open http://localhost:5179. The demo has light and dark controls, sliders for every option, long scrolling content and fixed elements.
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { themeTide, skipThemeTide, isThemeTideRunning, subscribeThemeTide, supportsThemeTide, type ThemeTideCommit, type ThemeTideResult, type ThemeTideInstantReason, } from "./tide.js";
|
|
2
|
+
export { resolveThemeTideOptions, themeTideDefaults, themeTideRanges, type ThemeTideOptions, type ResolvedThemeTideOptions, } from "./options.js";
|
|
3
|
+
export { useThemeTide, type UseThemeTide } from "./use-theme-tide.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface ThemeTideOptions {
|
|
2
|
+
/** Time for the water to rise from the bottom edge to the hold level, in ms. */
|
|
3
|
+
riseDuration?: number;
|
|
4
|
+
/** Time the mean waterline stays at the hold level, in ms. Waves keep moving. */
|
|
5
|
+
holdDuration?: number;
|
|
6
|
+
/** Time for the water to rise from the hold level past the top edge, in ms. */
|
|
7
|
+
finishDuration?: number;
|
|
8
|
+
/** How full the viewport is during the hold, from 0 (empty) to 1 (full). */
|
|
9
|
+
holdLevel?: number;
|
|
10
|
+
/** Height of a wave crest above the mean waterline, in CSS pixels. */
|
|
11
|
+
waveAmplitude?: number;
|
|
12
|
+
/** Distance between two crests, in CSS pixels. */
|
|
13
|
+
waveLength?: number;
|
|
14
|
+
/** Crests that pass a fixed point each second. */
|
|
15
|
+
waveSpeed?: number;
|
|
16
|
+
/** Change the theme instantly when the user prefers reduced motion. */
|
|
17
|
+
respectReducedMotion?: boolean;
|
|
18
|
+
/** Nonce for the temporary style element, for pages with a strict CSP. */
|
|
19
|
+
nonce?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ResolvedThemeTideOptions {
|
|
22
|
+
riseDuration: number;
|
|
23
|
+
holdDuration: number;
|
|
24
|
+
finishDuration: number;
|
|
25
|
+
holdLevel: number;
|
|
26
|
+
waveAmplitude: number;
|
|
27
|
+
waveLength: number;
|
|
28
|
+
waveSpeed: number;
|
|
29
|
+
respectReducedMotion: boolean;
|
|
30
|
+
nonce: string | undefined;
|
|
31
|
+
}
|
|
32
|
+
type NumericKey = Exclude<keyof ResolvedThemeTideOptions, "respectReducedMotion" | "nonce">;
|
|
33
|
+
export declare const themeTideDefaults: Readonly<ResolvedThemeTideOptions>;
|
|
34
|
+
export declare const themeTideRanges: Readonly<Record<NumericKey, readonly [min: number, max: number]>>;
|
|
35
|
+
/** Fills in defaults and throws a RangeError or TypeError for bad values. */
|
|
36
|
+
export declare function resolveThemeTideOptions(options?: ThemeTideOptions): ResolvedThemeTideOptions;
|
|
37
|
+
export declare function totalDuration(options: ResolvedThemeTideOptions): number;
|
|
38
|
+
export {};
|
package/dist/options.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const themeTideDefaults = Object.freeze({
|
|
2
|
+
riseDuration: 900,
|
|
3
|
+
holdDuration: 1000,
|
|
4
|
+
finishDuration: 700,
|
|
5
|
+
holdLevel: 0.6,
|
|
6
|
+
waveAmplitude: 12,
|
|
7
|
+
waveLength: 240,
|
|
8
|
+
waveSpeed: 1,
|
|
9
|
+
respectReducedMotion: true,
|
|
10
|
+
nonce: undefined,
|
|
11
|
+
});
|
|
12
|
+
export const themeTideRanges = Object.freeze({
|
|
13
|
+
riseDuration: [0, 10000],
|
|
14
|
+
holdDuration: [0, 10000],
|
|
15
|
+
finishDuration: [0, 10000],
|
|
16
|
+
holdLevel: [0, 1],
|
|
17
|
+
waveAmplitude: [0, 80],
|
|
18
|
+
waveLength: [40, 4000],
|
|
19
|
+
waveSpeed: [0, 5],
|
|
20
|
+
});
|
|
21
|
+
/** Fills in defaults and throws a RangeError or TypeError for bad values. */
|
|
22
|
+
export function resolveThemeTideOptions(options = {}) {
|
|
23
|
+
const resolved = { ...themeTideDefaults };
|
|
24
|
+
for (const key of Object.keys(themeTideRanges)) {
|
|
25
|
+
const value = options[key];
|
|
26
|
+
if (value === undefined)
|
|
27
|
+
continue;
|
|
28
|
+
const [min, max] = themeTideRanges[key];
|
|
29
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
30
|
+
throw new TypeError(`themeTide: ${key} must be a finite number.`);
|
|
31
|
+
}
|
|
32
|
+
if (value < min || value > max) {
|
|
33
|
+
throw new RangeError(`themeTide: ${key} must be between ${min} and ${max}, got ${value}.`);
|
|
34
|
+
}
|
|
35
|
+
resolved[key] = value;
|
|
36
|
+
}
|
|
37
|
+
if (options.respectReducedMotion !== undefined) {
|
|
38
|
+
if (typeof options.respectReducedMotion !== "boolean") {
|
|
39
|
+
throw new TypeError("themeTide: respectReducedMotion must be a boolean.");
|
|
40
|
+
}
|
|
41
|
+
resolved.respectReducedMotion = options.respectReducedMotion;
|
|
42
|
+
}
|
|
43
|
+
if (options.nonce !== undefined) {
|
|
44
|
+
if (typeof options.nonce !== "string") {
|
|
45
|
+
throw new TypeError("themeTide: nonce must be a string.");
|
|
46
|
+
}
|
|
47
|
+
resolved.nonce = options.nonce;
|
|
48
|
+
}
|
|
49
|
+
return resolved;
|
|
50
|
+
}
|
|
51
|
+
export function totalDuration(options) {
|
|
52
|
+
return options.riseDuration + options.holdDuration + options.finishDuration;
|
|
53
|
+
}
|
package/dist/tide.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ThemeTideOptions } from "./options.js";
|
|
2
|
+
/** Applies the new theme to the DOM. Called exactly once per themeTide call. */
|
|
3
|
+
export type ThemeTideCommit = () => void | Promise<void>;
|
|
4
|
+
export type ThemeTideInstantReason = "reduced-motion" | "unsupported" | "hidden" | "zero-duration" | "busy" | "interrupted";
|
|
5
|
+
export type ThemeTideResult = {
|
|
6
|
+
animated: true;
|
|
7
|
+
} | {
|
|
8
|
+
animated: false;
|
|
9
|
+
reason: ThemeTideInstantReason;
|
|
10
|
+
};
|
|
11
|
+
/** True while a tide animation is on screen. */
|
|
12
|
+
export declare function isThemeTideRunning(): boolean;
|
|
13
|
+
/** Calls `listener` when a tide starts or ends. Returns an unsubscribe function. */
|
|
14
|
+
export declare function subscribeThemeTide(listener: () => void): () => void;
|
|
15
|
+
/** Jumps a running tide to its end. The theme commit still happens once. */
|
|
16
|
+
export declare function skipThemeTide(): void;
|
|
17
|
+
/** True when this browser can animate a tide. Always false on the server. */
|
|
18
|
+
export declare function supportsThemeTide(): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Runs `commit` to change the theme and reveals the new theme with a rising
|
|
21
|
+
* water fill. Falls back to an instant change when the browser can't animate
|
|
22
|
+
* it, the user prefers reduced motion, or another tide is running.
|
|
23
|
+
*
|
|
24
|
+
* `commit` must update the DOM before it returns or before its promise
|
|
25
|
+
* resolves. With React state, wrap the update in `flushSync`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function themeTide(commit: ThemeTideCommit, options?: ThemeTideOptions): Promise<ThemeTideResult>;
|
package/dist/tide.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { resolveThemeTideOptions, totalDuration, } from "./options.js";
|
|
2
|
+
import { maskDeclarations, maskKeyframes, rushKeyframes, } from "./wave.js";
|
|
3
|
+
const attribute = "data-theme-tide";
|
|
4
|
+
const pseudo = "::view-transition-new(root)";
|
|
5
|
+
/** How long the water takes to reach the top after a scroll or pointer press. */
|
|
6
|
+
const rushDuration = 200;
|
|
7
|
+
let active = null;
|
|
8
|
+
// Commits run one after another, in call order, even when they are async.
|
|
9
|
+
let commitChain = Promise.resolve();
|
|
10
|
+
const listeners = new Set();
|
|
11
|
+
function setActive(next) {
|
|
12
|
+
active = next;
|
|
13
|
+
for (const listener of listeners)
|
|
14
|
+
listener();
|
|
15
|
+
}
|
|
16
|
+
/** True while a tide animation is on screen. */
|
|
17
|
+
export function isThemeTideRunning() {
|
|
18
|
+
return active !== null;
|
|
19
|
+
}
|
|
20
|
+
/** Calls `listener` when a tide starts or ends. Returns an unsubscribe function. */
|
|
21
|
+
export function subscribeThemeTide(listener) {
|
|
22
|
+
listeners.add(listener);
|
|
23
|
+
return () => {
|
|
24
|
+
listeners.delete(listener);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Jumps a running tide to its end. The theme commit still happens once. */
|
|
28
|
+
export function skipThemeTide() {
|
|
29
|
+
active?.transition.skipTransition();
|
|
30
|
+
}
|
|
31
|
+
/** True when this browser can animate a tide. Always false on the server. */
|
|
32
|
+
export function supportsThemeTide() {
|
|
33
|
+
return (typeof document !== "undefined" &&
|
|
34
|
+
typeof document.startViewTransition === "function" &&
|
|
35
|
+
typeof Element.prototype.animate === "function" &&
|
|
36
|
+
typeof CSS !== "undefined" &&
|
|
37
|
+
CSS.supports("mask-image", "linear-gradient(#000, #000)"));
|
|
38
|
+
}
|
|
39
|
+
function prefersReducedMotion() {
|
|
40
|
+
return (typeof matchMedia === "function" &&
|
|
41
|
+
matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
42
|
+
}
|
|
43
|
+
function once(commit) {
|
|
44
|
+
let result;
|
|
45
|
+
return () => {
|
|
46
|
+
if (!result) {
|
|
47
|
+
const run = commitChain.then(() => commit());
|
|
48
|
+
commitChain = run.catch(() => undefined);
|
|
49
|
+
result = run;
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function instant(commit, reason) {
|
|
55
|
+
return commit().then(() => ({ animated: false, reason }));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Runs `commit` to change the theme and reveals the new theme with a rising
|
|
59
|
+
* water fill. Falls back to an instant change when the browser can't animate
|
|
60
|
+
* it, the user prefers reduced motion, or another tide is running.
|
|
61
|
+
*
|
|
62
|
+
* `commit` must update the DOM before it returns or before its promise
|
|
63
|
+
* resolves. With React state, wrap the update in `flushSync`.
|
|
64
|
+
*/
|
|
65
|
+
export function themeTide(commit, options) {
|
|
66
|
+
const run = once(commit);
|
|
67
|
+
let resolved;
|
|
68
|
+
try {
|
|
69
|
+
resolved = resolveThemeTideOptions(options);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
// A bad option never blocks the theme change.
|
|
73
|
+
return run().then(() => Promise.reject(error));
|
|
74
|
+
}
|
|
75
|
+
if (!supportsThemeTide())
|
|
76
|
+
return instant(run, "unsupported");
|
|
77
|
+
if (active) {
|
|
78
|
+
// A second change during a tide finishes the first one, then applies
|
|
79
|
+
// the new theme without animation once the first commit has run.
|
|
80
|
+
const previous = active.transition.updateCallbackDone.catch(() => undefined);
|
|
81
|
+
skipThemeTide();
|
|
82
|
+
return previous.then(() => instant(run, "interrupted"));
|
|
83
|
+
}
|
|
84
|
+
if (document.activeViewTransition)
|
|
85
|
+
return instant(run, "busy");
|
|
86
|
+
if (resolved.respectReducedMotion && prefersReducedMotion()) {
|
|
87
|
+
return instant(run, "reduced-motion");
|
|
88
|
+
}
|
|
89
|
+
if (document.visibilityState === "hidden")
|
|
90
|
+
return instant(run, "hidden");
|
|
91
|
+
if (totalDuration(resolved) === 0)
|
|
92
|
+
return instant(run, "zero-duration");
|
|
93
|
+
return animate(run, resolved);
|
|
94
|
+
}
|
|
95
|
+
function viewportSize() {
|
|
96
|
+
return { width: window.innerWidth, height: window.innerHeight };
|
|
97
|
+
}
|
|
98
|
+
/** Size of the new-page snapshot, which can differ from innerHeight on mobile. */
|
|
99
|
+
function snapshotSize(fallback) {
|
|
100
|
+
const style = getComputedStyle(document.documentElement, pseudo);
|
|
101
|
+
const width = parseFloat(style.width);
|
|
102
|
+
const height = parseFloat(style.height);
|
|
103
|
+
return width > 0 && height > 0 ? { width, height } : fallback;
|
|
104
|
+
}
|
|
105
|
+
function rules(options, viewport) {
|
|
106
|
+
const root = `:root[${attribute}]`;
|
|
107
|
+
return [
|
|
108
|
+
`${root}::view-transition { pointer-events: none; }`,
|
|
109
|
+
`${root}::view-transition-old(root), ${root}${pseudo} { animation: none; mix-blend-mode: normal; }`,
|
|
110
|
+
`${root}${pseudo} { ${maskDeclarations(options, viewport)}; }`,
|
|
111
|
+
].join("\n");
|
|
112
|
+
}
|
|
113
|
+
async function animate(run, options) {
|
|
114
|
+
const root = document.documentElement;
|
|
115
|
+
const style = document.createElement("style");
|
|
116
|
+
if (options.nonce)
|
|
117
|
+
style.nonce = options.nonce;
|
|
118
|
+
style.textContent = rules(options, viewportSize());
|
|
119
|
+
document.head.appendChild(style);
|
|
120
|
+
root.setAttribute(attribute, "");
|
|
121
|
+
let transition;
|
|
122
|
+
try {
|
|
123
|
+
transition = document.startViewTransition(run);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
style.remove();
|
|
127
|
+
root.removeAttribute(attribute);
|
|
128
|
+
return instant(run, "unsupported");
|
|
129
|
+
}
|
|
130
|
+
setActive({ transition });
|
|
131
|
+
let animation;
|
|
132
|
+
let viewport = viewportSize();
|
|
133
|
+
let interrupted = false;
|
|
134
|
+
const interrupt = () => {
|
|
135
|
+
interrupted = true;
|
|
136
|
+
transition.skipTransition();
|
|
137
|
+
};
|
|
138
|
+
// The old page is a still snapshot, so it can't scroll. On a scroll or a
|
|
139
|
+
// pointer press the water rushes to the top in rushDuration instead.
|
|
140
|
+
// Chromium sends pointer events to the root element during a transition,
|
|
141
|
+
// so controls can't receive them until the tide ends.
|
|
142
|
+
const rush = () => {
|
|
143
|
+
if (interrupted || !animation)
|
|
144
|
+
return;
|
|
145
|
+
const from = Number(animation.currentTime ?? 0);
|
|
146
|
+
if (totalDuration(options) - from <= rushDuration)
|
|
147
|
+
return;
|
|
148
|
+
interrupted = true;
|
|
149
|
+
const next = root.animate(rushKeyframes(options, viewport, from, rushDuration), {
|
|
150
|
+
duration: rushDuration,
|
|
151
|
+
easing: "linear",
|
|
152
|
+
fill: "forwards",
|
|
153
|
+
pseudoElement: pseudo,
|
|
154
|
+
});
|
|
155
|
+
animation.cancel();
|
|
156
|
+
animation = next;
|
|
157
|
+
};
|
|
158
|
+
const ready = transition.ready.then(() => {
|
|
159
|
+
viewport = snapshotSize(viewport);
|
|
160
|
+
animation = root.animate(maskKeyframes(options, viewport), {
|
|
161
|
+
duration: totalDuration(options),
|
|
162
|
+
easing: "linear",
|
|
163
|
+
fill: "forwards",
|
|
164
|
+
pseudoElement: pseudo,
|
|
165
|
+
});
|
|
166
|
+
window.addEventListener("scroll", rush, { passive: true });
|
|
167
|
+
window.addEventListener("pointerdown", rush, { capture: true });
|
|
168
|
+
// A resize changes the snapshot's size, so end the tide right away.
|
|
169
|
+
window.addEventListener("resize", interrupt);
|
|
170
|
+
}, () => undefined);
|
|
171
|
+
let failure;
|
|
172
|
+
try {
|
|
173
|
+
await transition.updateCallbackDone;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
failure = { error };
|
|
177
|
+
}
|
|
178
|
+
await ready;
|
|
179
|
+
await transition.finished.catch(() => undefined);
|
|
180
|
+
// A skipped transition still calls its update callback; `run` keeps this
|
|
181
|
+
// safe if a browser ever doesn't.
|
|
182
|
+
if (!failure)
|
|
183
|
+
await run().catch((error) => (failure = { error }));
|
|
184
|
+
const completed = !interrupted && animation?.playState === "finished";
|
|
185
|
+
window.removeEventListener("scroll", rush);
|
|
186
|
+
window.removeEventListener("pointerdown", rush, { capture: true });
|
|
187
|
+
window.removeEventListener("resize", interrupt);
|
|
188
|
+
animation?.cancel();
|
|
189
|
+
style.remove();
|
|
190
|
+
root.removeAttribute(attribute);
|
|
191
|
+
setActive(null);
|
|
192
|
+
if (failure)
|
|
193
|
+
throw failure.error;
|
|
194
|
+
return completed ? { animated: true } : { animated: false, reason: "interrupted" };
|
|
195
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ThemeTideOptions } from "./options.js";
|
|
2
|
+
import { type ThemeTideCommit, type ThemeTideResult } from "./tide.js";
|
|
3
|
+
export interface UseThemeTide {
|
|
4
|
+
/** Runs a tide around `commit`. Per-call options override the hook's options. */
|
|
5
|
+
run: (commit: ThemeTideCommit, options?: ThemeTideOptions) => Promise<ThemeTideResult>;
|
|
6
|
+
/** True while any tide on the page is animating. */
|
|
7
|
+
running: boolean;
|
|
8
|
+
/** Jumps a running tide to its end. */
|
|
9
|
+
skip: () => void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* React access to themeTide. The hook keeps no theme state of its own: your
|
|
13
|
+
* commit callback decides the theme and where it's saved. Unmounting doesn't
|
|
14
|
+
* stop a tide that's already running, since the transition belongs to the page.
|
|
15
|
+
*/
|
|
16
|
+
export declare function useThemeTide(options?: ThemeTideOptions): UseThemeTide;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
|
3
|
+
import { isThemeTideRunning, skipThemeTide, subscribeThemeTide, themeTide, } from "./tide.js";
|
|
4
|
+
const serverRunning = () => false;
|
|
5
|
+
/**
|
|
6
|
+
* React access to themeTide. The hook keeps no theme state of its own: your
|
|
7
|
+
* commit callback decides the theme and where it's saved. Unmounting doesn't
|
|
8
|
+
* stop a tide that's already running, since the transition belongs to the page.
|
|
9
|
+
*/
|
|
10
|
+
export function useThemeTide(options) {
|
|
11
|
+
const running = useSyncExternalStore(subscribeThemeTide, isThemeTideRunning, serverRunning);
|
|
12
|
+
const optionsRef = useRef(options);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
optionsRef.current = options;
|
|
15
|
+
});
|
|
16
|
+
const run = useCallback((commit, overrides) => themeTide(commit, { ...optionsRef.current, ...overrides }), []);
|
|
17
|
+
return { run, running, skip: skipThemeTide };
|
|
18
|
+
}
|
package/dist/wave.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ResolvedThemeTideOptions } from "./options.js";
|
|
2
|
+
export interface Viewport {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
}
|
|
6
|
+
export interface WaveTile {
|
|
7
|
+
/** A CSS url() holding an SVG tile filled below one wavelength of the wave. */
|
|
8
|
+
image: string;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function waveTile(amplitude: number, length: number): WaveTile;
|
|
13
|
+
/** Mean waterline, in pixels from the top, at `time` ms into the tide. */
|
|
14
|
+
export declare function waterlineAt(time: number, options: ResolvedThemeTideOptions, viewportHeight: number): number;
|
|
15
|
+
/** Horizontal offset of the wave tile at `time` ms. Grows without wrapping. */
|
|
16
|
+
export declare function wavePhaseAt(time: number, options: ResolvedThemeTideOptions): number;
|
|
17
|
+
/** Static mask properties. The starting position hides the whole new page. */
|
|
18
|
+
export declare function maskDeclarations(options: ResolvedThemeTideOptions, viewport: Viewport): string;
|
|
19
|
+
export declare function maskKeyframes(options: ResolvedThemeTideOptions, viewport: Viewport): Keyframe[];
|
|
20
|
+
/**
|
|
21
|
+
* Keyframes that take the water from where it is at `from` ms to the end of
|
|
22
|
+
* the tide in `duration` ms. The waves keep their normal speed.
|
|
23
|
+
*/
|
|
24
|
+
export declare function rushKeyframes(options: ResolvedThemeTideOptions, viewport: Viewport, from: number, duration: number): Keyframe[];
|
package/dist/wave.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { totalDuration } from "./options.js";
|
|
2
|
+
// All geometry is in CSS pixels of the viewport, with y measured down from
|
|
3
|
+
// the top edge. The waterline is the mean height of the wave; the wave phase
|
|
4
|
+
// only moves the crests sideways, so the level can hold while the water moves.
|
|
5
|
+
/** Keyframes per second. Positions between keyframes are interpolated linearly. */
|
|
6
|
+
const sampleRate = 60;
|
|
7
|
+
/** Points per wavelength in the SVG tile outline. */
|
|
8
|
+
const tileSegments = 32;
|
|
9
|
+
/**
|
|
10
|
+
* Transparent rows at the top and bottom of the tile. Chromium can wrap the
|
|
11
|
+
* tile vertically when it resamples at fractional device-pixel ratios; empty
|
|
12
|
+
* rows on both edges keep that from drawing a hairline above the crests.
|
|
13
|
+
*/
|
|
14
|
+
const tilePadding = 2;
|
|
15
|
+
function tileHeight(amplitude) {
|
|
16
|
+
return 2 * amplitude + 2 * tilePadding;
|
|
17
|
+
}
|
|
18
|
+
export function waveTile(amplitude, length) {
|
|
19
|
+
const height = tileHeight(amplitude);
|
|
20
|
+
const middle = amplitude + tilePadding;
|
|
21
|
+
const bottom = height - tilePadding;
|
|
22
|
+
let d = `M0 ${bottom}`;
|
|
23
|
+
for (let i = 0; i <= tileSegments; i++) {
|
|
24
|
+
const x = (i * length) / tileSegments;
|
|
25
|
+
const y = middle - amplitude * Math.sin((2 * Math.PI * x) / length);
|
|
26
|
+
d += ` L${round(x)} ${round(y)}`;
|
|
27
|
+
}
|
|
28
|
+
d += ` L${length} ${bottom} Z`;
|
|
29
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${length}" height="${height}"><path d="${d}"/></svg>`;
|
|
30
|
+
return {
|
|
31
|
+
image: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`,
|
|
32
|
+
width: length,
|
|
33
|
+
height,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function easeInOut(t) {
|
|
37
|
+
return -(Math.cos(Math.PI * t) - 1) / 2;
|
|
38
|
+
}
|
|
39
|
+
/** Mean waterline, in pixels from the top, at `time` ms into the tide. */
|
|
40
|
+
export function waterlineAt(time, options, viewportHeight) {
|
|
41
|
+
const { riseDuration, holdDuration, finishDuration, waveAmplitude } = options;
|
|
42
|
+
// Starts with the whole tile below the bottom edge and ends with every
|
|
43
|
+
// trough above the top edge. The extra 3 px at the end keep the corners
|
|
44
|
+
// covered on screens with fractional device pixels.
|
|
45
|
+
const start = viewportHeight + waveAmplitude + tilePadding;
|
|
46
|
+
const hold = viewportHeight * (1 - options.holdLevel);
|
|
47
|
+
const end = -(waveAmplitude + 3);
|
|
48
|
+
if (time <= 0)
|
|
49
|
+
return start;
|
|
50
|
+
if (time < riseDuration) {
|
|
51
|
+
return start + (hold - start) * easeInOut(time / riseDuration);
|
|
52
|
+
}
|
|
53
|
+
if (time <= riseDuration + holdDuration)
|
|
54
|
+
return hold;
|
|
55
|
+
const finishTime = time - riseDuration - holdDuration;
|
|
56
|
+
if (finishTime >= finishDuration)
|
|
57
|
+
return end;
|
|
58
|
+
return hold + (end - hold) * easeInOut(finishTime / finishDuration);
|
|
59
|
+
}
|
|
60
|
+
/** Horizontal offset of the wave tile at `time` ms. Grows without wrapping. */
|
|
61
|
+
export function wavePhaseAt(time, options) {
|
|
62
|
+
return (options.waveSpeed * options.waveLength * time) / 1000;
|
|
63
|
+
}
|
|
64
|
+
/** Height of the solid layer under the wave. Taller than any viewport position needs. */
|
|
65
|
+
function bodyHeight(options, viewport) {
|
|
66
|
+
return 2 * viewport.height + 4 * options.waveAmplitude + 8;
|
|
67
|
+
}
|
|
68
|
+
function maskPosition(time, options, viewport) {
|
|
69
|
+
return positionFor(waterlineAt(time, options, viewport.height), wavePhaseAt(time, options), options.waveAmplitude);
|
|
70
|
+
}
|
|
71
|
+
function positionFor(line, phase, amplitude) {
|
|
72
|
+
// The solid layer starts one pixel above the bottom of the tile's filled
|
|
73
|
+
// area and spills one pixel past each side, so no seam shows at fractional
|
|
74
|
+
// device pixels.
|
|
75
|
+
return `${round(phase)}px ${round(line - amplitude - tilePadding)}px, -1px ${round(line + amplitude - 1)}px`;
|
|
76
|
+
}
|
|
77
|
+
/** Static mask properties. The starting position hides the whole new page. */
|
|
78
|
+
export function maskDeclarations(options, viewport) {
|
|
79
|
+
const tile = waveTile(options.waveAmplitude, options.waveLength);
|
|
80
|
+
return [
|
|
81
|
+
`mask-image: ${tile.image}, linear-gradient(#000, #000)`,
|
|
82
|
+
"mask-repeat: repeat-x, no-repeat",
|
|
83
|
+
`mask-size: ${tile.width}px ${tile.height}px, calc(100% + 2px) ${bodyHeight(options, viewport)}px`,
|
|
84
|
+
`mask-position: ${maskPosition(0, options, viewport)}`,
|
|
85
|
+
"mask-mode: alpha",
|
|
86
|
+
"mask-clip: no-clip",
|
|
87
|
+
].join("; ");
|
|
88
|
+
}
|
|
89
|
+
export function maskKeyframes(options, viewport) {
|
|
90
|
+
const duration = totalDuration(options);
|
|
91
|
+
const steps = Math.max(1, Math.ceil((duration / 1000) * sampleRate));
|
|
92
|
+
// Phase boundaries are sampled exactly so the hold starts and ends on time.
|
|
93
|
+
const times = new Set([
|
|
94
|
+
options.riseDuration,
|
|
95
|
+
options.riseDuration + options.holdDuration,
|
|
96
|
+
]);
|
|
97
|
+
for (let i = 0; i <= steps; i++)
|
|
98
|
+
times.add((i / steps) * duration);
|
|
99
|
+
return [...times]
|
|
100
|
+
.sort((a, b) => a - b)
|
|
101
|
+
.map((time) => ({
|
|
102
|
+
offset: duration === 0 ? 0 : time / duration,
|
|
103
|
+
maskPosition: maskPosition(time, options, viewport),
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Keyframes that take the water from where it is at `from` ms to the end of
|
|
108
|
+
* the tide in `duration` ms. The waves keep their normal speed.
|
|
109
|
+
*/
|
|
110
|
+
export function rushKeyframes(options, viewport, from, duration) {
|
|
111
|
+
const start = waterlineAt(from, options, viewport.height);
|
|
112
|
+
const end = waterlineAt(totalDuration(options), options, viewport.height);
|
|
113
|
+
const steps = Math.max(1, Math.ceil((duration / 1000) * sampleRate));
|
|
114
|
+
return Array.from({ length: steps + 1 }, (_, i) => {
|
|
115
|
+
const t = i / steps;
|
|
116
|
+
return {
|
|
117
|
+
offset: t,
|
|
118
|
+
maskPosition: positionFor(start + (end - start) * easeInOut(t), wavePhaseAt(from + t * duration, options), options.waveAmplitude),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function round(value) {
|
|
123
|
+
return Math.round(value * 100) / 100;
|
|
124
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-theme-tide",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Theme changes that rise up the page like water, using native View Transitions.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/alawonn/react-theme-tide.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/alawonn/react-theme-tide#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/alawonn/react-theme-tide/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react",
|
|
18
|
+
"theme",
|
|
19
|
+
"dark-mode",
|
|
20
|
+
"view-transitions",
|
|
21
|
+
"animation"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"main": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"files": [
|
|
33
|
+
"dist/*.js",
|
|
34
|
+
"dist/*.d.ts"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
38
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p demo/tsconfig.json --noEmit",
|
|
39
|
+
"lint": "eslint .",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:browser": "playwright test",
|
|
42
|
+
"demo": "vite --config demo/vite.config.ts",
|
|
43
|
+
"demo:build": "vite build --config demo/vite.config.ts",
|
|
44
|
+
"prepack": "npm run build"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"react": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@eslint/js": "^10.0.1",
|
|
51
|
+
"@playwright/test": "^1.64.1",
|
|
52
|
+
"@types/node": "^24.19.2",
|
|
53
|
+
"@types/pngjs": "^6.0.5",
|
|
54
|
+
"@types/react": "^19.3.0",
|
|
55
|
+
"@types/react-dom": "^19.3.0",
|
|
56
|
+
"@vitejs/plugin-react": "^6.2.1",
|
|
57
|
+
"eslint": "^10.14.0",
|
|
58
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
59
|
+
"jsdom": "^30.1.3",
|
|
60
|
+
"pngjs": "^7.0.0",
|
|
61
|
+
"react": "^19.3.1",
|
|
62
|
+
"react-dom": "^19.3.1",
|
|
63
|
+
"typescript": "~6.0.3",
|
|
64
|
+
"typescript-eslint": "^8.72.1",
|
|
65
|
+
"vite": "^8.3.5",
|
|
66
|
+
"vitest": "^5.1.0"
|
|
67
|
+
}
|
|
68
|
+
}
|