8 Commits

Author SHA1 Message Date
fee538f044 fixing issue with icon picker
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 7s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m3s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 7s
Build and Release / build (push) Successful in 15s
Build and Release / virus-total-check (push) Successful in 38s
Build and Release / release (push) Successful in 4s
Build and Release / Build Vision Start Image (push) Successful in 1m3s
Build and Release / Deploy Vision Start (production) (push) Successful in 2s
2026-07-10 21:18:26 -03:00
48ec764880 improving icons and fixing pipeline
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 9s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m4s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 2s
Build and Release / build (push) Successful in 51s
Build and Release / virus-total-check (push) Successful in 54s
Build and Release / release (push) Successful in 7s
Build and Release / Build Vision Start Image (push) Successful in 1m3s
Build and Release / Deploy Vision Start (production) (push) Successful in 2s
2026-07-10 20:58:06 -03:00
babd31548c fixing some things
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m3s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 2s
2026-07-10 20:34:43 -03:00
95ae04ecd2 moving icons
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m3s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 7s
2026-07-10 20:26:04 -03:00
7a137abb66 updating manifest and adding icons
Some checks failed
Build and Release to Staging / Build Vision Start (push) Failing after 7s
Build and Release to Staging / Build Vision Start Image (push) Has been skipped
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been skipped
2026-07-10 20:23:56 -03:00
023b2ffdc5 updating pipelines 2026-07-10 20:23:43 -03:00
c9dafd76d1 adding extra theme options
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 13s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m25s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 7s
2026-07-10 20:11:40 -03:00
254d8e26b6 Merge pull request 'feat/visual-rework' (#2) from feat/visual-rework into main
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 44s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m29s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 6s
Build and Release / build (push) Successful in 1m9s
Build and Release / virus-total-check (push) Successful in 1m24s
Build and Release / release (push) Successful in 8s
Build and Release / Build Vision Start Image (push) Successful in 1m3s
Build and Release / Deploy Vision Start (production) (push) Successful in 2s
Reviewed-on: #2
2026-07-04 18:03:17 +00:00
14 changed files with 135 additions and 54 deletions

View File

@@ -15,27 +15,38 @@ env:
jobs: jobs:
build: build:
name: Build Vision Start name: Build Vision Start
if: gitea.event_name == 'push'
runs-on: ubuntu-amd64 runs-on: ubuntu-amd64
steps: steps:
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup required tools
run: sudo apt-get install zip jq curl -y
- name: Install JS dependencies - name: Install JS dependencies
run: npm install run: npm install
- name: Run build - name: Run build
run: npm run build
- name: Package dist as zip
run: | run: |
cd dist bash scripts/prepare_release.sh
zip -r ../vision-start-build.zip . npm run build
- name: Upload build artifact - name: Prepare release
run: |
mv dist vision-start/
mv extension vision-start/
mv manifest.json vision-start/
- name: Create zip archive
run: |
cd vision-start
zip -r ../vision-start-${{ gitea.ref_name }}.zip *
- name: Upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: vision-start-build name: release-zip
path: vision-start-build.zip path: vision-start-${{ gitea.ref_name }}.zip
retention-days: 30
build_vision_start: build_vision_start:
name: Build Vision Start Image name: Build Vision Start Image

View File

@@ -19,19 +19,29 @@ jobs:
steps: steps:
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup required tools - name: Setup required tools
run: sudo apt-get install zip jq curl -y run: sudo apt-get install zip jq curl -y
- name: Install JS dependencies - name: Install JS dependencies
run: npm install run: npm install
- name: Run build - name: Run build
run: npm run build
- name: Prepare release
run: | run: |
bash scripts/prepare_release.sh bash scripts/prepare_release.sh
npm run build
- name: Prepare release
run: |
mv dist vision-start/ mv dist vision-start/
mv extension vision-start/
mv manifest.json vision-start/ mv manifest.json vision-start/
- name: Create zip archive - name: Create zip archive
run: zip -r vision-start-${{ gitea.ref_name }}.zip vision-start run: |
cd vision-start
zip -r ../vision-start-${{ gitea.ref_name }}.zip *
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:

View File

@@ -1,9 +1,9 @@
<div style="display: flex; justify-content: center; font-size: 2rem; font-weight: bold;"> <div style="display: flex; justify-content: center; align-items: center; font-size: 2rem; font-weight: bold;">
Vision Start <img src="extension/icons/vision-48.png" alt="Vision Start" width="32" height="32" style="margin-right: 1rem;"> Vision Start
</div> </div>
<div style="display: flex; justify-content: center; font-size: 1.5rem;"> <div style="display: flex; justify-content: center; font-size: 1.5rem;">
A light liquid-glass, modern and customizable startpage built with React. A light, modern and customizable startpage built with React.
</div> </div>
<span style="display: block; text-align: center; font-size: 1.2rem;">Try it here: <a href="http://vision-start.ivanch.me">http://vision-start.ivanch.me</a></span> <span style="display: block; text-align: center; font-size: 1.2rem;">Try it here: <a href="http://vision-start.ivanch.me">http://vision-start.ivanch.me</a></span>

View File

@@ -13,15 +13,18 @@ interface WallpaperProps {
wallpaperVersion: number; wallpaperVersion: number;
} }
const MIN_WALLPAPER_FREQUENCY_MS = 60 * 60 * 1000;
const MAX_WALLPAPER_FREQUENCY_MS = 48 * 60 * 60 * 1000;
const DEFAULT_WALLPAPER_FREQUENCY_MS = 24 * 60 * 60 * 1000;
const parseFrequencyToMs = (freq: string): number => { const parseFrequencyToMs = (freq: string): number => {
if (!freq) return 24 * 60 * 60 * 1000; // default 1 day if (!freq) return DEFAULT_WALLPAPER_FREQUENCY_MS;
const match = freq.match(/(\d+)(h|d)/); const match = freq.match(/^(\d+)(h|d)$/);
if (!match) return 24 * 60 * 60 * 1000; if (!match) return DEFAULT_WALLPAPER_FREQUENCY_MS;
const value = parseInt(match[1], 10); const value = parseInt(match[1], 10);
const unit = match[2]; const unit = match[2];
if (unit === 'h') return value * 60 * 60 * 1000; const frequencyMs = unit === 'd' ? value * 24 * 60 * 60 * 1000 : value * 60 * 60 * 1000;
if (unit === 'd') return value * 24 * 60 * 60 * 1000; return Math.min(MAX_WALLPAPER_FREQUENCY_MS, Math.max(MIN_WALLPAPER_FREQUENCY_MS, frequencyMs));
return 24 * 60 * 60 * 1000;
}; };
const wallpaperUrlCache = new Map<string, string | undefined>(); const wallpaperUrlCache = new Map<string, string | undefined>();

View File

@@ -33,12 +33,16 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
const [icon, setIcon] = useState(website ? website.icon : ''); const [icon, setIcon] = useState(website ? website.icon : '');
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[]>(() => iconMetadataCache ?? []);
const [iconsFetched, setIconsFetched] = useState(false); const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
const debounceRef = useRef<number | null>(null); const debounceRef = useRef<number | null>(null);
const ensureIconMetadata = () => { const ensureIconMetadata = () => {
if (iconMetadataCache || iconsFetched) return; if (iconMetadataCache) {
setIconMetadata(iconMetadataCache);
return;
}
if (iconsFetched) return;
setIconsFetched(true); setIconsFetched(true);
fetch('/icon-metadata.json', { cache: 'force-cache' }) fetch('/icon-metadata.json', { cache: 'force-cache' })
.then(response => response.json()) .then(response => response.json())

View File

@@ -21,6 +21,24 @@ const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
return { '--range-progress': `${progress}%` }; return { '--range-progress': `${progress}%` };
}; };
const MIN_WALLPAPER_FREQUENCY_HOURS = 1;
const MAX_WALLPAPER_FREQUENCY_HOURS = 48;
const DEFAULT_WALLPAPER_FREQUENCY_HOURS = 24;
const clampWallpaperFrequencyHours = (hours: number): number =>
Math.min(MAX_WALLPAPER_FREQUENCY_HOURS, Math.max(MIN_WALLPAPER_FREQUENCY_HOURS, Math.round(hours)));
const getWallpaperFrequencyHours = (frequency: string): number => {
const match = frequency.match(/^(\d+)(h|d)$/);
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
const value = Number(match[1]);
const hours = match[2] === 'd' ? value * 24 : value;
return clampWallpaperFrequencyHours(hours);
};
const formatWallpaperFrequency = (hours: number): string =>
`${hours} ${hours === 1 ? 'hour' : 'hours'}`;
const ThemeTab: React.FC<ThemeTabProps> = ({ const ThemeTab: React.FC<ThemeTabProps> = ({
config, config,
onChange, onChange,
@@ -35,6 +53,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
const [newWallpaperName, setNewWallpaperName] = useState(''); const [newWallpaperName, setNewWallpaperName] = useState('');
const [newWallpaperUrl, setNewWallpaperUrl] = useState(''); const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency);
const handleAddWallpaper = async () => { const handleAddWallpaper = async () => {
if (newWallpaperUrl.trim() === '') return; if (newWallpaperUrl.trim() === '') return;
@@ -75,19 +94,25 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && ( {Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label> <label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
<Dropdown <div className="flex items-center gap-4">
name="wallpaperFrequency" <input
value={config.wallpaperFrequency} type="range"
onChange={(e) => onChange({ wallpaperFrequency: e.target.value as string })} min={MIN_WALLPAPER_FREQUENCY_HOURS}
options={[ max={MAX_WALLPAPER_FREQUENCY_HOURS}
{ value: '1h', label: '1 hour' }, step="1"
{ value: '3h', label: '3 hours' }, value={wallpaperFrequencyHours}
{ value: '6h', label: '6 hours' }, onChange={(e) => onChange({ wallpaperFrequency: `${Number(e.target.value)}h` })}
{ value: '12h', label: '12 hours' }, className="liquid-range"
{ value: '1d', label: '1 day' }, style={getRangeStyle(
{ value: '2d', label: '2 days' }, wallpaperFrequencyHours,
]} MIN_WALLPAPER_FREQUENCY_HOURS,
/> MAX_WALLPAPER_FREQUENCY_HOURS,
)}
/>
<span className="w-20 text-right text-sm text-slate-200">
{formatWallpaperFrequency(wallpaperFrequencyHours)}
</span>
</div>
</div> </div>
)} )}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">

View File

@@ -43,13 +43,37 @@ const safeParse = (value: string | null): unknown => {
const toStorageString = (value: unknown): string => const toStorageString = (value: unknown): string =>
typeof value === 'string' ? value : JSON.stringify(value); typeof value === 'string' ? value : JSON.stringify(value);
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);
const deepMerge = <T>(base: T, stored: unknown): T => {
if (isPlainObject(base)) {
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
const storedObj = isPlainObject(stored) ? stored : {};
for (const key of Object.keys(result)) {
if (Object.prototype.hasOwnProperty.call(storedObj, key)) {
result[key] = deepMerge(result[key], storedObj[key]);
}
}
return result as T;
}
if (Array.isArray(base)) {
return (Array.isArray(stored) ? stored : base) as T;
}
if (stored === null) return base;
return typeof stored === typeof base ? (stored as T) : base;
};
export const normalizeConfig = (stored: unknown): Config =>
deepMerge(DEFAULT_CONFIG, stored);
export const ConfigurationService = { export const ConfigurationService = {
loadConfig(): Config { loadConfig(): Config {
try { try {
const stored = localStorage.getItem('config'); const stored = localStorage.getItem('config');
if (stored) { if (stored) {
const parsed = JSON.parse(stored); const parsed = JSON.parse(stored);
return { ...DEFAULT_CONFIG, ...parsed }; return normalizeConfig(parsed);
} }
} catch (error) { } catch (error) {
console.error('Error parsing config from localStorage', error); console.error('Error parsing config from localStorage', error);
@@ -159,12 +183,12 @@ export const ConfigurationService = {
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`); throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
} }
const importedConfig = (localStorageData as Record<string, unknown>).config as Config; const importedConfig = (localStorageData as Record<string, unknown>).config;
const importedUserWallpapers = (localStorageData as Record<string, unknown>) const importedUserWallpapers = (localStorageData as Record<string, unknown>)
.userWallpapers as Wallpaper[]; .userWallpapers;
return { return {
config: importedConfig || { ...DEFAULT_CONFIG }, config: normalizeConfig(importedConfig),
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [], userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
}; };
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

View File

@@ -1,15 +1,20 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Vision Startpage", "name": "Vision Startpage",
"version": "1.0", "version": "1.0.0",
"description": "A beautiful and customizable startpage for your browser.", "description": "A light, modern and customizable startpage.",
"chrome_url_overrides": { "chrome_url_overrides": {
"newtab": "index.html" "newtab": "index.html"
}, },
"permissions": [ "permissions": [
"storage" "storage"
], ],
"icons": {
"16": "extension/icons/vision-16.png",
"48": "extension/icons/vision-48.png",
"128": "extension/icons/vision-128.png"
},
"content_security_policy": { "content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self';" "extension_pages": "script-src 'self'; object-src 'self';"
} }
} }

View File

@@ -1,6 +1,6 @@
--- ---
project_name: Vision Start project_name: Vision Start
date: 2026-07-03 date: 2026-07-08
type: general_overview type: general_overview
--- ---
@@ -54,7 +54,7 @@ The startpage is composed of widgets and a configuration panel:
- **Clock** — Optional header clock with selectable size, font, and 12h/24h format. - **Clock** — Optional header clock with selectable size, font, 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. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency. - **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency.
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Supports rotating through multiple wallpapers at a cadence (`1h``2d`). - **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Supports rotating 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; stored in `chrome.storage.local` when available, falling back to storing the URL directly on CORS failure. - User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL; stored in `chrome.storage.local` when available, falling back to storing the URL directly on CORS failure.
- **Icon library & auto-fetch** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service. - **Icon library & auto-fetch** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service.
@@ -68,7 +68,7 @@ Performance notes:
- `Clock` updates on the minute boundary (one `setTimeout``setInterval(60_000)`) instead of every second. - `Clock` updates on the minute boundary (one `setTimeout``setInterval(60_000)`) instead of every second.
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles. - `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles.
- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings. - `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings.
- Icon metadata (`/icon-metadata.json`) is module-level cached, 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. - Icon metadata (`/icon-metadata.json`) is module-level cached and hydrated into each icon-picker instance, 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.
- `Wallpaper` caches resolved wallpaper URLs in a module-level `Map`; its image transition and readability overlay classes live in `index.css`. - `Wallpaper` caches resolved wallpaper URLs in a module-level `Map`; its image transition and readability overlay classes live in `index.css`.
Planned / To-do (tracked in `README.md`): Planned / To-do (tracked in `README.md`):
@@ -88,7 +88,6 @@ vision-start/
├── types.ts # Core domain types (Config, Category, Website, Server, Wallpaper) ├── types.ts # Core domain types (Config, Category, Website, Server, Wallpaper)
├── constants.tsx # DEFAULT_CATEGORIES seed data ├── constants.tsx # DEFAULT_CATEGORIES seed data
├── manifest.json # Chrome MV3 manifest (newtab override, storage permission) ├── manifest.json # Chrome MV3 manifest (newtab override, storage permission)
├── icon.png # Extension icon source
├── components/ ├── components/
│ ├── Clock.tsx # Header clock widget │ ├── Clock.tsx # Header clock widget
@@ -109,7 +108,7 @@ vision-start/
│ │ │ │
│ ├── configuration/ │ ├── configuration/
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size │ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
│ │ ├── ThemeTab.tsx # Background selection, wallpaper mgmt, blur/brightness/opacity │ │ ├── ThemeTab.tsx # Background selection, wallpaper cadence, wallpaper mgmt, blur/brightness/opacity
│ │ ├── ClockTab.tsx # Clock enable/size/font/format │ │ ├── ClockTab.tsx # Clock enable/size/font/format
│ │ └── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder) │ │ └── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
│ │ │ │
@@ -158,9 +157,9 @@ The shape of all persisted data lives in `types.ts`:
- **`Server`** — `id`, `name`, `address` - **`Server`** — `id`, `name`, `address`
- **`Category`** — `id`, `name`, `websites: Website[]` - **`Category`** — `id`, `name`, `websites: Website[]`
- **`Wallpaper`** — `name`, optional `url` or `base64` - **`Wallpaper`** — `name`, optional `url` or `base64`
- **`Config`** — Everything else: title, wallpaper list + 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. `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. 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):
@@ -224,7 +223,7 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead. - **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead.
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `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 itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws. - **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws.
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, advances the index if the frequency window has elapsed, and writes it back. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer. - **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, advances the index if the frequency window has elapsed, and writes it back; the frequency is clamped to 148 hours, while older saved values like `1d`/`2d` still resolve to their hour equivalents. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable. - **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
- **`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.
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.). - **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
@@ -253,4 +252,4 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
--- ---
_Last updated: 2026-07-03. Generated as a general project overview; not a coding-style guide._ _Last updated: 2026-07-08. Generated as a general project overview; not a coding-style guide._

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 265 KiB