3 Commits
Author SHA1 Message Date
ivanch 62e187b9f5 fixing small bugs; improving background handling and removing dead code
Build and Release to Staging / Build Vision Start (push) Successful in 1m6s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m41s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 18s
2026-10-28 21:52:07 -03:00
ivanch 99dbea0615 fixing small issues in the project
Build and Release to Staging / Build Vision Start (push) Successful in 40s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m8s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 16s
2026-10-10 23:25:20 -03:00
ivanch 032d2a100a improving pipelines 2026-10-10 23:25:06 -03:00
33 changed files with 632 additions and 297 deletions

No files matched your search

+3
View File
@@ -32,6 +32,9 @@ jobs:
- name: Install JS dependencies - name: Install JS dependencies
run: npm ci run: npm ci
- name: Type check
run: npm run typecheck
- name: Run build - name: Run build
run: | run: |
bash scripts/prepare_release.sh bash scripts/prepare_release.sh
+3
View File
@@ -30,6 +30,9 @@ jobs:
- name: Install JS dependencies - name: Install JS dependencies
run: npm ci run: npm ci
- name: Type check
run: npm run typecheck
- name: Run build - name: Run build
run: | run: |
bash scripts/prepare_release.sh bash scripts/prepare_release.sh
+4 -5
View File
@@ -41,9 +41,8 @@ The design language is **soft liquid glass**: lightly translucent matte surfaces
- Keep `prefers-reduced-motion` behavior intact when adding animations. - Keep `prefers-reduced-motion` behavior intact when adding animations.
### Tailwind specifics ### Tailwind specifics
- Tailwind **v4** via `@tailwindcss/vite` and `@tailwindcss/postcss`. Custom easing is in `index.css` under `@theme {}` — add new theme tokens there, not in `tailwind.config.js`. - Tailwind **v4** via the `@tailwindcss/vite` plugin only (there is no `tailwind.config.js` or PostCSS config). Custom easing is in `index.css` under `@theme {}` — add new theme tokens there.
- `tailwind.config.js` has a **safelist** of `w-[Npx]/h-[Npx]` classes because tile/icon sizes are constructed dynamically. If you add new pixel-size classes that are generated at runtime (not literally present in source), add them to the safelist or they will be purged. - Tailwind generates only classes it finds literally in source files. Prefer static classes; for sizes computed at runtime use numeric inline styles (as tile icons do), or add an `@source inline(...)` line to `index.css` if a dynamic class is unavoidable.
- Prefer static classes; only build dynamic class strings when unavoidable and then safelist them.
### Components with established conventions to reuse ### Components with established conventions to reuse
- `Dropdown` (`components/Dropdown.tsx`) for selects — single or multi. Has a glassy look and custom arrow. **Always use it** instead of `<select>`/`<input type=...>` for option picking. - `Dropdown` (`components/Dropdown.tsx`) for selects — single or multi. Has a glassy look and custom arrow. **Always use it** instead of `<select>`/`<input type=...>` for option picking.
@@ -97,8 +96,8 @@ A "feature" in this project typically means: a new widget, a new settings option
- **Runtime note:** Preact is the actual runtime (`@preact/preset-vite`), but types and imports use `react`/`@types/react`. Do not introduce React-only APIs without verifying Preact compatibility. - **Runtime note:** Preact is the actual runtime (`@preact/preset-vite`), but types and imports use `react`/`@types/react`. Do not introduce React-only APIs without verifying Preact compatibility.
- **Path alias:** `@/*` maps to the project root (see `tsconfig.json` `paths`). Source files currently use relative imports (`./`, `../`) — match the surrounding file. - **Path alias:** `@/*` maps to the project root (see `tsconfig.json` `paths`). Source files currently use relative imports (`./`, `../`) — match the surrounding file.
- **No comments** in committed code unless explicitly requested. - **No comments** in committed code unless explicitly requested.
- **Build vs. dev:** Prefer verifying with `npm run build` rather than `npm run dev`. The only npm scripts are `dev`, `build`, `preview`. - **Build vs. dev:** Prefer verifying with `npm run build` rather than `npm run dev`. npm scripts: `dev`, `build`, `preview`, `typecheck`, `test`, `capture:screenshots`.
- **No lint/typecheck script is configured.** Verify TS correctness manually (e.g. `npx tsc --noEmit`), and confirm `npm run build` succeeds before considering a task complete. - **Verification:** run `npm run typecheck` and `npm run build`, then `npm test` (Playwright regression suite; needs `npx playwright install chromium`) before considering a task complete. CI runs the type check on PRs and pushes to `main`.
- **Don't touch `public/icon-metadata.json`** — it's gitignored and fetched at release-build time by `scripts/prepare_release.sh`. Don't commit a local copy. - **Don't touch `public/icon-metadata.json`** — it's gitignored and fetched at release-build time by `scripts/prepare_release.sh`. Don't commit a local copy.
- **Don't reintroduce dead code:** `components/EditModal.tsx` imports `lucide-react` and `./IconPicker`, neither of which is a dependency. It is not wired into the app. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead, and add the icon picker inline (as in `WebsiteEditModal.tsx`) rather than reviving `IconPicker`. - **Don't reintroduce dead code:** `components/EditModal.tsx` imports `lucide-react` and `./IconPicker`, neither of which is a dependency. It is not wired into the app. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead, and add the icon picker inline (as in `WebsiteEditModal.tsx`) rather than reviving `IconPicker`.
- **External asset URLs:** any new icons/css/JS pulled from CDNs (e.g. `cdn.jsdelivr.net`) must work offline-or-not without crashing — always provide a fallback (see how `iconService.ts` falls back to Google's S2 favicon service). - **External asset URLs:** any new icons/css/JS pulled from CDNs (e.g. `cdn.jsdelivr.net`) must work offline-or-not without crashing — always provide a fallback (see how `iconService.ts` falls back to Google's S2 favicon service).
+37 -5
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, lazy, Suspense } from 'react'; import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
import ServerWidget from './components/ServerWidget'; import ServerWidget from './components/ServerWidget';
import { Category, Website, Config } from './types'; import { Category, Website, Config } from './types';
import Header from './components/layout/Header'; import Header from './components/layout/Header';
@@ -11,6 +11,7 @@ import { getAlignmentClass } from './components/utils/styleUtils';
import { getRandomWallpaperIndex, loadWallpaperState, saveWallpaperState } from './components/utils/wallpaperUtils'; import { getRandomWallpaperIndex, loadWallpaperState, saveWallpaperState } from './components/utils/wallpaperUtils';
import { PlusIcon } from './components/icons'; import { PlusIcon } from './components/icons';
import { createId } from './components/utils/idUtils'; import { createId } from './components/utils/idUtils';
import { pruneCachedIconsFromChromeStorageLocal } from './components/utils/StorageLocalManager';
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal')); const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal')); const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
@@ -26,6 +27,25 @@ const App: React.FC = () => {
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false); const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
const [config, setConfig] = useState<Config>(() => ConfigurationService.loadConfig()); const [config, setConfig] = useState<Config>(() => ConfigurationService.loadConfig());
const [wallpaperVersion, setWallpaperVersion] = useState(0); const [wallpaperVersion, setWallpaperVersion] = useState(0);
const categoriesRef = useRef(categories);
categoriesRef.current = categories;
useEffect(() => {
const timer = setTimeout(() => {
const icons = categoriesRef.current.flatMap(category => category.websites.map(website => website.icon));
void pruneCachedIconsFromChromeStorageLocal(icons);
}, 5000);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
const onStorage = (event: StorageEvent) => {
if (event.key === 'config' || event.key === null) setConfig(ConfigurationService.loadConfig());
if (event.key === 'categories' || event.key === null) setCategories(ConfigurationService.loadCategories());
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
useEffect(() => { useEffect(() => {
ConfigurationService.saveConfig(config); ConfigurationService.saveConfig(config);
@@ -45,9 +65,19 @@ const App: React.FC = () => {
}, []); }, []);
const handleWallpaperChange = useCallback((newConfig: Partial<Config>) => { const handleWallpaperChange = useCallback((newConfig: Partial<Config>) => {
setConfig(prev => ({ ...prev, ...newConfig })); setConfig(prev => {
const next = newConfig.currentWallpapers;
const unchanged = Object.keys(newConfig).length === 1 && next !== undefined
&& next.length === prev.currentWallpapers.length
&& next.every((name, index) => name === prev.currentWallpapers[index]);
return unchanged ? prev : { ...prev, ...newConfig };
});
}, []); }, []);
const handleToggleEditing = useCallback(() => setIsEditing(editing => !editing), []);
const handleOpenConfig = useCallback(() => setIsConfigModalOpen(true), []);
const handleRandomWallpaper = useCallback(() => { const handleRandomWallpaper = useCallback(() => {
const names = config.currentWallpapers; const names = config.currentWallpapers;
if (names.length === 0) return; if (names.length === 0) return;
@@ -117,6 +147,8 @@ const App: React.FC = () => {
const handleDeleteCategory = useCallback(() => { const handleDeleteCategory = useCallback(() => {
if (!editingCategory) return; if (!editingCategory) return;
const websiteCount = editingCategory.websites.length;
if (websiteCount > 0 && !confirm(`Delete "${editingCategory.name}" and its ${websiteCount} ${websiteCount === 1 ? 'website' : 'websites'}?`)) return;
setCategories(previous => previous.filter(c => c.id !== editingCategory.id)); setCategories(previous => previous.filter(c => c.id !== editingCategory.id));
setEditingCategory(null); setEditingCategory(null);
@@ -158,8 +190,8 @@ const App: React.FC = () => {
wallpaperFrequency={config.wallpaperFrequency} wallpaperFrequency={config.wallpaperFrequency}
wallpaperVersion={wallpaperVersion} wallpaperVersion={wallpaperVersion}
/> />
<EditButton isEditing={isEditing} onClick={() => setIsEditing(!isEditing)} /> <EditButton isEditing={isEditing} onClick={handleToggleEditing} />
<ConfigurationButton onClick={() => setIsConfigModalOpen(true)} /> <ConfigurationButton onClick={handleOpenConfig} />
<Header config={config} /> <Header config={config} />
@@ -195,7 +227,7 @@ const App: React.FC = () => {
)} )}
</div> </div>
{config.serverWidget.enabled && <ServerWidget config={config} />} {config.serverWidget.enabled && <ServerWidget serverWidget={config.serverWidget} />}
{(editingWebsite || addingWebsite) && ( {(editingWebsite || addingWebsite) && (
<Suspense fallback={null}> <Suspense fallback={null}>
+2 -1
View File
@@ -18,7 +18,8 @@ const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, o
title={edit ? 'Edit Category' : 'Add Category'} title={edit ? 'Edit Category' : 'Add Category'}
edit={edit} edit={edit}
onClose={onClose} onClose={onClose}
onSave={() => onSave(name)} onSave={() => onSave(name.trim())}
canSave={name.trim() !== ''}
onDelete={edit ? onDelete : undefined} onDelete={edit ? onDelete : undefined}
> >
<input <input
+5 -3
View File
@@ -12,8 +12,10 @@ interface ClockProps {
}; };
} }
const getMinuteKey = (date: Date): number => Math.floor(date.getTime() / 60_000);
const Clock: React.FC<ClockProps> = ({ config }) => { const Clock: React.FC<ClockProps> = ({ config }) => {
const [time, setTime] = useState(new Date()); const [minuteKey, setMinuteKey] = useState(() => getMinuteKey(new Date()));
useEffect(() => { useEffect(() => {
if (!config.clock.enabled) return; if (!config.clock.enabled) return;
@@ -23,7 +25,7 @@ const Clock: React.FC<ClockProps> = ({ config }) => {
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
if (document.visibilityState !== 'visible') return; if (document.visibilityState !== 'visible') return;
const now = new Date(); const now = new Date();
setTime(now); setMinuteKey(getMinuteKey(now));
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds(); const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
timeoutId = window.setTimeout(refresh, msToNextMinute); timeoutId = window.setTimeout(refresh, msToNextMinute);
}; };
@@ -62,7 +64,7 @@ const Clock: React.FC<ClockProps> = ({ config }) => {
fontFamily: config.clock.font, fontFamily: config.clock.font,
}} }}
> >
{formatTime(time)} {formatTime(new Date(minuteKey * 60_000))}
</div> </div>
); );
}; };
+13 -2
View File
@@ -63,6 +63,17 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
closeTimer.current = setTimeout(onClose, 250); closeTimer.current = setTimeout(onClose, 250);
}; };
const handleCloseRef = useRef(handleClose);
handleCloseRef.current = handleClose;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !e.defaultPrevented) handleCloseRef.current();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
const handleConfigChange = (updates: Partial<Config>) => { const handleConfigChange = (updates: Partial<Config>) => {
setConfig((prev) => ({ ...prev, ...updates })); setConfig((prev) => ({ ...prev, ...updates }));
}; };
@@ -139,7 +150,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
]; ];
return ( return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true"> <div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-labelledby="configuration-title">
<div <div
className={`liquid-modal-backdrop fixed inset-0 transition-opacity duration-200 ease-ios ${ className={`liquid-modal-backdrop fixed inset-0 transition-opacity duration-200 ease-ios ${
isVisible ? 'opacity-100' : 'opacity-0' isVisible ? 'opacity-100' : 'opacity-0'
@@ -153,7 +164,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
}`} }`}
> >
<div className="p-6 sm:p-8 flex-grow overflow-y-auto"> <div className="p-6 sm:p-8 flex-grow overflow-y-auto">
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">Configuration</h2> <h2 id="configuration-title" className="liquid-title-text text-3xl font-extrabold mb-6">Configuration</h2>
<div className="liquid-surface grid grid-cols-2 sm:grid-cols-4 gap-1 rounded-2xl p-1 mb-7"> <div className="liquid-surface grid grid-cols-2 sm:grid-cols-4 gap-1 rounded-2xl p-1 mb-7">
{tabs.map((tab) => ( {tabs.map((tab) => (
+11 -3
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useLayoutEffect } from 'react';
interface DropdownProps { interface DropdownProps {
options: { value: string; label: string }[]; options: { value: string; label: string }[];
@@ -12,17 +12,25 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, mul
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => { useLayoutEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false); setIsOpen(false);
} }
}; };
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
setIsOpen(false);
};
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown, true);
return () => { return () => {
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown, true);
}; };
}, []); }, [isOpen]);
const handleOptionClick = (optionValue: string) => { const handleOptionClick = (optionValue: string) => {
let newValue: string | string[]; let newValue: string | string[];
+22 -5
View File
@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useId, useRef } from 'react';
interface ModalShellProps { interface ModalShellProps {
title: string; title: string;
@@ -6,10 +6,23 @@ interface ModalShellProps {
onClose: () => void; onClose: () => void;
onSave: () => void; onSave: () => void;
onDelete?: () => void; onDelete?: () => void;
canSave?: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, onDelete, children }) => { const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, onDelete, canSave = true, children }) => {
const titleId = useId();
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !e.defaultPrevented) onCloseRef.current();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
onClose(); onClose();
@@ -18,8 +31,8 @@ const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, o
return ( return (
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}> <div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white"> <div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white" role="dialog" aria-modal="true" aria-labelledby={titleId}>
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{title}</h2> <h2 id={titleId} className="liquid-title-text text-3xl font-extrabold mb-6">{title}</h2>
<div className="flex flex-col gap-4">{children}</div> <div className="flex flex-col gap-4">{children}</div>
<div className="flex justify-between items-center mt-8"> <div className="flex justify-between items-center mt-8">
<div> <div>
@@ -30,7 +43,11 @@ const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, o
)} )}
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button onClick={onSave} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5"> <button
onClick={onSave}
disabled={!canSave}
className="liquid-button liquid-button-success liquid-focus py-2.5 px-5 disabled:opacity-40 disabled:cursor-not-allowed"
>
Save Save
</button> </button>
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5"> <button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
+16 -18
View File
@@ -1,14 +1,12 @@
import { useState, useEffect, useRef } from 'react'; import { memo, useState, useEffect, useRef } from 'react';
import { Server } from '../types'; import { Server } from '../types';
import ping from './utils/jsping'; import ping from './utils/jsping';
interface ServerWidgetProps { interface ServerWidgetProps {
config: { serverWidget: {
serverWidget: { enabled: boolean;
enabled: boolean; pingFrequency: number;
pingFrequency: number; servers: Server[];
servers: Server[];
};
}; };
} }
@@ -20,20 +18,20 @@ const STATUS_COLORS: Record<string, string> = {
const getStatusColor = (status: string): string => const getStatusColor = (status: string): string =>
STATUS_COLORS[status] ?? 'bg-slate-400 text-slate-400'; STATUS_COLORS[status] ?? 'bg-slate-400 text-slate-400';
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => { const ServerWidget: React.FC<ServerWidgetProps> = ({ serverWidget }) => {
const [serverStatus, setServerStatus] = useState<Record<string, string>>({}); const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
const serversRef = useRef(config.serverWidget.servers); const serversRef = useRef(serverWidget.servers);
serversRef.current = config.serverWidget.servers; serversRef.current = serverWidget.servers;
const serversSignature = JSON.stringify(config.serverWidget.servers.map(s => [s.id, s.address])); const serversSignature = JSON.stringify(serverWidget.servers.map(s => [s.id, s.address]));
useEffect(() => { useEffect(() => {
if (!config.serverWidget.enabled || serversRef.current.length === 0) return; if (!serverWidget.enabled || serversRef.current.length === 0) return;
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
let controller: AbortController | undefined; let controller: AbortController | undefined;
let cancelled = false; let cancelled = false;
const frequency = Math.min(60, Math.max(5, config.serverWidget.pingFrequency || 15)) * 1000; const frequency = Math.min(60, Math.max(5, serverWidget.pingFrequency || 15)) * 1000;
setServerStatus(Object.fromEntries(serversRef.current.map(server => [server.id, 'pending']))); setServerStatus(Object.fromEntries(serversRef.current.map(server => [server.id, 'pending'])));
const pingServers = async () => { const pingServers = async () => {
@@ -74,19 +72,19 @@ const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
document.removeEventListener('visibilitychange', onVisibilityChange); document.removeEventListener('visibilitychange', onVisibilityChange);
}; };
}, [ }, [
config.serverWidget.enabled, serverWidget.enabled,
config.serverWidget.pingFrequency, serverWidget.pingFrequency,
serversSignature, serversSignature,
]); ]);
if (!config.serverWidget.enabled) { if (!serverWidget.enabled) {
return null; return null;
} }
return ( return (
<div className="fixed bottom-3 left-1/2 z-20 w-auto max-w-[calc(100%-1.5rem)] -translate-x-1/2"> <div className="fixed bottom-3 left-1/2 z-20 w-auto max-w-[calc(100%-1.5rem)] -translate-x-1/2">
<div className="liquid-surface flex items-center gap-4 rounded-full px-4 py-2"> <div className="liquid-surface flex items-center gap-4 rounded-full px-4 py-2">
{config.serverWidget.servers.map((server) => ( {serverWidget.servers.map((server) => (
<div key={server.id} className="flex items-center gap-2"> <div key={server.id} className="flex items-center gap-2">
<div className={`liquid-status-dot w-2.5 h-2.5 rounded-full ${getStatusColor(serverStatus[server.id])}`}></div> <div className={`liquid-status-dot w-2.5 h-2.5 rounded-full ${getStatusColor(serverStatus[server.id])}`}></div>
<span className="text-slate-100 text-sm font-medium"> <span className="text-slate-100 text-sm font-medium">
@@ -99,4 +97,4 @@ const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
); );
}; };
export default ServerWidget; export default memo(ServerWidget);
+68 -4
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { memo, useState, useEffect } from 'react';
import { baseWallpapers } from './utils/baseWallpapers'; import { baseWallpapers } from './utils/baseWallpapers';
import { Wallpaper as WallpaperType } from '../types'; import { Wallpaper as WallpaperType } from '../types';
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager'; import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
@@ -27,9 +27,56 @@ const rememberWallpaperUrl = (name: string, resolved: string | undefined): void
} }
}; };
const MAX_WALLPAPER_OBJECT_URLS = 2;
const wallpaperObjectUrls = new Map<string, string>();
const revokeWallpaperObjectUrls = (keepNames?: string[]): void => {
for (const [name, url] of wallpaperObjectUrls) {
if (keepNames?.includes(name)) continue;
URL.revokeObjectURL(url);
wallpaperObjectUrls.delete(name);
}
};
const rememberWallpaperObjectUrl = (name: string, url: string): string => {
const existing = wallpaperObjectUrls.get(name);
if (existing) {
URL.revokeObjectURL(url);
return existing;
}
wallpaperObjectUrls.set(name, url);
while (wallpaperObjectUrls.size > MAX_WALLPAPER_OBJECT_URLS) {
const [oldestName, oldestUrl] = wallpaperObjectUrls.entries().next().value ?? [];
if (oldestName === undefined || oldestUrl === undefined) break;
URL.revokeObjectURL(oldestUrl);
wallpaperObjectUrls.delete(oldestName);
}
return url;
};
const dataUrlToObjectUrl = (dataUrl: string): string => {
const header = dataUrl.match(/^data:([^;,]*)((?:;[^;,]*)*),/);
if (!header || typeof URL.createObjectURL !== 'function') return dataUrl;
try {
const payload = dataUrl.slice(header[0].length);
const type = header[1] || 'application/octet-stream';
if (!/;base64/i.test(header[2])) {
return URL.createObjectURL(new Blob([decodeURIComponent(payload)], { type }));
}
const binary = atob(payload);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return URL.createObjectURL(new Blob([bytes], { type }));
} catch {
return dataUrl;
}
};
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => { const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
if (!name) return undefined; if (!name) return undefined;
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name); if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
const objectUrl = wallpaperObjectUrls.get(name);
if (objectUrl) return objectUrl;
let resolved: string | undefined; let resolved: string | undefined;
const foundInBase = baseWallpapers.find((w: WallpaperType) => w.name === name); const foundInBase = baseWallpapers.find((w: WallpaperType) => w.name === name);
@@ -56,10 +103,22 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
} }
} }
if (resolved?.startsWith('data:')) {
const url = dataUrlToObjectUrl(resolved);
return url.startsWith('blob:') ? rememberWallpaperObjectUrl(name, url) : url;
}
rememberWallpaperUrl(name, resolved); rememberWallpaperUrl(name, resolved);
return resolved; return resolved;
}; };
const getWallpaperFilter = (blur: number, brightness: number): string => {
const filters = [
blur > 0 ? `blur(${blur}px)` : '',
brightness !== 100 ? `brightness(${brightness / 100})` : '',
].filter(Boolean);
return filters.length > 0 ? filters.join(' ') : 'none';
};
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => { const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined); const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
@@ -68,6 +127,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
let revision = 0; let revision = 0;
wallpaperUrlCache.clear(); wallpaperUrlCache.clear();
revokeWallpaperObjectUrls(wallpaperNames);
const updateWallpaper = async () => { const updateWallpaper = async () => {
const request = ++revision; const request = ++revision;
@@ -119,6 +179,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
const onStorage = (event: StorageEvent) => { const onStorage = (event: StorageEvent) => {
if (event.key === 'wallpaperState' || event.key === 'userWallpapers' || event.key === null) { if (event.key === 'wallpaperState' || event.key === 'userWallpapers' || event.key === null) {
wallpaperUrlCache.clear(); wallpaperUrlCache.clear();
if (event.key !== 'wallpaperState') revokeWallpaperObjectUrls();
refresh(); refresh();
} }
}; };
@@ -137,6 +198,8 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
}; };
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]); }, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
useEffect(() => () => revokeWallpaperObjectUrls(), []);
if (!imageUrl) return null; if (!imageUrl) return null;
return ( return (
@@ -144,8 +207,9 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
<div <div
className="wallpaper-layer wallpaper-transition" className="wallpaper-layer wallpaper-transition"
style={{ style={{
backgroundImage: `url(${imageUrl})`, backgroundImage: `url(${JSON.stringify(imageUrl)})`,
filter: `blur(${blur}px) brightness(${brightness / 100})`, filter: getWallpaperFilter(blur, brightness),
transform: blur > 0 ? 'scale(1.015)' : 'none',
opacity: opacity / 100, opacity: opacity / 100,
}} }}
aria-label="Wallpaper background" aria-label="Wallpaper background"
@@ -155,4 +219,4 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
); );
}; };
export default Wallpaper; export default memo(Wallpaper);
+39 -6
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Website } from '../types'; import { Website } from '../types';
import { getWebsiteIcon } from './utils/iconService'; import { getWebsiteIcon } from './utils/iconService';
import { normalizeWebsiteUrl } from './utils/urlUtils';
import ModalShell from './ModalShell'; import ModalShell from './ModalShell';
interface WebsiteEditModalProps { interface WebsiteEditModalProps {
@@ -20,6 +21,8 @@ interface IconMetadata {
const getIconPickUrl = (iconData: IconMetadata): string => const getIconPickUrl = (iconData: IconMetadata): string =>
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`; `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
const isIconUrl = (value: string): boolean => /^(https?:|data:image\/)/i.test(value.trim());
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => { const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
const [name, setName] = useState(website ? website.name : ''); const [name, setName] = useState(website ? website.name : '');
const [url, setUrl] = useState(website ? website.url : ''); const [url, setUrl] = useState(website ? website.url : '');
@@ -27,6 +30,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
const [iconQuery, setIconQuery] = useState(''); const [iconQuery, setIconQuery] = useState('');
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]); const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]); const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
const [previewFailed, setPreviewFailed] = useState(false);
const metadataRequest = useRef<AbortController | null>(null); const metadataRequest = useRef<AbortController | null>(null);
const debounceRef = useRef<number | null>(null); const debounceRef = useRef<number | null>(null);
@@ -98,10 +102,36 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
} }
}, [iconQuery, iconMetadata]); }, [iconQuery, iconMetadata]);
const handleSave = () => {
const normalizedUrl = normalizeWebsiteUrl(url);
if (!normalizedUrl) {
alert('Please enter a valid website URL (http, https, ftp, or mailto).');
return;
}
onSave({ id: website?.id, name: name.trim(), url: normalizedUrl, icon: resolveIcon(icon) });
};
const resolveIcon = (value: string): string => {
const trimmed = value.trim();
if (!trimmed || isIconUrl(trimmed)) return trimmed;
const query = trimmed.toLowerCase();
for (const ic of iconMetadata) {
if (ic.name.toLowerCase() === query) return getIconPickUrl(ic);
const color = ic.colors.find(c => c.toLowerCase() === query);
if (color) return getIconPickUrl({ ...ic, name: color });
}
return trimmed;
};
const updateIcon = (value: string) => {
setIcon(value);
setPreviewFailed(false);
};
const fetchIcon = async () => { const fetchIcon = async () => {
if (url) { if (url) {
const fetchedIcon = await getWebsiteIcon(url); const fetchedIcon = await getWebsiteIcon(url);
setIcon(fetchedIcon); if (fetchedIcon) updateIcon(fetchedIcon);
} }
}; };
@@ -110,12 +140,13 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
title={edit ? 'Edit Website' : 'Add Website'} title={edit ? 'Edit Website' : 'Add Website'}
edit={edit} edit={edit}
onClose={onClose} onClose={onClose}
onSave={() => onSave({ id: website?.id, name, url, icon })} onSave={handleSave}
canSave={name.trim() !== '' && url.trim() !== ''}
onDelete={edit ? onDelete : undefined} onDelete={edit ? onDelete : undefined}
> >
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
{icon ? ( {isIconUrl(icon) && !previewFailed ? (
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" /> <img src={icon.trim()} alt="Website Icon" className="h-24 w-24 object-contain" onError={() => setPreviewFailed(true)} />
) : ( ) : (
<div className="liquid-surface h-24 w-24 rounded-2xl flex items-center justify-center"> <div className="liquid-surface h-24 w-24 rounded-2xl flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-white/50"> <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-white/50">
@@ -147,7 +178,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
placeholder="Icon URL or name" placeholder="Icon URL or name"
value={icon} value={icon}
onChange={(e) => { onChange={(e) => {
setIcon(e.target.value); updateIcon(e.target.value);
setIconQuery(e.target.value); setIconQuery(e.target.value);
}} }}
onFocus={ensureIconMetadata} onFocus={ensureIconMetadata}
@@ -159,7 +190,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
<div <div
key={`${iconData.name}-${index}`} key={`${iconData.name}-${index}`}
onClick={() => { onClick={() => {
setIcon(getIconPickUrl(iconData)); updateIcon(getIconPickUrl(iconData));
setIconQuery(''); setIconQuery('');
setFilteredIcons([]); setFilteredIcons([]);
}} }}
@@ -168,6 +199,8 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
<img <img
src={getIconPickUrl(iconData)} src={getIconPickUrl(iconData)}
alt={iconData.name} alt={iconData.name}
loading="lazy"
decoding="async"
className="h-6 w-6 mr-2" className="h-6 w-6 mr-2"
/> />
<span>{iconData.name}</span> <span>{iconData.name}</span>
+23 -10
View File
@@ -1,6 +1,6 @@
import React, { memo, useEffect, useState } from 'react'; import React, { memo, useEffect, useRef, useState } from 'react';
import { Website } from '../types'; import { Website } from '../types';
import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService'; import { cacheWebsiteIcon, getCachedWebsiteIcon, getInitialIconSource, removeCachedWebsiteIcon } from './utils/iconService';
import { getTileSizeClass, getIconPixelSize, getIconLoadingPixelSize } from './utils/styleUtils'; import { getTileSizeClass, getIconPixelSize, getIconLoadingPixelSize } from './utils/styleUtils';
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from './icons'; import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from './icons';
@@ -12,22 +12,33 @@ interface WebsiteTileProps {
tileSize?: string; tileSize?: string;
} }
const LOADING_RESET_MS = 10_000;
const isNavigableUrl = (url: string): boolean => /^https?:/i.test(url);
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => { const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [iconSource, setIconSource] = useState<string | null>(null); const [iconSource, setIconSource] = useState<string | null | undefined>(() => getInitialIconSource(website.icon));
const [usingCachedIcon, setUsingCachedIcon] = useState(false); const [usingCachedIcon, setUsingCachedIcon] = useState(false);
const loadingTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => { useEffect(() => {
const resetLoading = () => setIsLoading(false); const resetLoading = () => {
clearTimeout(loadingTimer.current);
setIsLoading(false);
};
window.addEventListener('pageshow', resetLoading); window.addEventListener('pageshow', resetLoading);
return () => window.removeEventListener('pageshow', resetLoading); return () => {
clearTimeout(loadingTimer.current);
window.removeEventListener('pageshow', resetLoading);
};
}, []); }, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setIconSource(null); setIconSource(getInitialIconSource(website.icon));
setUsingCachedIcon(false); setUsingCachedIcon(false);
const loadIcon = async () => { const loadIcon = async () => {
@@ -40,7 +51,7 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
return; return;
} }
setIconSource(website.icon); setIconSource(website.icon || null);
const newlyCachedIcon = await cacheWebsiteIcon(website.icon); const newlyCachedIcon = await cacheWebsiteIcon(website.icon);
if (!cancelled && newlyCachedIcon) { if (!cancelled && newlyCachedIcon) {
setIconSource(newlyCachedIcon); setIconSource(newlyCachedIcon);
@@ -70,8 +81,10 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
e.preventDefault(); e.preventDefault();
return; return;
} }
if (!e.defaultPrevented && e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) { if (!e.defaultPrevented && e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && isNavigableUrl(website.url)) {
setIsLoading(true); setIsLoading(true);
clearTimeout(loadingTimer.current);
loadingTimer.current = setTimeout(() => setIsLoading(false), LOADING_RESET_MS);
} }
}; };
@@ -105,9 +118,9 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
decoding="async" decoding="async"
onError={handleIconError} onError={handleIconError}
/> />
) : ( ) : iconSource === null ? (
<span className="text-2xl font-semibold text-slate-300" aria-hidden="true">{website.name.charAt(0).toUpperCase()}</span> <span className="text-2xl font-semibold text-slate-300" aria-hidden="true">{website.name.charAt(0).toUpperCase()}</span>
)} ) : null}
</div> </div>
<span className={`max-w-full px-1 text-slate-50 font-semibold text-base text-center leading-tight transition-all duration-200 ease-ios [text-shadow:0_2px_12px_rgba(2,6,23,0.44)] ${isLoading ? 'text-sm' : ''}`}> <span className={`max-w-full px-1 text-slate-50 font-semibold text-base text-center leading-tight transition-all duration-200 ease-ios [text-shadow:0_2px_12px_rgba(2,6,23,0.44)] ${isLoading ? 'text-sm' : ''}`}>
{website.name} {website.name}
-1
View File
@@ -40,7 +40,6 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
onChange={(e) => updateClock({ font: e.target.value as string })} onChange={(e) => updateClock({ font: e.target.value as string })}
options={[ options={[
{ value: 'Helvetica', label: 'Helvetica' }, { value: 'Helvetica', label: 'Helvetica' },
{ value: `'Orbitron', sans-serif`, label: 'Orbitron' },
{ value: 'monospace', label: 'Monospace' }, { value: 'monospace', label: 'Monospace' },
{ value: 'cursive', label: 'Cursive' }, { value: 'cursive', label: 'Cursive' },
]} ]}
@@ -5,6 +5,7 @@ import { Config, Server } from '../../types';
import RangeSlider from './RangeSlider'; import RangeSlider from './RangeSlider';
import { TrashIcon } from '../icons'; import { TrashIcon } from '../icons';
import { createId } from '../utils/idUtils'; import { createId } from '../utils/idUtils';
import { parseServerAddress } from '../utils/urlUtils';
interface ServerWidgetTabProps { interface ServerWidgetTabProps {
config: Config; config: Config;
@@ -21,6 +22,12 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
const handleAddServer = () => { const handleAddServer = () => {
if (newServerName.trim() === '' || newServerAddress.trim() === '') return; if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
try {
parseServerAddress(newServerAddress);
} catch (error) {
alert(error instanceof Error && error.message.startsWith('Server') ? error.message : 'Please enter a valid server address, e.g. 192.168.1.10:8080 or https://example.com.');
return;
}
const newServer: Server = { const newServer: Server = {
id: createId(), id: createId(),
name: newServerName.trim(), name: newServerName.trim(),
+5 -2
View File
@@ -121,7 +121,10 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
> >
<span className="truncate">{wallpaper.name}</span> <span className="truncate">{wallpaper.name}</span>
<button <button
onClick={() => onDeleteWallpaper(wallpaper)} onClick={() => {
if (!wallpaper.url && !confirm(`Delete "${wallpaper.name}"? Uploaded wallpapers are not included in exports and cannot be recovered.`)) return;
void onDeleteWallpaper(wallpaper);
}}
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100" className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
aria-label={`Delete ${wallpaper.name}`} aria-label={`Delete ${wallpaper.name}`}
> >
@@ -181,7 +184,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
<p className="mb-2 text-sm text-gray-400"> <p className="mb-2 text-sm text-gray-400">
<span className="font-semibold">Click to upload</span> <span className="font-semibold">Click to upload</span>
</p> </p>
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p> <p className="text-xs text-gray-400">PNG, JPG, WEBP, etc. (up to 20MB)</p>
</div> </div>
<input <input
id="file-upload" id="file-upload"
+2 -3
View File
@@ -1,5 +1,4 @@
import { memo } from 'react';
interface ConfigurationButtonProps { interface ConfigurationButtonProps {
onClick: () => void; onClick: () => void;
} }
@@ -21,4 +20,4 @@ const ConfigurationButton: React.FC<ConfigurationButtonProps> = ({ onClick }) =>
); );
}; };
export default ConfigurationButton; export default memo(ConfigurationButton);
+2 -1
View File
@@ -1,3 +1,4 @@
import { memo } from 'react';
import { PencilIcon } from '../icons'; import { PencilIcon } from '../icons';
interface EditButtonProps { interface EditButtonProps {
@@ -20,4 +21,4 @@ const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
); );
}; };
export default EditButton; export default memo(EditButton);
+2 -1
View File
@@ -1,3 +1,4 @@
import { memo } from 'react';
import Clock from '../Clock'; import Clock from '../Clock';
import { Config } from '../../types'; import { Config } from '../../types';
import { getTitleSizeClass } from '../utils/styleUtils'; import { getTitleSizeClass } from '../utils/styleUtils';
@@ -29,4 +30,4 @@ const Header: React.FC<HeaderProps> = ({ config }) => {
); );
}; };
export default Header; export default memo(Header);
+65 -27
View File
@@ -6,7 +6,10 @@ import {
checkChromeStorageLocalAvailable, checkChromeStorageLocalAvailable,
removeWallpaperFromChromeStorageLocal, removeWallpaperFromChromeStorageLocal,
} from '../utils/StorageLocalManager'; } from '../utils/StorageLocalManager';
import { getFileNameFromUrl } from '../utils/urlUtils'; import { getFileNameFromUrl, normalizeWebsiteUrl } from '../utils/urlUtils';
import { getWallpaperFrequencyHours } from '../utils/wallpaperUtils';
export const CLOCK_FONTS = ['Helvetica', 'monospace', 'cursive'];
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const; const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number]; type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
@@ -14,7 +17,7 @@ type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
export const DEFAULT_CONFIG: Config = { export const DEFAULT_CONFIG: Config = {
title: 'Vision Start', title: 'Vision Start',
currentWallpapers: ['Beach'], currentWallpapers: ['Beach'],
wallpaperFrequency: '1d', wallpaperFrequency: '24h',
wallpaperBlur: 0, wallpaperBlur: 0,
wallpaperBrightness: 108, wallpaperBrightness: 108,
wallpaperOpacity: 96, wallpaperOpacity: 96,
@@ -78,6 +81,7 @@ const hasStrings = (value: unknown, keys: string[]): value is Record<string, str
export const normalizeConfig = (stored: unknown): Config => { export const normalizeConfig = (stored: unknown): Config => {
const config = deepMerge(DEFAULT_CONFIG, stored); const config = deepMerge(DEFAULT_CONFIG, stored);
config.currentWallpapers = [...new Set(config.currentWallpapers.filter(name => typeof name === 'string' && name.length > 0))]; config.currentWallpapers = [...new Set(config.currentWallpapers.filter(name => typeof name === 'string' && name.length > 0))];
config.wallpaperFrequency = `${getWallpaperFrequencyHours(config.wallpaperFrequency)}h`;
config.wallpaperBlur = boundedNumber(config.wallpaperBlur, DEFAULT_CONFIG.wallpaperBlur, 0, 50); config.wallpaperBlur = boundedNumber(config.wallpaperBlur, DEFAULT_CONFIG.wallpaperBlur, 0, 50);
config.wallpaperBrightness = boundedNumber(config.wallpaperBrightness, DEFAULT_CONFIG.wallpaperBrightness, 0, 200); config.wallpaperBrightness = boundedNumber(config.wallpaperBrightness, DEFAULT_CONFIG.wallpaperBrightness, 0, 200);
config.wallpaperOpacity = boundedNumber(config.wallpaperOpacity, DEFAULT_CONFIG.wallpaperOpacity, 1, 100); config.wallpaperOpacity = boundedNumber(config.wallpaperOpacity, DEFAULT_CONFIG.wallpaperOpacity, 1, 100);
@@ -86,6 +90,7 @@ export const normalizeConfig = (stored: unknown): Config => {
config.alignment = choice(config.alignment, ['top', 'middle', 'bottom'], DEFAULT_CONFIG.alignment); config.alignment = choice(config.alignment, ['top', 'middle', 'bottom'], DEFAULT_CONFIG.alignment);
config.horizontalAlignment = choice(config.horizontalAlignment, ['left', 'middle', 'right'], DEFAULT_CONFIG.horizontalAlignment); config.horizontalAlignment = choice(config.horizontalAlignment, ['left', 'middle', 'right'], DEFAULT_CONFIG.horizontalAlignment);
config.clock.size = choice(config.clock.size, ['tiny', 'small', 'medium', 'large'], DEFAULT_CONFIG.clock.size); config.clock.size = choice(config.clock.size, ['tiny', 'small', 'medium', 'large'], DEFAULT_CONFIG.clock.size);
config.clock.font = choice(config.clock.font, CLOCK_FONTS, DEFAULT_CONFIG.clock.font);
config.clock.format = choice(config.clock.format, ['HH:mm', 'h:mm A'], DEFAULT_CONFIG.clock.format); config.clock.format = choice(config.clock.format, ['HH:mm', 'h:mm A'], DEFAULT_CONFIG.clock.format);
config.serverWidget.pingFrequency = boundedNumber(config.serverWidget.pingFrequency, DEFAULT_CONFIG.serverWidget.pingFrequency, 5, 60); config.serverWidget.pingFrequency = boundedNumber(config.serverWidget.pingFrequency, DEFAULT_CONFIG.serverWidget.pingFrequency, 5, 60);
const serverIds = new Set<string>(); const serverIds = new Set<string>();
@@ -110,8 +115,10 @@ export const normalizeCategories = (stored: unknown): Category[] => {
name: category.name, name: category.name,
websites: websites.flatMap(website => { websites: websites.flatMap(website => {
if (!hasStrings(website, ['id', 'name', 'url']) || !website.id || websiteIds.has(website.id)) return []; if (!hasStrings(website, ['id', 'name', 'url']) || !website.id || websiteIds.has(website.id)) return [];
const url = normalizeWebsiteUrl(website.url);
if (!url) return [];
websiteIds.add(website.id); websiteIds.add(website.id);
return [{ id: website.id, name: website.name, url: website.url, icon: typeof website.icon === 'string' ? website.icon : '', categoryId: category.id }]; return [{ id: website.id, name: website.name, url, icon: typeof website.icon === 'string' ? website.icon : '', categoryId: category.id }];
}), }),
}]; }];
}); });
@@ -131,6 +138,50 @@ export const normalizeUserWallpapers = (stored: unknown): Wallpaper[] => {
}); });
}; };
const MAX_WALLPAPER_SOURCE_BYTES = 20 * 1024 * 1024;
const MAX_WALLPAPER_DATA_URL_LENGTH = 4.5 * 1024 * 1024;
const MIN_WALLPAPER_EDGE = 1920;
const MAX_WALLPAPER_EDGE = 2560;
const readFileAsDataUrl = (file: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') resolve(reader.result);
else reject(new Error('Could not read the image file.'));
};
reader.onerror = () => reject(reader.error || new Error('Could not read the image file.'));
reader.readAsDataURL(file);
});
const downscaleWallpaper = async (file: File): Promise<Blob> => {
if (typeof createImageBitmap !== 'function' || typeof document === 'undefined' || file.type === 'image/gif' || file.type === 'image/svg+xml') {
return file;
}
try {
const bitmap = await createImageBitmap(file);
try {
const screenEdge = Math.max(window.screen?.width ?? 0, window.screen?.height ?? 0) * (window.devicePixelRatio || 1);
const maxEdge = Math.min(MAX_WALLPAPER_EDGE, Math.max(MIN_WALLPAPER_EDGE, screenEdge));
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const context = canvas.getContext('2d');
if (!context) return file;
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/webp', 0.85));
canvas.width = 0;
canvas.height = 0;
return blob && blob.type === 'image/webp' && blob.size < file.size ? blob : file;
} finally {
bitmap.close();
}
} catch {
return file;
}
};
const assertWallpaperNameAvailable = (name: string): void => { const assertWallpaperNameAvailable = (name: string): void => {
const names = [...baseWallpapers, ...ConfigurationService.loadUserWallpapers()]; const names = [...baseWallpapers, ...ConfigurationService.loadUserWallpapers()];
if (name.startsWith('vision-start:icon:') || names.some(wallpaper => wallpaper.name === name)) { if (name.startsWith('vision-start:icon:') || names.some(wallpaper => wallpaper.name === name)) {
@@ -201,29 +252,16 @@ export const ConfigurationService = {
async addWallpaperFile(file: File): Promise<Wallpaper> { async addWallpaperFile(file: File): Promise<Wallpaper> {
if (!file.type.startsWith('image/')) throw new Error('Please choose an image file.'); if (!file.type.startsWith('image/')) throw new Error('Please choose an image file.');
assertWallpaperNameAvailable(file.name.trim()); assertWallpaperNameAvailable(file.name.trim());
return new Promise((resolve, reject) => { if (file.size > MAX_WALLPAPER_SOURCE_BYTES) {
if (file.size > 4 * 1024 * 1024) { throw new Error('File size exceeds 20MB. Please choose a smaller file.');
reject(new Error('File size exceeds 4MB. Please choose a smaller file.')); }
return; const base64 = await readFileAsDataUrl(await downscaleWallpaper(file));
} if (base64.length > MAX_WALLPAPER_DATA_URL_LENGTH) {
const reader = new FileReader(); throw new Error('The uploaded image is too large. Please choose a smaller file.');
reader.onload = async () => { }
const base64 = reader.result as string; assertWallpaperNameAvailable(file.name.trim());
if (base64.length > 4.5 * 1024 * 1024) { const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
reject(new Error('The uploaded image is too large. Please choose a smaller file.')); return { name: finalName };
return;
}
try {
assertWallpaperNameAvailable(file.name.trim());
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
resolve({ name: finalName });
} catch (error) {
reject(error);
}
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}, },
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> { async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
@@ -253,7 +291,7 @@ export const ConfigurationService = {
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); setTimeout(() => URL.revokeObjectURL(url), 0);
}, },
async importConfig(file: File): Promise<{ config: Config; userWallpapers: Wallpaper[] }> { async importConfig(file: File): Promise<{ config: Config; userWallpapers: Wallpaper[] }> {
+57 -11
View File
@@ -7,7 +7,8 @@ declare global {
storage?: { storage?: {
local?: { local?: {
set: (items: object, callback?: () => void) => void; set: (items: object, callback?: () => void) => void;
get: (keys: string[] | string, callback: (items: { [key: string]: string }) => void) => void; get: (keys: string[] | string | null, callback: (items: { [key: string]: string }) => void) => void;
getKeys?: (callback: (keys: string[]) => void) => void;
remove: (keys: string | string[], callback?: () => void) => void; remove: (keys: string | string[], callback?: () => void) => void;
}; };
}; };
@@ -57,17 +58,36 @@ const chromeLocalCall = <T>(
}); });
}); });
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> { let pendingIconLookups: Map<string, ((value: string | null) => void)[]> | null = null;
if (!checkChromeStorageLocalAvailable()) return null;
const flushIconLookups = async (): Promise<void> => {
const lookups = pendingIconLookups;
pendingIconLookups = null;
if (!lookups) return;
let result: StorageResult = {};
try { try {
const key = getIconCacheKey(sourceUrl); result = await chromeLocalCall<StorageResult>((cb) =>
const result = await chromeLocalCall<StorageResult>((cb) => window.chrome?.storage?.local?.get([...lookups.keys()], cb),
window.chrome?.storage?.local?.get([key], cb),
); );
return result[key] || null;
} catch { } catch {
return null; result = {};
} }
for (const [key, resolvers] of lookups) {
const value = typeof result[key] === 'string' ? result[key] || null : null;
resolvers.forEach(resolve => resolve(value));
}
};
export function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
if (!checkChromeStorageLocalAvailable()) return Promise.resolve(null);
return new Promise(resolve => {
if (!pendingIconLookups) {
pendingIconLookups = new Map();
queueMicrotask(() => void flushIconLookups());
}
const key = getIconCacheKey(sourceUrl);
pendingIconLookups.set(key, [...(pendingIconLookups.get(key) ?? []), resolve]);
});
} }
export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> { export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> {
@@ -94,6 +114,25 @@ export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string):
} }
} }
export async function pruneCachedIconsFromChromeStorageLocal(keepSourceUrls: string[]): Promise<number> {
if (!checkChromeStorageLocalAvailable()) return 0;
try {
const keep = new Set(keepSourceUrls.map(getIconCacheKey));
const local = window.chrome?.storage?.local;
const keys = local?.getKeys
? await chromeLocalCall<string[]>((cb) => local.getKeys?.(cb))
: Object.keys(await chromeLocalCall<StorageResult>((cb) => local?.get(null, cb)));
const stale = keys.filter(key => key.startsWith(ICON_CACHE_KEY_PREFIX) && !keep.has(key));
if (stale.length === 0) return 0;
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.remove(stale, cb),
);
return stale.length;
} catch {
return 0;
}
}
/** /**
* Adds a new wallpaper to chrome.storage.local. * Adds a new wallpaper to chrome.storage.local.
* File uploads are stored as base64 while remote wallpapers remain URLs. * File uploads are stored as base64 while remote wallpapers remain URLs.
@@ -123,9 +162,16 @@ export async function addWallpaperToChromeStorageLocal(name: string, url: string
finalName = finalName || getFileNameFromUrl(parsedUrl); finalName = finalName || getFileNameFromUrl(parsedUrl);
} }
await chromeLocalCall<void>((cb) => try {
window.chrome?.storage?.local?.set({ [finalName]: url }, cb), await chromeLocalCall<void>((cb) =>
); window.chrome?.storage?.local?.set({ [finalName]: url }, cb),
);
} catch (error) {
if (error instanceof Error && /quota/i.test(error.message)) {
throw new Error('Not enough extension storage for this wallpaper. Delete an uploaded wallpaper and try again.');
}
throw error;
}
return finalName; return finalName;
} }
+19 -12
View File
@@ -4,6 +4,7 @@ import {
removeCachedIconFromChromeStorageLocal, removeCachedIconFromChromeStorageLocal,
saveCachedIconToChromeStorageLocal, saveCachedIconToChromeStorageLocal,
} from './StorageLocalManager'; } from './StorageLocalManager';
import { normalizeWebsiteUrl } from './urlUtils';
const MAX_CACHED_ICON_BYTES = 256 * 1024; const MAX_CACHED_ICON_BYTES = 256 * 1024;
const MAX_RESOLVED_ICONS = 50; const MAX_RESOLVED_ICONS = 50;
@@ -138,20 +139,25 @@ const getIconFetchSource = (iconUrl: string): string | null => {
async function getWebsiteIcon(rawUrl: string): Promise<string> { async function getWebsiteIcon(rawUrl: string): Promise<string> {
const trimmed = rawUrl.trim(); const trimmed = rawUrl.trim();
const hasProtocol = /^https?:\/\//i.test(trimmed); const hasProtocol = /^https?:\/\//i.test(trimmed);
const targetUrl = hasProtocol ? trimmed : `https://${trimmed}`; const normalized = normalizeWebsiteUrl(trimmed);
if (!normalized) return '';
try { const parsed = new URL(normalized);
const parsed = new URL(targetUrl); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
if (!isTrustedTld(parsed.hostname)) { if (!isTrustedTld(parsed.hostname)) {
if (!hasProtocol) { if (!hasProtocol) {
return `http://${parsed.host}/favicon.ico`; return `http://${parsed.host}/favicon.ico`;
}
return `${parsed.origin}/favicon.ico`;
} }
return `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=128`; return `${parsed.origin}/favicon.ico`;
} catch {
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(rawUrl)}&sz=128`;
} }
return `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=128`;
}
function getInitialIconSource(iconUrl: string): string | null | undefined {
if (!iconUrl) return null;
if (isDataUrl(iconUrl)) return iconUrl;
const inMemoryIcon = resolvedIconCache.get(iconUrl);
if (inMemoryIcon) return inMemoryIcon;
return isCacheableIconUrl(iconUrl) && checkChromeStorageLocalAvailable() ? undefined : iconUrl;
} }
async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> { async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
@@ -245,6 +251,7 @@ async function removeCachedWebsiteIcon(iconUrl: string): Promise<void> {
export { export {
cacheWebsiteIcon, cacheWebsiteIcon,
getCachedWebsiteIcon, getCachedWebsiteIcon,
getInitialIconSource,
getWebsiteIcon, getWebsiteIcon,
removeCachedWebsiteIcon, removeCachedWebsiteIcon,
}; };
+3 -5
View File
@@ -1,9 +1,7 @@
import { parseServerAddress } from './urlUtils';
async function ping(url: string, multiplier = 1, signal?: AbortSignal): Promise<number> { async function ping(url: string, multiplier = 1, signal?: AbortSignal): Promise<number> {
const address = url.trim(); const target = parseServerAddress(url);
const target = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(address) ? address : `http://${address}`);
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
throw new Error('Server addresses must use HTTP or HTTPS.');
}
const controller = new AbortController(); const controller = new AbortController();
const abort = () => controller.abort(); const abort = () => controller.abort();
+25
View File
@@ -7,3 +7,28 @@ export const getFileNameFromUrl = (url: URL): string => {
return pathName; return pathName;
} }
}; };
const SCHEME_PATTERN = /^[a-z][a-z\d+.-]*:/i;
const AUTHORITY_SCHEME_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i;
const ALLOWED_WEBSITE_PROTOCOLS = new Set(['http:', 'https:', 'ftp:', 'mailto:']);
export const normalizeWebsiteUrl = (raw: string): string | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
const hasScheme = AUTHORITY_SCHEME_PATTERN.test(trimmed) || (SCHEME_PATTERN.test(trimmed) && !/^[^:/]+:\d/.test(trimmed));
try {
const url = new URL(hasScheme ? trimmed : `https://${trimmed}`);
return ALLOWED_WEBSITE_PROTOCOLS.has(url.protocol) ? url.href : null;
} catch {
return null;
}
};
export const parseServerAddress = (address: string): URL => {
const trimmed = address.trim();
const target = new URL(AUTHORITY_SCHEME_PATTERN.test(trimmed) ? trimmed : `http://${trimmed}`);
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
throw new Error('Server addresses must use HTTP or HTTPS.');
}
return target;
};
-1
View File
@@ -55,7 +55,6 @@ body {
height: 100%; height: 100%;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
transform: scale(1.015);
} }
.wallpaper-luminance { .wallpaper-luminance {
+1 -4
View File
@@ -4,10 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vision Start</title> <title>Vision Start</title>
</head>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-slate-950"> <body class="bg-slate-950">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/index.tsx"></script> <script type="module" src="/index.tsx"></script>
+111 -102
View File
@@ -9,40 +9,25 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@tailwindcss/vite": "^4.1.11",
"preact": "^10.26.4" "preact": "^10.26.4"
}, },
"devDependencies": { "devDependencies": {
"@preact/preset-vite": "^2.10.1", "@preact/preset-vite": "^2.10.1",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"autoprefixer": "^10.4.21",
"playwright": "1.61.1", "playwright": "1.61.1",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"vite": "^6.2.0" "vite": "^6.2.0"
} }
}, },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.5",
@@ -383,6 +368,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -399,6 +385,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -415,6 +402,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -431,6 +419,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -447,6 +436,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -463,6 +453,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -479,6 +470,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -495,6 +487,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -511,6 +504,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -527,6 +521,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -543,6 +538,7 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -559,6 +555,7 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -575,6 +572,7 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -591,6 +589,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -607,6 +606,7 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -623,6 +623,7 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -639,6 +640,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -655,6 +657,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -671,6 +674,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -687,6 +691,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -703,6 +708,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -719,6 +725,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -735,6 +742,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -751,6 +759,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -767,6 +776,7 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -783,6 +793,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -813,6 +824,7 @@
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"minipass": "^7.0.4" "minipass": "^7.0.4"
@@ -825,6 +837,7 @@
"version": "0.3.12", "version": "0.3.12",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -846,6 +859,7 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@@ -855,12 +869,14 @@
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.29", "version": "0.3.29",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/resolve-uri": "^3.1.0",
@@ -874,6 +890,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -1008,6 +1025,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1021,6 +1039,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1034,6 +1053,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1047,6 +1067,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1060,6 +1081,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1073,6 +1095,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1086,6 +1109,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -1102,6 +1126,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@@ -1118,6 +1143,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -1134,6 +1160,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@@ -1150,6 +1177,7 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -1166,6 +1194,7 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@@ -1182,6 +1211,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -1198,6 +1228,7 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@@ -1214,6 +1245,7 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -1230,6 +1262,7 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@@ -1246,6 +1279,7 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -1262,6 +1296,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"glibc" "glibc"
], ],
@@ -1278,6 +1313,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"libc": [ "libc": [
"musl" "musl"
], ],
@@ -1294,6 +1330,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1307,6 +1344,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1320,6 +1358,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1333,6 +1372,7 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1346,6 +1386,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1359,6 +1400,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1369,6 +1411,7 @@
"version": "4.1.11", "version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
"integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==", "integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.3.0", "@ampproject/remapping": "^2.3.0",
@@ -1384,6 +1427,7 @@
"version": "4.1.11", "version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz",
"integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==", "integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1415,6 +1459,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1431,6 +1476,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1447,6 +1493,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1463,6 +1510,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1479,6 +1527,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1495,6 +1544,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1511,6 +1561,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1527,6 +1578,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1543,6 +1595,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1567,6 +1620,7 @@
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -1588,6 +1642,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1604,6 +1659,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1613,24 +1669,11 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tailwindcss/postcss": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz",
"integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.1.11",
"@tailwindcss/oxide": "4.1.11",
"postcss": "^8.4.41",
"tailwindcss": "4.1.11"
}
},
"node_modules/@tailwindcss/vite": { "node_modules/@tailwindcss/vite": {
"version": "4.1.11", "version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.11.tgz",
"integrity": "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==", "integrity": "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tailwindcss/node": "4.1.11", "@tailwindcss/node": "4.1.11",
@@ -1645,13 +1688,14 @@
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.16.3", "version": "22.16.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.3.tgz",
"integrity": "sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==", "integrity": "sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@@ -1683,44 +1727,6 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.24.4",
"caniuse-lite": "^1.0.30001702",
"fraction.js": "^4.3.7",
"normalize-range": "^0.1.2",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
"bin": {
"autoprefixer": "bin/autoprefixer"
},
"engines": {
"node": "^10 || ^12 || >=14"
},
"peerDependencies": {
"postcss": "^8.1.0"
}
},
"node_modules/babel-plugin-transform-hook-names": { "node_modules/babel-plugin-transform-hook-names": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
@@ -1810,6 +1816,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -1890,6 +1897,7 @@
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -1965,6 +1973,7 @@
"version": "5.18.2", "version": "5.18.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz",
"integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.4", "graceful-fs": "^4.2.4",
@@ -1991,6 +2000,7 @@
"version": "0.25.6", "version": "0.25.6",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
"integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==", "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -2049,6 +2059,7 @@
"version": "6.4.6", "version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"picomatch": "^3 || ^4" "picomatch": "^3 || ^4"
@@ -2059,24 +2070,11 @@
} }
} }
}, },
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "patreon",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -2101,6 +2099,7 @@
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/he": { "node_modules/he": {
@@ -2117,6 +2116,7 @@
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
@@ -2166,6 +2166,7 @@
"version": "1.30.1", "version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"detect-libc": "^2.0.3" "detect-libc": "^2.0.3"
@@ -2197,6 +2198,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2217,6 +2219,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2237,6 +2240,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2257,6 +2261,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2277,6 +2282,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2297,6 +2303,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2317,6 +2324,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2337,6 +2345,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2357,6 +2366,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2377,6 +2387,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2411,6 +2422,7 @@
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
@@ -2420,6 +2432,7 @@
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@@ -2429,6 +2442,7 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"minipass": "^7.1.2" "minipass": "^7.1.2"
@@ -2448,6 +2462,7 @@
"version": "3.3.20", "version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -2483,16 +2498,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nth-check": { "node_modules/nth-check": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -2510,12 +2515,14 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.7", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -2575,6 +2582,7 @@
"version": "8.5.29", "version": "8.5.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -2599,13 +2607,6 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/preact": { "node_modules/preact": {
"version": "10.29.0", "version": "10.29.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz",
@@ -2678,6 +2679,7 @@
"version": "4.64.3", "version": "4.64.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.3.tgz",
"integrity": "sha512-iIMWbptLYsZdGTJMH8ngApXdsCatE6bCMv2eqGIfGE1++kwh7TCWBdWKK0FKC0yx++196vGK5VdagzRXSLMa8A==", "integrity": "sha512-iIMWbptLYsZdGTJMH8ngApXdsCatE6bCMv2eqGIfGE1++kwh7TCWBdWKK0FKC0yx++196vGK5VdagzRXSLMa8A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "1.0.9" "@types/estree": "1.0.9"
@@ -2760,6 +2762,7 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -2779,12 +2782,14 @@
"version": "4.1.11", "version": "4.1.11",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -2794,6 +2799,7 @@
"version": "7.5.22", "version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"@isaacs/fs-minipass": "^4.0.0", "@isaacs/fs-minipass": "^4.0.0",
@@ -2816,6 +2822,7 @@
"version": "0.2.14", "version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.4.4", "fdir": "^6.4.4",
@@ -2846,7 +2853,7 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
@@ -2893,6 +2900,7 @@
"version": "6.4.4", "version": "6.4.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz",
"integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
@@ -2985,6 +2993,7 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
+3 -4
View File
@@ -7,22 +7,21 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "node scripts/check_regressions.mjs",
"capture:screenshots": "node scripts/capture_screenshots.mjs" "capture:screenshots": "node scripts/capture_screenshots.mjs"
}, },
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@tailwindcss/vite": "^4.1.11",
"preact": "^10.26.4" "preact": "^10.26.4"
}, },
"devDependencies": { "devDependencies": {
"@preact/preset-vite": "^2.10.1", "@preact/preset-vite": "^2.10.1",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"autoprefixer": "^10.4.21",
"playwright": "1.61.1", "playwright": "1.61.1",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"vite": "^6.2.0" "vite": "^6.2.0"
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {}, // ← use the new package name
autoprefixer: {},
}
}
+27 -29
View File
@@ -1,9 +1,3 @@
---
project_name: Vision Start
date: 2026-10-09
type: general_overview
---
# Vision Start — Project Context # Vision Start — Project Context
A general, non-normative overview of the **Vision Start** project: what it is, how it's structured, what features it offers, and which files do what. This document is intended as a map for humans and AI agents to orient themselves in the codebase. It deliberately avoids coding-style prescriptive rules. A general, non-normative overview of the **Vision Start** project: what it is, how it's structured, what features it offers, and which files do what. This document is intended as a map for humans and AI agents to orient themselves in the codebase. It deliberately avoids coding-style prescriptive rules.
@@ -34,7 +28,7 @@ Live instances / artifacts:
| Language | TypeScript (~5.7), target ES2020, strict mode | | Language | TypeScript (~5.7), target ES2020, strict mode |
| UI runtime | Preact 10 (via `@preact/preset-vite`); types from `@types/react` 19 | | UI runtime | Preact 10 (via `@preact/preset-vite`); types from `@types/react` 19 |
| Bundler / dev server | Vite 6 | | Bundler / dev server | Vite 6 |
| Styling | Tailwind CSS v4 (`@tailwindcss/vite` plugin + `@tailwindcss/postcss` + `autoprefixer`) | | Styling | Tailwind CSS v4 via the `@tailwindcss/vite` plugin only (no `tailwind.config.js` or PostCSS config) |
| Drag & drop | `@hello-pangea/dnd` 18 | | Drag & drop | `@hello-pangea/dnd` 18 |
| Build output | Plain static files in `dist/` (relative `base: './'`, single CSS bundle; modals code-split into separate JS chunks via `React.lazy`) | | Build output | Plain static files in `dist/` (relative `base: './'`, single CSS bundle; modals code-split into separate JS chunks via `React.lazy`) |
| Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` | | Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` |
@@ -52,29 +46,34 @@ The startpage is composed of widgets and a configuration panel:
- **Website Tiles** — Bookmarks organized into categories. Each tile shows an icon + name and opens the configured URL. Tiles can be added, edited, deleted, and moved left/right within their own category (reordering) while in edit mode. - **Website Tiles** — Bookmarks organized into categories. Each tile shows an icon + name and opens the configured URL. Tiles can be added, edited, deleted, and moved left/right within their own category (reordering) while in edit mode.
- **Categories** — Groupings of website tiles (e.g. "Search"). Add/edit/delete/name. - **Categories** — Groupings of website tiles (e.g. "Search"). Add/edit/delete/name.
- **Clock** — Optional header clock with selectable size, font, and 12h/24h format. - **Clock** — Optional header clock with selectable size, font (Helvetica, Monospace, Cursive — system fonts only, nothing is downloaded), and 12h/24h format.
- **Title** — Optional big header title (text + size configurable). - **Title** — Optional big header title (text + size configurable).
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Probes use cancellable, credential-free HTTP HEAD requests with a 5s timeout. Online means the browser received a response; cross-origin HTTP status codes are opaque, so this is reachability rather than an HTTP health check. Network failures, timeouts, and browser-blocked requests appear offline. - **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Probes use cancellable, credential-free HTTP HEAD requests with a 5s timeout. Online means the browser received a response; cross-origin HTTP status codes are opaque, so this is reachability rather than an HTTP health check. Network failures, timeouts, and browser-blocked requests appear offline.
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Randomly rotates through multiple wallpapers at an hourly cadence selected by slider (`1h`–`48h`). - **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Randomly rotates through multiple wallpapers at an hourly cadence selected by slider (`1h`–`48h`).
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`). - Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL. Remote URLs remain lightweight entries in the `userWallpapers` index; uploaded image data is stored in `chrome.storage.local` when available. - User wallpapers: upload from a file (≤20MB source; non-GIF/SVG images are downscaled to a long edge between 1920px and 2560px depending on the screen and re-encoded as WebP when smaller; ≤4.5MB stored base64) or add by URL. Remote URLs remain lightweight entries in the `userWallpapers` index; uploaded image data is stored in `chrome.storage.local` when available.
- **Icon library, auto-fetch & cache** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched for trusted public TLDs as a Google S2 favicon URL derived from the site's hostname (no HTML fetching — the S2 URL is used purely as an `<img>` source). Non-trusted TLDs (e.g. local TLDs, IP addresses) fetch `${origin}/favicon.ico` directly. Tiles opportunistically cache CORS-readable icon responses as data URLs in `chrome.storage.local` and retain the original URL when caching is unavailable. Cache population fetches trusted TLDs through a CORS-open favicon service (`icon.horse`) because Google's S2 endpoint 301-redirects without `Access-Control-Allow-Origin`, which would block cross-origin `fetch`. - **Icon library, auto-fetch & cache** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched for trusted public TLDs as a Google S2 favicon URL derived from the site's hostname (no HTML fetching — the S2 URL is used purely as an `<img>` source). Non-trusted TLDs (e.g. local TLDs, IP addresses) fetch `${origin}/favicon.ico` directly. Tiles opportunistically cache CORS-readable icon responses as data URLs in `chrome.storage.local` and retain the original URL when caching is unavailable. Cache population fetches trusted TLDs through a CORS-open favicon service (`icon.horse`) because Google's S2 endpoint 301-redirects without `Access-Control-Allow-Origin`, which would block cross-origin `fetch`.
- **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page). - **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page).
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles. - **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles. Add/edit modals disable Save until required fields are filled, and website URLs without a scheme get `https://` (only `http`, `https`, `ftp`, `mailto` are accepted). Deleting a category that has websites, or an uploaded wallpaper, asks for confirmation first. All modals close with Escape (an open `Dropdown` consumes Escape first). The navigation spinner only appears for `http(s)` links and resets after 10s.
- **Cross-tab sync** — `App.tsx` listens for `storage` events on `config` and `categories` and reloads normalized state, so an older open tab never overwrites edits made in another tab.
- **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`. - **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`.
Performance notes: Performance notes:
- Website tile icons check a deterministic `vision-start:icon:<encoded-source-url>` entry in `chrome.storage.local` before loading the external URL. Cache population fetches Google S2 favicon URLs for trusted public TLDs through `https://icon.horse/icon/<host>` (a CORS-open favicon service), while non-trusted TLDs and direct icon URLs render directly in `<img>` tags without triggering background `fetch` calls. It deduplicates in-flight requests, stores only `image/*` responses up to 256KiB, and never intercepts or proxies outside requests. - Website tile icons check a deterministic `vision-start:icon:<encoded-source-url>` entry in `chrome.storage.local` before loading the external URL. Lookups made in the same tick are batched into one `chrome.storage.local.get`, and tiles render an empty icon box (not the fallback initial) while the lookup is pending. Cache population fetches Google S2 favicon URLs for trusted public TLDs through `https://icon.horse/icon/<host>` (a CORS-open favicon service), while non-trusted TLDs and direct icon URLs render directly in `<img>` tags without triggering background `fetch` calls. It deduplicates in-flight requests, stores only `image/*` responses up to 256KiB, and never intercepts or proxies outside requests.
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ServerWidgetTab` is independently lazy-loaded, so its `@hello-pangea/dnd` dependency is loaded only when the Server Widget settings tab is selected. `WebsiteEditModal` and `CategoryEditModal` share a common `ModalShell` component. - Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ServerWidgetTab` is independently lazy-loaded, so its `@hello-pangea/dnd` dependency is loaded only when the Server Widget settings tab is selected. `WebsiteEditModal` and `CategoryEditModal` share a common `ModalShell` component.
- `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile. - `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile.
- `Clock` schedules one timeout for the next minute boundary, pauses while hidden, and refreshes immediately on focus or visibility return. - `Clock` schedules one timeout for the next minute boundary, pauses while hidden, and refreshes immediately on focus or visibility return.
- `jsping` uses HTTP HEAD without downloading or decoding probe images, propagates failures, and cleans up its timeout and abort listener. - `jsping` parses addresses with the shared `parseServerAddress` (also used to validate new servers in the settings tab), uses HTTP HEAD without downloading or decoding probe images, propagates failures, and cleans up its timeout and abort listener.
- `ServerWidget` runs at most four probes concurrently, batches completed results into one state update, avoids overlapping polling cycles, cancels work when hidden or unmounted, and depends on a stable ids/addresses signature. Hidden tabs stop polling and refresh on visibility return; removed servers leave no retained status entries. - `ServerWidget` runs at most four probes concurrently, batches completed results into one state update, avoids overlapping polling cycles, cancels work when hidden or unmounted, and depends on a stable ids/addresses signature. Hidden tabs stop polling and refresh on visibility return; removed servers leave no retained status entries.
- The icon picker only previews `http(s)`/`data:image` values (typed names never trigger relative-URL requests), lazy-loads result images, and resolves an exactly matching icon name to its Dashboard Icons URL on save. The Fetch button ignores non-`http(s)` website URLs.
- Icon metadata (`/icon-metadata.json`) is fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront. Only names, file formats, and color variants are retained in component state; requests are aborted on close, failed loads can retry, and metadata URLs resolve relative to the page so subdirectory deployments work (the browser HTTP cache makes reopening cheap), and the dashboard-icon URL for a picker entry is derived from one shared helper. - Icon metadata (`/icon-metadata.json`) is fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront. Only names, file formats, and color variants are retained in component state; requests are aborted on close, failed loads can retry, and metadata URLs resolve relative to the page so subdirectory deployments work (the browser HTTP cache makes reopening cheap), and the dashboard-icon URL for a picker entry is derived from one shared helper.
- `Wallpaper` resolves remote wallpaper URLs through a module-level `Map` bounded to the last 3 entries. Uploaded data URLs are excluded from that cache, and the cache clears on unmount; its image transition and readability overlay classes live in `index.css`. Wallpaper rotation and frequency parsing (`h`/`d` → hours/ms) share helpers with the Theme tab via `components/utils/wallpaperUtils.ts`. - `Wallpaper` resolves remote wallpaper URLs through a module-level `Map` bounded to the last 3 entries. Uploaded data URLs are excluded from that cache, and the cache clears on unmount; its image transition and readability overlay classes live in `index.css`. Wallpaper rotation and frequency parsing (`h`/`d` → hours/ms) share helpers with the Theme tab via `components/utils/wallpaperUtils.ts`.
- The website icon service bounds its extra resolved data-URL cache to 50 entries and 2MiB of string storage (conservatively counted at two bytes per character). Cache downloads run at most four at a time, with at most 50 outstanding requests and an 8s timeout. Streaming reads stop at 256KiB before decoding; invalid or oversized persisted entries are discarded. Tile images still retain the data they display, separate from this extra cache. - The website icon service bounds its extra resolved data-URL cache to 50 entries and 2MiB of string storage (conservatively counted at two bytes per character). Cache downloads run at most four at a time, with at most 50 outstanding requests and an 8s timeout. Streaming reads stop at 256KiB before decoding; invalid or oversized persisted entries are discarded. Tile images still retain the data they display, separate from this extra cache.
- Tile icon dimensions use numeric inline styles, independent of generated Tailwind classes. Failed icons show the website initial, modified link clicks avoid navigation spinners, and browser history restoration clears the loading state. Category mutations preserve unaffected object references and use stable functional state handlers. Shared IDs use UUIDs with a fallback for ordinary HTTP deployments. - Tile icon dimensions use numeric inline styles, independent of generated Tailwind classes. Failed icons show the website initial, modified link clicks avoid navigation spinners, and browser history restoration clears the loading state. Category mutations preserve unaffected object references and use stable functional state handlers. Shared IDs use UUIDs with a fallback for ordinary HTTP deployments.
- Permanent `will-change` hints are removed from tiles and wallpaper so the browser chooses when to allocate compositing layers. - Permanent `will-change` hints are removed from tiles and wallpaper so the browser chooses when to allocate compositing layers. The wallpaper only applies `blur()`/`brightness()` filters and the edge-hiding `scale(1.015)` when they are not neutral.
- Uploaded wallpaper data URLs are converted once into `blob:` object URLs (at most two kept, revoked on deselection, cross-tab `userWallpapers` changes, and unmount), so focus/visibility refreshes do not re-read multi-MB base64 from storage and inline styles stay small.
- `Wallpaper`, `Header`, `ServerWidget`, `EditButton`, and `ConfigurationButton` are memoized; `Clock` stores a minute key so duplicate focus/visibility refreshes do not re-render; `Dropdown` attaches document listeners only while open.
- Startup icon-cache pruning lists keys with `chrome.storage.local.getKeys()` (Chrome 130+) instead of loading every stored value, falling back to `get(null)` on older browsers.
- Shared styling/frequency helpers live in `components/utils/styleUtils.ts` (tile/clock/title size classes, icon pixel sizes, alignment classes, size presets), `wallpaperUtils.ts`, and `urlUtils.ts` (URL→filename extraction, also used by wallpaper storage). Range sliders use the shared `RangeSlider` component; repeated glyphs (pencil, plus, trash, chevrons) use `components/icons.tsx`. - Shared styling/frequency helpers live in `components/utils/styleUtils.ts` (tile/clock/title size classes, icon pixel sizes, alignment classes, size presets), `wallpaperUtils.ts`, and `urlUtils.ts` (URL→filename extraction, also used by wallpaper storage). Range sliders use the shared `RangeSlider` component; repeated glyphs (pencil, plus, trash, chevrons) use `components/icons.tsx`.
Planned / To-do (tracked in `README.md`): Planned / To-do (tracked in `README.md`):
@@ -132,7 +131,7 @@ vision-start/
│ ├── idUtils.ts # Shared UUID generation with an ordinary-HTTP fallback │ ├── idUtils.ts # Shared UUID generation with an ordinary-HTTP fallback
│ ├── styleUtils.ts # Shared tile/clock/title size classes, icon pixel sizes, alignment classes, size presets │ ├── styleUtils.ts # Shared tile/clock/title size classes, icon pixel sizes, alignment classes, size presets
│ ├── wallpaperUtils.ts # Wallpaper frequency parsing (h/d → hours/ms) and random-index helpers │ ├── wallpaperUtils.ts # Wallpaper frequency parsing (h/d → hours/ms) and random-index helpers
│ └── urlUtils.ts # URL → filename extraction (shared by wallpaper storage paths) │ └── urlUtils.ts # URL → filename extraction, website URL normalization (scheme allow-list), server address parsing
│ │
├── public/ ├── public/
│ ├── favicon.ico │ ├── favicon.ico
@@ -143,7 +142,7 @@ vision-start/
│ ├── check_regressions.mjs # Persistence/network regressions, browser memory checks, unpacked-extension smoke test │ ├── check_regressions.mjs # Persistence/network regressions, browser memory checks, unpacked-extension smoke test
│ ├── capture_screenshots.mjs # Loads demoData, captures home/edit/configuration with Playwright, and can target a deployed URL │ ├── capture_screenshots.mjs # Loads demoData, captures home/edit/configuration with Playwright, and can target a deployed URL
│ ├── demoData.json # Base64-encoded localStorage fixture used for release screenshots │ ├── demoData.json # Base64-encoded localStorage fixture used for release screenshots
│ ├── prepare_release.sh # Downloads icon-metadata.json from homarr-labs/dashboard-icons │ ├── prepare_release.sh # Downloads icon-metadata.json from homarr-labs/dashboard-icons (fails the build on download errors)
│ └── check_virustotal.sh # Uploads the release zip to VirusTotal, waits for & reports the verdict │ └── check_virustotal.sh # Uploads the release zip to VirusTotal, waits for & reports the verdict
│ │
├── .gitea/workflows/ ├── .gitea/workflows/
@@ -155,11 +154,9 @@ vision-start/
├── nginx.conf # nginx gzip config (JSON/JS/CSS/SVG/XML), mounted into container ├── nginx.conf # nginx gzip config (JSON/JS/CSS/SVG/XML), mounted into container
├── .dockerignore ├── .dockerignore
├── .gitignore # Ignores node_modules, dist, .claude/, public/icon-metadata.json, etc. ├── .gitignore # Ignores node_modules, dist, .claude/, public/icon-metadata.json, etc.
├── postcss.config.cjs # @tailwindcss/postcss + autoprefixer
├── tailwind.config.js # Content globs + legacy pixel-size safelist
├── tsconfig.json # Strict TS, bundler resolution, `@/*` path alias to project root ├── tsconfig.json # Strict TS, bundler resolution, `@/*` path alias to project root
├── vite.config.ts # preact + tailwindcss plugins; single-bundle output; base './' ├── vite.config.ts # preact + tailwindcss plugins; single-bundle output; base './'
├── package.json # Scripts: dev, build, preview ├── package.json # Scripts: dev, build, preview, typecheck, test, capture:screenshots
├── README.md # Public-facing readme + feature list + roadmap ├── README.md # Public-facing readme + feature list + roadmap
└── .env.local # Local env (not tracked in context here) └── .env.local # Local env (not tracked in context here)
``` ```
@@ -178,7 +175,7 @@ The shape of all persisted data lives in `types.ts`:
- **`Wallpaper`** — `name`, optional `url` or `base64` - **`Wallpaper`** — `name`, optional `url` or `base64`
- **`Config`** — Everything else: title, wallpaper list + hourly frequency/blur/brightness/opacity, titleSize, vertical & horizontal alignment, tileSize, `clock {enabled, size, font, format}`, `serverWidget {enabled, pingFrequency, servers}` - **`Config`** — Everything else: title, wallpaper list + hourly frequency/blur/brightness/opacity, titleSize, vertical & horizontal alignment, tileSize, `clock {enabled, size, font, format}`, `serverWidget {enabled, pingFrequency, servers}`
`ConfigurationService` (`components/services/ConfigurationService.ts`) is the source of truth for default config and persistence helpers. Config loaded from `localStorage` (and imported from export files) is run through `normalizeConfig()`, a schema-driven deep merge against `DEFAULT_CONFIG`: nested blocks (`clock`, `serverWidget`) inherit new sub-fields added to defaults, stored values with the wrong type fall back to defaults, and keys absent from the default schema are pruned. Nested defaults are cloned, option values and numeric ranges are validated, and malformed wallpaper/server entries are filtered. Categories and the user wallpaper index are normalized before rendering; website category IDs are repaired to match their containing category. The merge is self-healing — `App.tsx`'s persist-`useEffect` writes the normalized shape back to `localStorage` on first run. `ConfigurationService` (`components/services/ConfigurationService.ts`) is the source of truth for default config and persistence helpers. Config loaded from `localStorage` (and imported from export files) is run through `normalizeConfig()`, a schema-driven deep merge against `DEFAULT_CONFIG`: nested blocks (`clock`, `serverWidget`) inherit new sub-fields added to defaults, stored values with the wrong type fall back to defaults, and keys absent from the default schema are pruned. Nested defaults are cloned, option values (including `clock.font`) and numeric ranges are validated, `wallpaperFrequency` is rewritten to the canonical `<hours>h` form (default `24h`; legacy `1d`/`2d` convert), and malformed wallpaper/server entries are filtered. Categories and the user wallpaper index are normalized before rendering; website URLs go through `normalizeWebsiteUrl` (bare hosts get `https://`, disallowed schemes such as `javascript:` drop the website), and website category IDs are repaired to match their containing category. The merge is self-healing — `App.tsx`'s persist-`useEffect` writes the normalized shape back to `localStorage` on first run.
Storage layout (browser-side): Storage layout (browser-side):
@@ -189,7 +186,7 @@ Storage layout (browser-side):
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names plus URLs for remote wallpapers) | | `userWallpapers` | `localStorage` | `Wallpaper[]` index (names plus URLs for remote wallpapers) |
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex, currentName? }` for rotation (legacy index-only state remains supported) | | `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex, currentName? }` for rotation (legacy index-only state remains supported) |
| `<wallpaperName>` | `chrome.storage.local` (when available) | Base64 image data for uploaded wallpaper files and legacy URL wallpapers | | `<wallpaperName>` | `chrome.storage.local` (when available) | Base64 image data for uploaded wallpaper files and legacy URL wallpapers |
| `vision-start:icon:<encoded-source-url>` | `chrome.storage.local` (when available) | CORS-readable website icon data URL, capped at 256KiB | | `vision-start:icon:<encoded-source-url>` | `chrome.storage.local` (when available) | CORS-readable website icon data URL, capped at 256KiB; entries no tile references are pruned ~5s after startup |
Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaperState`. Remote wallpaper URLs are included through `userWallpapers`; uploaded image data and rebuildable icon cache entries are not included. Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaperState`. Remote wallpaper URLs are included through `userWallpapers`; uploaded image data and rebuildable icon cache entries are not included.
@@ -217,16 +214,16 @@ Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaper
### Local development / preview ### Local development / preview
```bash ```bash
npm install npm install
npm run dev # Vite dev server (note: PROJECT.md says prefer `npm run build` for real testing) npm run dev # Vite dev server (note: AGENTS.md says prefer `npm run build` for real testing)
npm run build # Production build → dist/ npm run build # Production build → dist/
npm run preview # Serve built dist/ npm run preview # Serve built dist/
npx tsc --noEmit # Strict TypeScript check npm run typecheck # Strict TypeScript check (tsc --noEmit)
npx playwright install chromium npx playwright install chromium
node scripts/check_regressions.mjs # Run after building; isolated fixtures and temporary extension profile npm test # scripts/check_regressions.mjs; run after building; isolated fixtures and temporary extension profile
npm audit # Audit the locked dependency tree npm audit # Audit the locked dependency tree
``` ```
The regression script covers malformed persisted data, partial/legacy imports and quota rollback, wallpaper validation, server probe failure/timeout/cancellation, icon request concurrency/stream limits/cache eviction, hidden-tab clock and polling behavior, modal request cancellation and retained JS heap after repeated picker cycles, lazy settings chunks, and loading an unpacked Manifest V3 extension in an isolated Chromium profile. It uses existing Vite/Playwright dependencies and does not modify the normal browser profile or shipped metadata. The regression script covers malformed persisted data, website URL/clock font/wallpaper frequency normalization, icon cache pruning (including the `getKeys` path), Fetch-icon URL derivation, batched icon lookups, cross-tab category sync, partial/legacy imports and quota rollback, wallpaper validation, server probe failure/timeout/cancellation, icon request concurrency/stream limits/cache eviction, hidden-tab clock and polling behavior, modal request cancellation and retained JS heap after repeated picker cycles, lazy settings chunks, and loading an unpacked Manifest V3 extension in an isolated Chromium profile. It uses existing Vite/Playwright dependencies and does not modify the normal browser profile or shipped metadata.
### Chrome extension install (manual) ### Chrome extension install (manual)
Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked" from `chrome://extensions`. The release workflow automates this zip. Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked" from `chrome://extensions`. The release workflow automates this zip.
@@ -238,7 +235,7 @@ Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked"
- `release.yaml` validates each `vX.Y.Z` tag and stamps the version into `manifest.json` (`"version": "0.0.0"` → the tag) in each build checkout before producing the extension archive and production image. The release ZIP contains the contents of `dist/` at its root alongside `manifest.json` and `extension/icons/`, so the manifest's `index.html` new-tab path resolves directly. Before upload, the workflow tests ZIP integrity, extracts it, and validates the manifest version (no leading zeros, components at most 65535, not all zero), metadata, new-tab page, and icon paths. This is the Chrome Web Store upload ZIP; screenshots remain separate release assets. - `release.yaml` validates each `vX.Y.Z` tag and stamps the version into `manifest.json` (`"version": "0.0.0"` → the tag) in each build checkout before producing the extension archive and production image. The release ZIP contains the contents of `dist/` at its root alongside `manifest.json` and `extension/icons/`, so the manifest's `index.html` new-tab path resolves directly. Before upload, the workflow tests ZIP integrity, extracts it, and validates the manifest version (no leading zeros, components at most 65535, not all zero), metadata, new-tab page, and icon paths. This is the Chrome Web Store upload ZIP; screenshots remain separate release assets.
- **`pull-request.yaml`** — Triggers on pull request open, reopen, and synchronization. It builds and uploads a PR extension archive with `dist/` contents, `manifest.json`, and `extension/` at its root, then unpacks that archive into `dist/` in a separate Playwright job to generate the three demo screenshots, and uploads both artifacts (screenshots are retained for 30 days). For same-repository PRs, it maintains one Gitea PR comment with inline image attachments; fork PRs retain artifacts but skip the comment because their workflow token is read-only. - **`pull-request.yaml`** — Triggers on pull request open, reopen, and synchronization. It builds and uploads a PR extension archive with `dist/` contents, `manifest.json`, and `extension/` at its root, then unpacks that archive into `dist/` in a separate Playwright job to generate the three demo screenshots, and uploads both artifacts (screenshots are retained for 30 days). For same-repository PRs, it maintains one Gitea PR comment with inline image attachments; fork PRs retain artifacts but skip the comment because their workflow token is read-only.
- After `deploy_vision_start` succeeds, `release.yaml` uses Playwright Chromium against the deployed production page and seeds each browser context from `scripts/demoData.json`. It regenerates `home.png`, `editing.png`, and `configuration.png` at exactly 1280×800, uploads them as artifacts (retained for 30 days), and attaches them as individual Gitea release assets. - After `deploy_vision_start` succeeds, `release.yaml` uses Playwright Chromium against the deployed production page and seeds each browser context from `scripts/demoData.json`. It regenerates `home.png`, `editing.png`, and `configuration.png` at exactly 1280×800, uploads them as artifacts (retained for 30 days), and attaches them as individual Gitea release assets.
- **`main.yaml`** — Triggers on push to `main` (and `workflow_dispatch`). Builds, pushes a `staging` multi-arch (amd64/arm64) image to `git.ivanch.me/ivanch/vision-start:staging`, then SSH-deploys on the staging host via `docker compose up -d --force-recreate`. - **`main.yaml`** — Triggers on push to `main` (and `workflow_dispatch`). Type-checks (`npm run typecheck`, also run by `pull-request.yaml`), builds, pushes a `staging` multi-arch (amd64/arm64) image to `git.ivanch.me/ivanch/vision-start:staging`, then SSH-deploys on the staging host via `docker compose up -d --force-recreate`.
- **`release.yaml`** — Triggers on `v*` tags. Builds, zips `dist/` + `manifest.json` as `vision-start-<tag>.zip`, runs `scripts/check_virustotal.sh` against it (publishes analysis URL + detection ratio on the release body), creates a Gitea release, pushes a `latest` multi-arch image, and SSH-deploys to production. - **`release.yaml`** — Triggers on `v*` tags. Builds, zips `dist/` + `manifest.json` as `vision-start-<tag>.zip`, runs `scripts/check_virustotal.sh` against it (publishes analysis URL + detection ratio on the release body), creates a Gitea release, pushes a `latest` multi-arch image, and SSH-deploys to production.
Required CI secrets (referenced by the workflows): `REGISTRY_PASSWORD`, `HOST`, `USERNAME`, `KEY`, `PORT`, `STAGING_DIR`, `PROD_DIR`, `VIRUSTOTAL_APIKEY`. Required CI secrets (referenced by the workflows): `REGISTRY_PASSWORD`, `HOST`, `USERNAME`, `KEY`, `PORT`, `STAGING_DIR`, `PROD_DIR`, `VIRUSTOTAL_APIKEY`.
@@ -254,13 +251,13 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which is independently lazy-loaded by `ConfigurationModal`, so opening General/Theme/Clock settings does not load the drag-and-drop chunk. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop. - **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which is independently lazy-loaded by `ConfigurationModal`, so opening General/Theme/Clock settings does not load the drag-and-drop chunk. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
- **Import validation** accepts both export bundles and legacy objects containing serialized storage values. Files larger than 10MB are rejected before reading. Required keys are normalized and validated before writes; storage failures clear affected keys before restoring previous values so rollback also works near the quota limit. Omitted keys retain their existing settings. Importing marks the settings drawer as saved, preventing its cancel cleanup from restoring pre-import wallpapers. - **Import validation** accepts both export bundles and legacy objects containing serialized storage values. Files larger than 10MB are rejected before reading. Required keys are normalized and validated before writes; storage failures clear affected keys before restoring previous values so rollback also works near the quota limit. Omitted keys retain their existing settings. Importing marks the settings drawer as saved, preventing its cancel cleanup from restoring pre-import wallpapers.
- **Wallpaper mutations** reject duplicate/built-in/reserved names and non-image file uploads. The settings drawer serializes wallpaper mutations and reads the latest persisted index, only updates its list after persistence succeeds, and cancel does not reselect deleted wallpapers. - **Wallpaper mutations** reject duplicate/built-in/reserved names and non-image file uploads. The settings drawer serializes wallpaper mutations and reads the latest persisted index, only updates its list after persistence succeeds, and cancel does not reselect deleted wallpapers.
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. Remote wallpaper URLs work without it because their URLs live in `userWallpapers`; file upload is gated off when unavailable, and `addWallpaperToChromeStorageLocal` throws if called without it. - **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. Remote wallpaper URLs work without it because their URLs live in `userWallpapers`; file upload is gated off when unavailable, and `addWallpaperToChromeStorageLocal` throws if called without it. The extension uses the default ~10MB `chrome.storage.local` quota (no `unlimitedStorage`); quota failures on upload show a friendly "not enough extension storage" message.
- **Wallpaper rotation** uses a timeout scheduled from the persisted last-change timestamp, with checks on mount, focus, and return to a visible tab. Overdue backgrounds rotate once and start a new interval; frequency changes use elapsed time rather than restarting the countdown. Frequency is clamped to 1–48 hours and legacy `1d`/`2d` values remain supported. Invalid or future timestamps recover to the current time. State tracks the wallpaper name as well as its legacy index so reordering or shrinking the selection preserves the current wallpaper when possible. Missing data is skipped and an empty selection hides the background. Manual random changes exclude the current wallpaper and restart the interval. Opening settings does not reset rotation. Wallpaper state writes skip identical serialized values, and empty selections preserve the saved timestamp during refreshes to prevent repeated cross-tab updates. Storage events synchronize wallpaper changes across tabs; outdated asynchronous resolutions are discarded. URL caches are cleared when wallpaper inputs or stored wallpaper data change. - **Wallpaper rotation** uses a timeout scheduled from the persisted last-change timestamp, with checks on mount, focus, and return to a visible tab. Overdue backgrounds rotate once and start a new interval; frequency changes use elapsed time rather than restarting the countdown. Frequency is clamped to 1–48 hours and legacy `1d`/`2d` values remain supported. Invalid or future timestamps recover to the current time. State tracks the wallpaper name as well as its legacy index so reordering or shrinking the selection preserves the current wallpaper when possible. Missing data is skipped and an empty selection hides the background. Manual random changes exclude the current wallpaper and restart the interval. Opening settings does not reset rotation. Wallpaper state writes skip identical serialized values, and empty selections preserve the saved timestamp during refreshes to prevent repeated cross-tab updates. Storage events synchronize wallpaper changes across tabs; outdated asynchronous resolutions are discarded. URL caches are cleared when wallpaper inputs or stored wallpaper data change.
- **Icon picker** in `WebsiteEditModal` loads page-relative `icon-metadata.json` on focus, validates the response, and lazily expands color variants into up to 50 searchable results. Closing it cancels metadata fetching and filtering timers. - **Icon picker** in `WebsiteEditModal` loads page-relative `icon-metadata.json` on focus, validates the response, and lazily expands color variants into up to 50 searchable results. Closing it cancels metadata fetching and filtering timers.
- **Website icon cache** is persistent only in `chrome.storage.local`; it is rebuilt from website icon URLs after configuration import. Only `image/*` responses no larger than 256KiB are cached. Trusted public TLDs use Google S2 and the CORS-open `icon.horse` service for favicon caching, while non-trusted TLDs (local TLDs, IP addresses, custom domains) fetch `${origin}/favicon.ico` directly. No site HTML is ever fetched for icon discovery. - **Website icon cache** is persistent only in `chrome.storage.local`; it is rebuilt from website icon URLs after configuration import. Only `image/*` responses no larger than 256KiB are cached. Trusted public TLDs use Google S2 and the CORS-open `icon.horse` service for favicon caching, while non-trusted TLDs (local TLDs, IP addresses, custom domains) fetch `${origin}/favicon.ico` directly. No site HTML is ever fetched for icon discovery.
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation. - **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
- **Tile/icon sizing** uses static tile classes and numeric inline icon dimensions. The legacy pixel-size safelist remains in `tailwind.config.js`, but tile icons no longer depend on it. - **Tile/icon sizing** uses static tile classes and numeric inline icon dimensions. Tailwind v4 only generates classes found literally in source; there is no safelist.
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`. - **Project guidance note** in `AGENTS.md`: do not use `npm run dev` for real verification — use `npm run build`.
- **`.claude/` and `.env.local`** are local-only / gitignored; not part of shipped artifacts. - **`.claude/` and `.env.local`** are local-only / gitignored; not part of shipped artifacts.
--- ---
@@ -282,10 +279,11 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
| Website icon cache | `components/utils/iconService.ts`, `components/WebsiteTile.tsx`, `components/utils/StorageLocalManager.ts` | | Website icon cache | `components/utils/iconService.ts`, `components/WebsiteTile.tsx`, `components/utils/StorageLocalManager.ts` |
| Regression and memory checks | `scripts/check_regressions.mjs` (run after `npm run build`) | | Regression and memory checks | `scripts/check_regressions.mjs` (run after `npm run build`) |
| Shared IDs | `components/utils/idUtils.ts` | | Shared IDs | `components/utils/idUtils.ts` |
| URL validation (websites, server addresses) | `components/utils/urlUtils.ts` (`normalizeWebsiteUrl`, `parseServerAddress`) |
| Export/import config | `components/services/ConfigurationService.ts` (`exportConfig`, `importConfig`) | | Export/import config | `components/services/ConfigurationService.ts` (`exportConfig`, `importConfig`) |
| Build/release/PR pipelines | `scripts/prepare_release.sh`, `scripts/capture_screenshots.mjs`, `scripts/check_virustotal.sh`, `.gitea/workflows/pull-request.yaml`, `.gitea/workflows/release.yaml` | | Build/release/PR pipelines | `scripts/prepare_release.sh`, `scripts/capture_screenshots.mjs`, `scripts/check_virustotal.sh`, `.gitea/workflows/pull-request.yaml`, `.gitea/workflows/release.yaml` |
| Docker build | `Dockerfile` | | Docker build | `Dockerfile` |
--- ---
_Last updated: 2026-10-09. Generated as a general project overview; not a coding-style guide._ _Last updated: 2026-10-28. Generated as a general project overview; not a coding-style guide._
+54 -3
View File
@@ -42,13 +42,13 @@ globalThis.localStorage = {
globalThis.window = { chrome: { storage: { local: { globalThis.window = { chrome: { storage: { local: {
get: (keys, callback) => { get: (keys, callback) => {
chromeReads++; chromeReads++;
callback(Object.fromEntries(keys.map(key => [key, chromeStorage.get(key)]))); callback(keys === null ? Object.fromEntries(chromeStorage) : Object.fromEntries(keys.map(key => [key, chromeStorage.get(key)])));
}, },
set: (values, callback) => { set: (values, callback) => {
Object.entries(values).forEach(([key, value]) => chromeStorage.set(key, value)); Object.entries(values).forEach(([key, value]) => chromeStorage.set(key, value));
callback(); callback();
}, },
remove: (key, callback) => { chromeStorage.delete(key); callback(); }, remove: (keys, callback) => { [keys].flat().forEach(key => chromeStorage.delete(key)); callback(); },
} } } }; } } } };
globalThis.FileReader = class { globalThis.FileReader = class {
readAsDataURL(blob) { readAsDataURL(blob) {
@@ -59,10 +59,12 @@ globalThis.FileReader = class {
}); });
} }
}; };
const [{ ConfigurationService, DEFAULT_CONFIG, normalizeConfig, normalizeCategories }, icons, { default: ping }] = await Promise.all([ const [{ ConfigurationService, DEFAULT_CONFIG, normalizeConfig, normalizeCategories }, icons, { default: ping }, { normalizeWebsiteUrl }, { pruneCachedIconsFromChromeStorageLocal }] = await Promise.all([
loadModule('components/services/ConfigurationService.ts'), loadModule('components/services/ConfigurationService.ts'),
loadModule('components/utils/iconService.ts'), loadModule('components/utils/iconService.ts'),
loadModule('components/utils/jsping.ts'), loadModule('components/utils/jsping.ts'),
loadModule('components/utils/urlUtils.ts'),
loadModule('components/utils/StorageLocalManager.ts'),
]); ]);
const file = value => new File([JSON.stringify(value)], 'config.json', { type: 'application/json' }); const file = value => new File([JSON.stringify(value)], 'config.json', { type: 'application/json' });
const iconUrl = name => `https://www.google.com/s2/favicons?domain=${name}.com&sz=128`; const iconUrl = name => `https://www.google.com/s2/favicons?domain=${name}.com&sz=128`;
@@ -88,6 +90,42 @@ await test('malformed stored data recovers without shared defaults or unsafe pol
assert.deepEqual(ConfigurationService.loadUserWallpapers(), []); assert.deepEqual(ConfigurationService.loadUserWallpapers(), []);
}); });
await test('website URLs, clock fonts, and wallpaper frequency are normalized', async () => {
assert.equal(normalizeWebsiteUrl('github.com'), 'https://github.com/');
assert.equal(normalizeWebsiteUrl('localhost:8080/app'), 'https://localhost:8080/app');
assert.equal(normalizeWebsiteUrl('mailto:me@example.com'), 'mailto:me@example.com');
assert.equal(normalizeWebsiteUrl('javascript:alert(1)'), null);
assert.equal(normalizeWebsiteUrl('data:text/html,hi'), null);
assert.equal(normalizeWebsiteUrl(' '), null);
const websites = normalizeCategories([{ id: 'c', name: 'Group', websites: [
{ id: 'a', name: 'Bad', url: 'javascript:alert(1)' },
{ id: 'b', name: 'Bare', url: 'example.com' },
] }])[0].websites;
assert.deepEqual(websites.map(website => website.url), ['https://example.com/']);
assert.equal(normalizeConfig({ clock: { font: `'Orbitron', sans-serif` } }).clock.font, 'Helvetica');
assert.equal(normalizeConfig({ clock: { font: 'monospace' } }).clock.font, 'monospace');
assert.equal(normalizeConfig({ wallpaperFrequency: '2d' }).wallpaperFrequency, '48h');
assert.equal(normalizeConfig({ wallpaperFrequency: 'weekly' }).wallpaperFrequency, '24h');
chromeStorage.clear();
chromeStorage.set(`vision-start:icon:${encodeURIComponent(iconUrl('keep'))}`, 'data:image/png;base64,a');
chromeStorage.set(`vision-start:icon:${encodeURIComponent(iconUrl('drop'))}`, 'data:image/png;base64,b');
chromeStorage.set('My Wallpaper', 'data:image/png;base64,c');
assert.equal(await pruneCachedIconsFromChromeStorageLocal([iconUrl('keep')]), 1);
assert.deepEqual([...chromeStorage.keys()].sort(), ['My Wallpaper', `vision-start:icon:${encodeURIComponent(iconUrl('keep'))}`].sort());
chromeStorage.set(`vision-start:icon:${encodeURIComponent(iconUrl('stale'))}`, 'data:image/png;base64,d');
window.chrome.storage.local.getKeys = callback => callback([...chromeStorage.keys()]);
try {
const reads = chromeReads;
assert.equal(await pruneCachedIconsFromChromeStorageLocal([iconUrl('keep')]), 1);
assert.equal(chromeReads, reads, 'Pruning must list keys without reading stored values');
assert(chromeStorage.has('My Wallpaper'));
} finally { delete window.chrome.storage.local.getKeys; }
chromeStorage.clear();
assert.equal(await icons.getWebsiteIcon('mailto:me@example.com'), '');
assert.equal(await icons.getWebsiteIcon('github.com'), 'https://www.google.com/s2/favicons?domain=github.com&sz=128');
assert.equal(await icons.getWebsiteIcon('192.168.1.10:8080'), 'http://192.168.1.10:8080/favicon.ico');
});
await test('imports validate before writing, roll back failures, and preserve omitted settings', async () => { await test('imports validate before writing, roll back failures, and preserve omitted settings', async () => {
await assert.rejects(ConfigurationService.importConfig({ size: 10 * 1024 * 1024 + 1, text: () => assert.fail('Oversized files must not be read into memory') }), /10MB/); await assert.rejects(ConfigurationService.importConfig({ size: 10 * 1024 * 1024 + 1, text: () => assert.fail('Oversized files must not be read into memory') }), /10MB/);
storage.clear(); storage.clear();
@@ -164,6 +202,9 @@ await test('icon requests deduplicate, cap concurrency, reject oversized streams
const reads = chromeReads; const reads = chromeReads;
await icons.getCachedWebsiteIcon(iconUrl('site0')); await icons.getCachedWebsiteIcon(iconUrl('site0'));
assert(chromeReads > reads, 'The oldest large icon must leave the extra memory cache'); assert(chromeReads > reads, 'The oldest large icon must leave the extra memory cache');
const batchReads = chromeReads;
await Promise.all(['batch-a', 'batch-b', 'batch-c'].map(name => icons.getCachedWebsiteIcon(iconUrl(name))));
assert.equal(chromeReads - batchReads, 1, 'Icon cache lookups in one tick must share one storage read');
const before = requests; const before = requests;
await Promise.all([icons.cacheWebsiteIcon(iconUrl('shared')), icons.cacheWebsiteIcon(iconUrl('shared'))]); await Promise.all([icons.cacheWebsiteIcon(iconUrl('shared')), icons.cacheWebsiteIcon(iconUrl('shared'))]);
assert.equal(requests - before, 1); assert.equal(requests - before, 1);
@@ -242,6 +283,15 @@ await test('production UI survives malformed data, distinguishes server status,
await page.getByRole('button', { name: 'Edit page' }).click(); await page.getByRole('button', { name: 'Edit page' }).click();
await page.getByRole('button', { name: 'Move Beta left' }).click(); await page.getByRole('button', { name: 'Move Beta left' }).click();
assert.deepEqual(await page.locator('a[aria-label$="edit controls"]').evaluateAll(links => links.map(link => [...link.querySelectorAll('span')].at(-1).textContent)), ['Beta', 'Alpha']); assert.deepEqual(await page.locator('a[aria-label$="edit controls"]').evaluateAll(links => links.map(link => [...link.querySelectorAll('span')].at(-1).textContent)), ['Beta', 'Alpha']);
await page.clock.runFor(100);
await page.evaluate(() => {
const categories = JSON.parse(localStorage.getItem('categories'));
categories[0].websites.push({ id: 'g', name: 'Gamma', url: 'https://example.net', icon: '', categoryId: categories[0].id });
localStorage.setItem('categories', JSON.stringify(categories));
window.dispatchEvent(new StorageEvent('storage', { key: 'categories' }));
});
await page.locator('a[aria-label="Gamma edit controls"]').waitFor();
assert.deepEqual(await page.locator('a[aria-label$="edit controls"]').evaluateAll(links => links.map(link => [...link.querySelectorAll('span')].at(-1).textContent)), ['Beta', 'Alpha', 'Gamma']);
const openPicker = async () => { const openPicker = async () => {
await page.getByRole('button', { name: 'Edit Alpha', exact: true }).click(); await page.getByRole('button', { name: 'Edit Alpha', exact: true }).click();
const input = page.getByPlaceholder('Icon URL or name'); const input = page.getByPlaceholder('Icon URL or name');
@@ -263,6 +313,7 @@ await test('production UI survives malformed data, distinguishes server status,
const growth = (await heap()) - before; const growth = (await heap()) - before;
assert(growth < 2 * 1024 * 1024, `Retained JS heap grew by ${growth} bytes`); assert(growth < 2 * 1024 * 1024, `Retained JS heap grew by ${growth} bytes`);
assert.equal(metadataLoads, 16); assert.equal(metadataLoads, 16);
assert(!downloaded.some(url => url === `${baseUrl}/service`), 'Typing an icon name must not request it as a relative URL');
await page.evaluate(() => { await page.evaluate(() => {
const original = window.fetch; const original = window.fetch;
window.fetch = (url, options) => { window.fetch = (url, options) => {
+3 -1
View File
@@ -1 +1,3 @@
wget https://raw.githubusercontent.com/homarr-labs/dashboard-icons/refs/heads/main/metadata.json -O public/icon-metadata.json #!/bin/sh
set -eu
wget -q https://raw.githubusercontent.com/homarr-labs/dashboard-icons/refs/heads/main/metadata.json -O public/icon-metadata.json
-22
View File
@@ -1,22 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
safelist: [
'w-[24px]', 'h-[24px]',
'w-[28px]', 'h-[28px]',
'w-[32px]', 'h-[32px]',
'w-[34px]', 'h-[34px]',
'w-[36px]', 'h-[36px]',
'w-[40px]', 'h-[40px]',
'w-[42px]', 'h-[42px]',
'w-[48px]', 'h-[48px]',
// add any other sizes you use
],
}