diff --git a/apps/web/package.json b/apps/web/package.json index f1685b1..34f1d46 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@elysiajs/eden": "^1.2.0", "@lingui/core": "^5.2.0", "@lingui/react": "^5.2.0", + "@sinclair/typebox": "^0.34.28", "@workspace/schema": "workspace:*", "@workspace/ui": "workspace:*", "browser-fs-access": "^0.35.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5c8bdd5..77336ca 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -20,19 +20,17 @@ export function App() {
-
-
- - - - - - - - - - -
+
+ + + + + + + + + +
diff --git a/apps/web/src/atoms/areImagesReversedAtom.ts b/apps/web/src/atoms/areImagesReversedAtom.ts index e59fe41..0f2be88 100644 --- a/apps/web/src/atoms/areImagesReversedAtom.ts +++ b/apps/web/src/atoms/areImagesReversedAtom.ts @@ -1,8 +1,11 @@ +import { createJSONStorage } from 'jotai/utils'; +import { Type } from '@sinclair/typebox'; import { atomWithToggleAndStorage } from '@/utils/atomWithToggleAndStorage'; +import { withStorageValidator } from '@/utils/withStorageValidator'; export const areImagesReversedAtom = atomWithToggleAndStorage( 'areImagesReversed', false, - undefined, + withStorageValidator(Type.Boolean())(createJSONStorage()), { getOnInit: true } ); diff --git a/apps/web/src/atoms/imagesAtom.ts b/apps/web/src/atoms/imagesAtom.ts index 9442f92..e4b939e 100644 --- a/apps/web/src/atoms/imagesAtom.ts +++ b/apps/web/src/atoms/imagesAtom.ts @@ -1,33 +1,39 @@ import { atom } from 'jotai'; -import { atomWithStorage, RESET, unwrap } from 'jotai/utils'; -import { readFile } from '@/utils/readFile'; +import { atomWithStorage, unwrap } from 'jotai/utils'; +import { Type } from '@sinclair/typebox'; +import { readFileAsDataURL } from '@/utils/readFile'; import { loadImage } from '@/utils/loadImage'; -import { createIndexedDbStorage } from '@/utils/createIndexedDbStorage'; +import { createIdbKeyvalStorage } from '@/utils/createIdbKeyvalStorage'; +import { withStorageValidator } from '@/utils/withStorageValidator'; -let imageId = 0; +const ImageFileSchema = Type.Tuple([ + Type.String({ title: 'filename' }), + Type.Unsafe(Type.Any({ type: 'object' })) +]); -const filesAtom = atomWithStorage( - 'files', - [], - createIndexedDbStorage(), - { getOnInit: true } -); +const ImageFilesSchema = Type.Array(ImageFileSchema); + +export type ImageFile = typeof ImageFileSchema.static; -export const imagesAtom = atom( - async get => { - const files = await get(filesAtom); - return Promise.all( - files.map(async file => { - const dataUrl = (await readFile(r => r.readAsDataURL(file))) as string; - const image = await loadImage(dataUrl); - image.id = `${imageId++}`; - return image; - }) - ); - }, - (_get, set, files: Blob[] | typeof RESET) => { - set(filesAtom, files); +export const imageFilesAtom = atomWithStorage( + 'images', + [], + withStorageValidator(ImageFilesSchema)(createIdbKeyvalStorage()), + { + getOnInit: true } ); +export const imagesAtom = atom(async (get, options) => { + const imageFiles = await get(imageFilesAtom); + return Promise.all( + imageFiles.map(async ([id, file]) => { + const dataUrl = await readFileAsDataURL(file, options); + const image = await loadImage(dataUrl, options); + image.id = id; + return image; + }) + ); +}); + export const unwrappedImagesAtom = unwrap(imagesAtom, prev => prev ?? []); diff --git a/apps/web/src/atoms/importAtoms.ts b/apps/web/src/atoms/importAtoms.ts index 5011f8b..6ace456 100644 --- a/apps/web/src/atoms/importAtoms.ts +++ b/apps/web/src/atoms/importAtoms.ts @@ -2,9 +2,7 @@ import { atom } from 'jotai'; import { client } from '@/utils/client'; import { atomWithExpiringWriteState } from '@/utils/atomWithExpiringWriteState'; import { ImageSource } from '@/types'; -import { largestImageAtom } from './largestImageAtom'; -import { optimizeWaveformAtom } from './waveformAtoms'; -import { imagesAtom } from './imagesAtom'; +import { ImageFile, imageFilesAtom } from './imagesAtom'; const importAbortControllerAtom = atom(null); @@ -23,15 +21,15 @@ export const importAtom = atomWithExpiringWriteState( ? imageSource : [imageSource]; - const blobs = ( - await Promise.all( - imageSources.map(async source => { + const imageFiles = ( + await Promise.all( + imageSources.map>(async source => { if (source instanceof File) { - return [source]; + return [[source.name, source]]; } if (typeof source === 'string') { const response = await fetch(source, { signal }); - return [await response.blob()]; + return [[source, await response.blob()]]; } const { url, deviceType } = source; const { data, error } = await client @@ -44,12 +42,11 @@ export const importAtom = atomWithExpiringWriteState( throw error.value; } - return Object.values(data || {}); + return Object.entries(data); }) ) ).flat(); - set(imagesAtom, blobs); - set(optimizeWaveformAtom, await get(largestImageAtom)); + set(imageFilesAtom, imageFiles); } ); diff --git a/apps/web/src/atoms/waveAtom.ts b/apps/web/src/atoms/waveAtom.ts index e3daaf6..0440866 100644 --- a/apps/web/src/atoms/waveAtom.ts +++ b/apps/web/src/atoms/waveAtom.ts @@ -34,10 +34,10 @@ export const waveAtom = atom(get => { for (let x = 0; x <= width; x++) { const y = - amplitude * waveFunction((2 * Math.PI * x) / wavelength) + halfHeight; + -amplitude * waveFunction((2 * Math.PI * x) / wavelength) + halfHeight; ys.push(round(y - prevY)); prevY = y; } - return `m0 ${ys.join(' 1 ')} V0H0Z`; + return `m0 ${ys.join(' 1 ')} V${height}H0Z`; }); diff --git a/apps/web/src/atoms/waveFunctionAtom.ts b/apps/web/src/atoms/waveFunctionAtom.ts index 9622dde..6419a3c 100644 --- a/apps/web/src/atoms/waveFunctionAtom.ts +++ b/apps/web/src/atoms/waveFunctionAtom.ts @@ -1,10 +1,16 @@ -import { atomWithStorage } from 'jotai/utils'; +import { atomWithStorage, createJSONStorage } from 'jotai/utils'; +import { Type } from '@sinclair/typebox'; +import { withStorageValidator } from '@/utils/withStorageValidator'; import { WAVE_FUNCTION } from '@/constants'; import { WaveFunction } from '@/types'; +const WaveFunctionSchema = Type.Union( + Object.values(WAVE_FUNCTION).map(value => Type.Literal(value)) +); + export const waveFunctionAtom = atomWithStorage( 'waveFunction', WAVE_FUNCTION.HEARTBEAT, - undefined, + withStorageValidator(WaveFunctionSchema)(createJSONStorage()), { getOnInit: true } ); diff --git a/apps/web/src/atoms/waveformAtoms.ts b/apps/web/src/atoms/waveformAtoms.ts index c763f16..ef873f0 100644 --- a/apps/web/src/atoms/waveformAtoms.ts +++ b/apps/web/src/atoms/waveformAtoms.ts @@ -1,19 +1,25 @@ -import { atom } from 'jotai'; +import { observe } from 'jotai-effect'; import { focusAtom } from 'jotai-optics'; -import { atomWithStorage } from 'jotai/utils'; +import { atomWithStorage, createJSONStorage, RESET } from 'jotai/utils'; +import { Type } from '@sinclair/typebox'; +import { largestImageAtom } from '@/atoms/largestImageAtom'; +import { withStorageValidator } from '@/utils/withStorageValidator'; export const MIN_ROTATION = 0; export const MAX_ROTATION = 360; export const MIN_WAVELENGTH = 9; export const MIN_AMPLITUDE = 0; -export interface Waveform { - rotation: number; - wavelength: number; - wavelengthMax: number; - amplitude: number; - amplitudeMax: number; -} +const WaveformSchema = Type.Object({ + rotation: Type.Number(), + wavelength: Type.Number(), + wavelengthMax: Type.Number(), + amplitude: Type.Number(), + amplitudeMax: Type.Number(), + _optimized: Type.Optional(Type.Boolean()) +}); + +export type Waveform = typeof WaveformSchema.static; export const waveformAtom = atomWithStorage( 'waveform', @@ -24,10 +30,14 @@ export const waveformAtom = atomWithStorage( wavelengthMax: MIN_WAVELENGTH + 1, amplitudeMax: MIN_AMPLITUDE + 1 }, - undefined, + withStorageValidator(WaveformSchema)(createJSONStorage()), { getOnInit: true } ); +export const isWaveformOptimizedAtom = focusAtom(waveformAtom, optic => + optic.prop('_optimized') +); + // Rotation export const rotationAtom = focusAtom(waveformAtom, optic => @@ -54,20 +64,37 @@ export const amplitudeMaxAtom = focusAtom(waveformAtom, optic => optic.prop('amplitudeMax') ); -export const optimizeWaveformAtom = atom( - null, - (_get, set, image?: HTMLImageElement) => { - const wavelengthMax = image?.width ?? MIN_WAVELENGTH + 1; - const amplitudeMax = image - ? Math.round(image.height / 8) - : MIN_AMPLITUDE + 1; +observe((get, set) => { + const abortController = new AbortController(); + const largestImagePromise = get(largestImageAtom); + const isOptimized = get(isWaveformOptimizedAtom); - set(waveformAtom, { + (async () => { + const largestImage = await largestImagePromise; + if (abortController.signal.aborted) { + return; + } + if (!largestImage) { + set(waveformAtom, RESET); + return; + } + if (isOptimized) { + return; + } + const { width, height } = largestImage; + const wavelengthMax = width; + const amplitudeMax = Math.round(height / 8); + + set(waveformAtom, prev => ({ + ...prev, rotation: 0, wavelength: wavelengthMax, amplitude: amplitudeMax, wavelengthMax, - amplitudeMax - }); - } -); + amplitudeMax, + _optimized: true + })); + })(); + + return () => abortController.abort(); +}); diff --git a/apps/web/src/components/ChangeWaveFunction.tsx b/apps/web/src/components/ChangeWaveFunction.tsx index 9ce3f03..91e4815 100644 --- a/apps/web/src/components/ChangeWaveFunction.tsx +++ b/apps/web/src/components/ChangeWaveFunction.tsx @@ -1,15 +1,15 @@ import { useAtom, useAtomValue } from 'jotai'; +import { useLingui } from '@lingui/react/macro'; import { waveFunctionAtom } from '@/atoms/waveFunctionAtom'; import { ToggleGroup, ToggleGroupItem } from '@workspace/ui/components/toggle-group'; -import { Sin } from '@/components/Sin'; -import { Heartbeat } from '@/components/Heartbeat'; +import { unwrappedImagesAtom } from '@/atoms/imagesAtom'; +import { Sin } from '@/icons/Sin'; +import { Heartbeat } from '@/icons/Heartbeat'; import { WAVE_FUNCTION } from '@/constants'; import { WaveFunction } from '@/types'; -import { unwrappedImagesAtom } from '@/atoms/imagesAtom'; -import { useLingui } from '@lingui/react/macro'; export type ChanveWaveFunctionProps = Omit< React.ComponentProps, diff --git a/apps/web/src/components/Graphics.tsx b/apps/web/src/components/Graphics.tsx index 747ee27..b4ab27a 100644 --- a/apps/web/src/components/Graphics.tsx +++ b/apps/web/src/components/Graphics.tsx @@ -56,13 +56,6 @@ export function Graphics(props: GraphicsProps) { @@ -94,11 +93,7 @@ export function Graphics(props: GraphicsProps) { = 1 - ? `url(#${waveId}${orderedImages.length - i})` - : undefined - } + clipPath={i >= 1 ? `url(#${waveId}${i})` : undefined} /> ))} diff --git a/apps/web/src/components/OptimizeWaveform.tsx b/apps/web/src/components/OptimizeWaveform.tsx index 8421238..a54d6f0 100644 --- a/apps/web/src/components/OptimizeWaveform.tsx +++ b/apps/web/src/components/OptimizeWaveform.tsx @@ -3,16 +3,16 @@ import { WandSparkles } from 'lucide-react'; import { useLingui } from '@lingui/react/macro'; import { Button } from '@workspace/ui/components/button'; import { Tooltip } from '@workspace/ui/components/tooltip'; -import { optimizeWaveformAtom } from '@/atoms/waveformAtoms'; +import { isWaveformOptimizedAtom } from '@/atoms/waveformAtoms'; import { unwrappedLargestImageAtom } from '@/atoms/largestImageAtom'; export function OptimizeWaveform() { const { t } = useLingui(); const largestImage = useAtomValue(unwrappedLargestImageAtom); - const optimizeWaveform = useSetAtom(optimizeWaveformAtom); + const setWaveformOptimized = useSetAtom(isWaveformOptimizedAtom); const handleClick = () => { - optimizeWaveform(largestImage); + setWaveformOptimized(false); }; return ( diff --git a/apps/web/src/components/RemoveImages.tsx b/apps/web/src/components/RemoveImages.tsx index 13faca6..437f8bd 100644 --- a/apps/web/src/components/RemoveImages.tsx +++ b/apps/web/src/components/RemoveImages.tsx @@ -15,8 +15,7 @@ import { AlertDialogTitle, AlertDialogTrigger } from '@workspace/ui/components/alert-dialog'; -import { imagesAtom, unwrappedImagesAtom } from '@/atoms/imagesAtom'; -import { waveformAtom } from '@/atoms/waveformAtoms'; +import { imageFilesAtom, unwrappedImagesAtom } from '@/atoms/imagesAtom'; export type RemoveImagesProps = React.ComponentProps; @@ -24,13 +23,11 @@ export function RemoveImages(props: RemoveImagesProps) { const { t } = useLingui(); const [isPending, startTransition] = useTransition(); const images = useAtomValue(unwrappedImagesAtom); - const resetImages = useResetAtom(imagesAtom); - const resetWaveform = useResetAtom(waveformAtom); + const resetImageFiles = useResetAtom(imageFilesAtom); const handleConfirm = () => { startTransition(() => { - resetWaveform(); - resetImages(); + resetImageFiles(); }); }; diff --git a/apps/web/src/components/Heartbeat.tsx b/apps/web/src/icons/Heartbeat.tsx similarity index 100% rename from apps/web/src/components/Heartbeat.tsx rename to apps/web/src/icons/Heartbeat.tsx diff --git a/apps/web/src/components/Sin.tsx b/apps/web/src/icons/Sin.tsx similarity index 100% rename from apps/web/src/components/Sin.tsx rename to apps/web/src/icons/Sin.tsx diff --git a/apps/web/src/utils/__tests__/withStorageValidator.test.ts b/apps/web/src/utils/__tests__/withStorageValidator.test.ts new file mode 100644 index 0000000..51ddaea --- /dev/null +++ b/apps/web/src/utils/__tests__/withStorageValidator.test.ts @@ -0,0 +1,47 @@ +import type { SyncStorage } from 'jotai/vanilla/utils/atomWithStorage'; +import { Type } from '@sinclair/typebox'; +import { withStorageValidator } from '@/utils/withStorageValidator'; + +describe('withStorageValidator', () => { + const store = new Map(); + const storage: SyncStorage = { + getItem: (key, initialValue) => store.get(key) ?? initialValue, + setItem: (key, value) => store.set(key, value), + removeItem: key => store.delete(key) + }; + + beforeEach(() => { + store.clear(); + }); + + it('should store and retrieve valid data', () => { + const validatedStorage = withStorageValidator(Type.Number())(storage); + validatedStorage.setItem('a', 1); + expect(validatedStorage.getItem('a', 2)).toBe(1); + }); + + it('should return initial value when invalid data is retrieved', () => { + const validatedStorage = withStorageValidator(Type.Number())(storage); + storage.setItem('a', 'one'); + expect(validatedStorage.getItem('a', 1)).toBe(1); + }); + + it('should handle object schemas', () => { + const validatedStorage = withStorageValidator( + Type.Union([ + Type.Object({ + name: Type.String() + }), + Type.Null() + ]) + )(storage); + + validatedStorage.setItem('john', { name: 'John' }); + expect(validatedStorage.getItem('john', null)).toEqual(store.get('john')); + + storage.setItem('jane', { name: false }); + expect(validatedStorage.getItem('jane', { name: 'Jane' })).toEqual({ + name: 'Jane' + }); + }); +}); diff --git a/apps/web/src/utils/atomWithToggleAndStorage.ts b/apps/web/src/utils/atomWithToggleAndStorage.ts index 1dbd0b8..cd27ff0 100644 --- a/apps/web/src/utils/atomWithToggleAndStorage.ts +++ b/apps/web/src/utils/atomWithToggleAndStorage.ts @@ -8,10 +8,9 @@ export function atomWithToggleAndStorage( const derivedAtom = atom( get => get(anAtom), (get, set, nextValue?: boolean) => { - const update = nextValue ?? !get(anAtom); - void set(anAtom, update); + set(anAtom, nextValue ?? !get(anAtom)); } ); - return derivedAtom as WritableAtom; + return derivedAtom; } diff --git a/apps/web/src/utils/createIdbKeyvalStorage.ts b/apps/web/src/utils/createIdbKeyvalStorage.ts new file mode 100644 index 0000000..58a7556 --- /dev/null +++ b/apps/web/src/utils/createIdbKeyvalStorage.ts @@ -0,0 +1,21 @@ +import { get, set, del, createStore } from 'idb-keyval'; +import type { AsyncStorage } from 'jotai/vanilla/utils/atomWithStorage'; + +const idbKeyvalStore = createStore('keyval', 'keyval-store'); + +/** + * Creates an async storage based on `idb-keyval` for use with Jotai's `atomWithStorage`. + * + * @template Value - The type of value stored in the storage. + * @param store - The `idb-keyval` store to use. + */ +export function createIdbKeyvalStorage( + store = idbKeyvalStore +): AsyncStorage { + return { + getItem: async (key, initialValue) => + (await get(key, store)) ?? initialValue, + setItem: (key, value) => set(key, value, store), + removeItem: key => del(key, store) + }; +} diff --git a/apps/web/src/utils/createIndexedDbStorage.ts b/apps/web/src/utils/createIndexedDbStorage.ts deleted file mode 100644 index 8e4792d..0000000 --- a/apps/web/src/utils/createIndexedDbStorage.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { get, set, del, UseStore } from 'idb-keyval'; -import { AsyncStorage } from 'jotai/vanilla/utils/atomWithStorage'; - -export function createIndexedDbStorage( - store?: UseStore -): AsyncStorage { - return { - async getItem(key, initialValue) { - return (await get(key, store)) ?? initialValue; - }, - setItem: set, - removeItem: del - }; -} diff --git a/apps/web/src/utils/readFile.ts b/apps/web/src/utils/readFile.ts index 99b48a3..281af93 100644 --- a/apps/web/src/utils/readFile.ts +++ b/apps/web/src/utils/readFile.ts @@ -1,17 +1,39 @@ import { createAbortablePromise } from '@/utils/createAbortablePromise'; export interface ReadFileOptions { + /** + * Optional AbortSignal to allow cancellation of the file reading. + */ signal?: AbortSignal; } +/** + * Reads a file and returns a promise that resolves with the result. + * + * @param read - A function that invokes the appropriate `FileReader` method to read the file. + * @param options - Optional parameters. + */ export function readFile( read: (reader: FileReader) => void, options?: ReadFileOptions -): Promise { +): Promise { return createAbortablePromise((resolve, reject) => { const reader = new FileReader(); - reader.addEventListener('load', e => resolve(e.target?.result)); + reader.addEventListener('load', () => resolve(reader.result)); reader.addEventListener('error', reject); read(reader); }, options); } + +/** + * Reads the `file` as a base64-encoded data URL. + * + * @param file - The file to be read. + * @param options - Optional parameters. + */ +export function readFileAsDataURL(file: Blob, options?: ReadFileOptions) { + return readFile( + reader => reader.readAsDataURL(file), + options + ) as Promise; +} diff --git a/apps/web/src/utils/withStorageValidator.ts b/apps/web/src/utils/withStorageValidator.ts new file mode 100644 index 0000000..70d4f8a --- /dev/null +++ b/apps/web/src/utils/withStorageValidator.ts @@ -0,0 +1,30 @@ +import { unstable_withStorageValidator } from 'jotai/utils'; +import { TSchema } from '@sinclair/typebox'; +import { Value } from '@sinclair/typebox/value'; + +/** + * Creates a storage validator using a Typebox `schema` for use with Jotai's `atomWithStorage`. + * + * Validates stored data against the provided `schema`, ensuring data consistency by falling + * back to the initial value if the stored data is invalid. This helps prevent application + * errors when schema changes occur. + * + * @param schema - The Typebox schema used for validation. + * + * @example + * ``` + * const UserSchema = Type.Object({ + * name: Type.Optional(Type.String()), + * age: Type.Optional(Type.Number()) + * }); + + * const userAtom = atomWithStorage( + * 'user', + * {}, + * withStorageValidator(UserSchema)(createJSONStorage()) + * ); + * ``` + */ +export function withStorageValidator(schema: S) { + return unstable_withStorageValidator(value => Value.Check(schema, value)); +} diff --git a/bun.lock b/bun.lock index 5f7e7b2..3c07dec 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,7 @@ "@elysiajs/eden": "^1.2.0", "@lingui/core": "^5.2.0", "@lingui/react": "^5.2.0", + "@sinclair/typebox": "^0.34.28", "@workspace/schema": "workspace:*", "@workspace/ui": "workspace:*", "browser-fs-access": "^0.35.0", @@ -627,7 +628,7 @@ "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], - "bun-types": ["bun-types@1.2.4", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q=="], + "bun-types": ["bun-types@1.2.5", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-3oO6LVGGRRKI4kHINx5PIdIgnLRb7l/SprhzqXapmoYkFl5m4j6EvALvbDVuuBFaamB46Ap6HCUxIXNLCGy+tg=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], @@ -1369,6 +1370,8 @@ "@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], + "@types/bun/bun-types": ["bun-types@1.2.4", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q=="], + "@types/eslint__js/@eslint/js": ["@eslint/js@9.21.0", "", {}, "sha512-BqStZ3HX8Yz6LvsF5ByXYrtigrV5AXADWLAGc7PH/1SxOb7/FIYYMszZZWiUou/GB9P2lXWk2SV4d+Z8h0nknw=="], "@types/ws/@types/node": ["@types/node@22.13.1", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew=="], @@ -1379,8 +1382,6 @@ "babel-plugin-macros/cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], - "bun-types/@types/node": ["@types/node@22.13.1", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew=="], - "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -1491,6 +1492,8 @@ "@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "@types/bun/bun-types/@types/node": ["@types/node@22.13.1", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "babel-plugin-macros/cosmiconfig/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],