Author SHA1 Message Date
ivanch 41eb568610 fixing wallpapers again and improving pipeline for release
Build and Release to Staging / Build Vision Start (push) Successful in 1m22s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m40s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 14s
Build and Release / build (push) Successful in 1m21s
Build and Release / virus-total-check (push) Successful in 1m29s
Build and Release / Build Vision Start Image (push) Successful in 2m42s
Build and Release / Deploy Vision Start (production) (push) Successful in 9s
Build and Release / Capture Vision Start Screenshots (push) Successful in 47s
Build and Release / release (push) Successful in 1m0s
2026-09-09 22:30:25 -03:00
ivanch 551bff1e5b adjusting icon cache
Build and Release to Staging / Build Vision Start (push) Successful in 12s
Build and Release / build (push) Successful in 10s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m9s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 8s
Build and Release / virus-total-check (push) Successful in 2m44s
Build and Release / Build Vision Start Image (push) Successful in 1m7s
Build and Release / Deploy Vision Start (production) (push) Successful in 7s
Build and Release / Capture Vision Start Screenshots (push) Successful in 51s
Build and Release / release (push) Successful in 7s
2026-08-11 22:10:36 -03:00
ivanch d391dc7135 Merge pull request 'Feat/general enhancements' (#4) from feat/general-enhancements into main
Build and Release to Staging / Build Vision Start (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m13s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 3s
Build and Release / build (push) Successful in 11m13s
Build and Release / virus-total-check (push) Successful in 4m37s
Build and Release / Build Vision Start Image (push) Successful in 1m7s
Build and Release / Deploy Vision Start (production) (push) Successful in 3s
Build and Release / Capture Vision Start Screenshots (push) Successful in 54s
Build and Release / release (push) Successful in 10s
Reviewed-on: #4
2026-08-12 00:25:13 +00:00
8 changed files with 181 additions and 105 deletions
+38 -7
View File
@@ -38,7 +38,7 @@ jobs:
cache: 'npm'
- name: Setup required tools
run: sudo apt-get install zip jq curl -y
run: sudo apt-get install zip unzip jq curl -y
- name: Install JS dependencies
run: npm ci
@@ -48,12 +48,12 @@ jobs:
bash scripts/prepare_release.sh
npm run build
- name: Prepare release
- name: Prepare Chrome Web Store package
run: |
mkdir -p vision-start
mv dist vision-start/
mv extension vision-start/
mv manifest.json vision-start/
mkdir vision-start
cp -a dist/. vision-start/
cp -a extension vision-start/
cp manifest.json vision-start/
- name: Set archive name
id: set-archive
@@ -70,7 +70,38 @@ jobs:
- name: Create zip archive
run: |
cd vision-start
zip -r "../${ARCHIVE_NAME}" *
zip -r -X "../${ARCHIVE_NAME}" .
- name: Validate Chrome Web Store archive
env:
RELEASE_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
unzip -t "$ARCHIVE_NAME"
PACKAGE_DIR=$(mktemp -d)
unzip -q "$ARCHIVE_NAME" -d "$PACKAGE_DIR"
node --input-type=module - "$PACKAGE_DIR" "${RELEASE_TAG#v}" <<'NODE'
import assert from 'node:assert/strict';
import { readFileSync, statSync } from 'node:fs';
import { resolve, sep } from 'node:path';
const [directory, version] = process.argv.slice(2);
const manifest = JSON.parse(readFileSync(resolve(directory, 'manifest.json'), 'utf8'));
assert.equal(manifest.manifest_version, 3);
assert.equal(manifest.version, version);
assert.match(version, /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/);
const parts = version.split('.').map(Number);
assert(parts.every(part => part <= 65535) && parts.some(part => part > 0), 'Invalid Chrome extension version');
assert(typeof manifest.name === 'string' && manifest.name.length > 0);
assert(typeof manifest.description === 'string' && manifest.description.length <= 132);
assert.equal(manifest.chrome_url_overrides.newtab, 'index.html');
assert(manifest.icons['128'], 'Missing store icon');
for (const file of [manifest.chrome_url_overrides.newtab, ...Object.values(manifest.icons)]) {
const path = resolve(directory, file);
assert(path.startsWith(resolve(directory) + sep), `Invalid package path: ${file}`);
assert(statSync(path).isFile(), `Missing packaged file: ${file}`);
}
console.log('Chrome Web Store archive structure and manifest validated');
NODE
- name: Upload artifact
uses: actions/upload-artifact@v3
+4 -12
View File
@@ -9,7 +9,7 @@ import CategoryGroup from './components/layout/CategoryGroup';
import Wallpaper from './components/Wallpaper';
import { ConfigurationService } from './components/services/ConfigurationService';
import { getAlignmentClass } from './components/utils/styleUtils';
import { getRandomWallpaperIndex } from './components/utils/wallpaperUtils';
import { getRandomWallpaperIndex, loadWallpaperState, saveWallpaperState } from './components/utils/wallpaperUtils';
import { PlusIcon } from './components/icons';
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
@@ -62,17 +62,9 @@ const App: React.FC = () => {
const names = config.currentWallpapers;
if (names.length === 0) return;
try {
const state = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
const current = typeof state.currentIndex === 'number' ? state.currentIndex : 0;
const safeCurrent = current < 0 || current >= names.length ? 0 : current;
const randomIndex = getRandomWallpaperIndex(names.length, safeCurrent);
localStorage.setItem(
'wallpaperState',
JSON.stringify({
lastWallpaperChange: new Date().toISOString(),
currentIndex: randomIndex,
}),
);
const { currentIndex } = loadWallpaperState(names);
const randomIndex = getRandomWallpaperIndex(names.length, currentIndex);
saveWallpaperState(names, randomIndex, Date.now());
} catch (error) {
console.error('Error randomizing wallpaper state', error);
}
-1
View File
@@ -51,7 +51,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
useEffect(() => {
onWallpaperChange({ currentWallpapers: config.currentWallpapers });
ConfigurationService.resetWallpaperState();
}, [config.currentWallpapers]);
const handleClose = () => {
+40 -39
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { baseWallpapers } from './utils/baseWallpapers';
import { Wallpaper as WallpaperType } from '../types';
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
import { getRandomWallpaperIndex, getWallpaperFrequencyMs } from './utils/wallpaperUtils';
import { getRandomWallpaperIndex, getWallpaperFrequencyMs, loadWallpaperState, saveWallpaperState } from './utils/wallpaperUtils';
interface WallpaperProps {
wallpaperNames: string[];
@@ -64,41 +64,31 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let revision = 0;
wallpaperUrlCache.clear();
const updateWallpaper = async () => {
const request = ++revision;
clearTimeout(timer);
if (wallpaperNames.length === 0) {
if (!cancelled) setImageUrl(undefined);
localStorage.setItem(
'wallpaperState',
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }),
);
setImageUrl(undefined);
saveWallpaperState([], 0, loadWallpaperState([]).lastChange);
return;
}
const wallpaperState = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
const lastChange = wallpaperState.lastWallpaperChange
? new Date(wallpaperState.lastWallpaperChange).getTime()
: 0;
const now = Date.now();
const { currentIndex, lastChange } = loadWallpaperState(wallpaperNames, now);
const freqMs = getWallpaperFrequencyMs(wallpaperFrequency);
let storedIndex =
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
if (storedIndex < 0 || storedIndex >= wallpaperNames.length) storedIndex = 0;
const shouldRotate = now - lastChange >= freqMs;
const shouldRotate = wallpaperNames.length > 1 && now - lastChange >= freqMs;
let resolvedIndex = shouldRotate
? getRandomWallpaperIndex(wallpaperNames.length, storedIndex)
: storedIndex;
const tried = new Set<number>();
? getRandomWallpaperIndex(wallpaperNames.length, currentIndex)
: currentIndex;
let resolvedUrl: string | undefined;
for (let i = 0; i < wallpaperNames.length; i++) {
if (tried.has(resolvedIndex)) break;
tried.add(resolvedIndex);
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
if (cancelled) return;
if (cancelled || request !== revision) return;
if (url) {
resolvedUrl = url;
break;
@@ -106,26 +96,37 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
}
if (cancelled) return;
const nextLastChange = shouldRotate
? new Date().toISOString()
: wallpaperState.lastWallpaperChange || new Date().toISOString();
localStorage.setItem(
'wallpaperState',
JSON.stringify({
lastWallpaperChange: nextLastChange,
currentIndex: resolvedIndex,
}),
);
if (cancelled || request !== revision) return;
const nextLastChange = shouldRotate || resolvedIndex !== currentIndex ? Date.now() : lastChange;
saveWallpaperState(wallpaperNames, resolvedIndex, nextLastChange);
setImageUrl(resolvedUrl);
if (wallpaperNames.length > 1) {
timer = setTimeout(refresh, Math.max(1, nextLastChange + freqMs - Date.now()));
}
};
updateWallpaper();
const refresh = () => {
void updateWallpaper().catch(error => console.error('Error updating wallpaper', error));
};
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') refresh();
};
const onStorage = (event: StorageEvent) => {
if (event.key === 'wallpaperState' || event.key === 'userWallpapers' || event.key === null) {
wallpaperUrlCache.clear();
refresh();
}
};
refresh();
document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('focus', refresh);
window.addEventListener('storage', onStorage);
return () => {
cancelled = true;
clearTimeout(timer);
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('focus', refresh);
window.removeEventListener('storage', onStorage);
};
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
@@ -147,4 +148,4 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
);
};
export default Wallpaper;
export default Wallpaper;
@@ -205,14 +205,4 @@ export const ConfigurationService = {
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
};
},
resetWallpaperState(): void {
localStorage.setItem(
'wallpaperState',
JSON.stringify({
lastWallpaperChange: new Date().toISOString(),
currentIndex: 0,
}),
);
},
};
+65 -29
View File
@@ -47,39 +47,72 @@ const blobToDataUrl = (blob: Blob): Promise<string> =>
reader.readAsDataURL(blob);
});
const TRUSTED_TLDS = new Set([
'com',
'org',
'net',
'gov',
'edu',
'io',
'co',
'dev',
'app',
'me',
'ai',
'info',
'br',
'uk',
'de',
]);
const isTrustedTld = (hostname: string): boolean => {
if (!hostname || !hostname.includes('.')) return false;
const parts = hostname.toLowerCase().split('.');
const lastPart = parts[parts.length - 1];
return TRUSTED_TLDS.has(lastPart);
};
const getIconFetchSource = (iconUrl: string): string | null => {
try {
const url = new URL(iconUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
if (
(url.hostname === 'www.google.com' || url.hostname === 'google.com') &&
url.pathname.startsWith('/s2/favicons')
) {
const domain = url.searchParams.get('domain');
if (domain) {
let cleanHost = domain.trim().replace(/^https?:\/\//i, '');
try {
cleanHost = new URL(`https://${cleanHost}`).hostname;
} catch {
// ignore parsing error
}
if (cleanHost && isTrustedTld(cleanHost)) {
return `https://icon.horse/icon/${encodeURIComponent(cleanHost)}`;
}
}
}
return null;
} catch {
return null;
}
};
async function getWebsiteIcon(rawUrl: string): Promise<string> {
let targetUrl = rawUrl.trim();
if (targetUrl && !/^https?:\/\//i.test(targetUrl)) {
targetUrl = `https://${targetUrl}`;
}
const trimmed = rawUrl.trim();
const hasProtocol = /^https?:\/\//i.test(trimmed);
const targetUrl = hasProtocol ? trimmed : `https://${trimmed}`;
try {
const response = await fetch(targetUrl);
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const appleTouchIcon = doc.querySelector('link[rel="apple-touch-icon"]');
if (appleTouchIcon) {
const href = appleTouchIcon.getAttribute('href');
if (href) {
return new URL(href, targetUrl).href;
const parsed = new URL(targetUrl);
if (!isTrustedTld(parsed.hostname)) {
if (!hasProtocol) {
return `http://${parsed.host}/favicon.ico`;
}
return `${parsed.origin}/favicon.ico`;
}
const iconLink = doc.querySelector('link[rel="icon"][type="image/png"]') || doc.querySelector('link[rel="icon"]');
if (iconLink) {
const href = iconLink.getAttribute('href');
if (href) {
return new URL(href, targetUrl).href;
}
}
} catch (error) {
console.error('Error fetching and parsing HTML for icon:', error);
}
try {
const hostname = new URL(targetUrl).hostname;
return `https://www.google.com/s2/favicons?domain=${hostname}&sz=128`;
return `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=128`;
} catch {
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(rawUrl)}&sz=128`;
}
@@ -125,8 +158,11 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
const cachedIcon = await getCachedWebsiteIcon(iconUrl);
if (cachedIcon) return cachedIcon;
const fetchSource = getIconFetchSource(iconUrl);
if (!fetchSource) return null;
try {
const response = await fetch(iconUrl, { mode: 'cors' });
const response = await fetch(fetchSource);
if (!response.ok || response.type === 'opaque') return null;
const blob = await response.blob();
+28 -1
View File
@@ -21,4 +21,31 @@ export const getRandomWallpaperIndex = (wallpaperCount: number, currentIndex: nu
if (wallpaperCount <= 1) return 0;
const offset = Math.floor(Math.random() * (wallpaperCount - 1)) + 1;
return (currentIndex + offset) % wallpaperCount;
};
};
export const loadWallpaperState = (names: string[], now = Date.now()) => {
let state: Record<string, unknown> = {};
try {
const parsed: unknown = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
if (parsed && typeof parsed === 'object') state = parsed as Record<string, unknown>;
} catch {
state = {};
}
const namedIndex = typeof state.currentName === 'string' ? names.indexOf(state.currentName) : -1;
const index = namedIndex >= 0 ? namedIndex : typeof state.currentName === 'string' ? 0 : state.currentIndex;
const currentIndex = typeof index === 'number' && Number.isInteger(index) && index >= 0 && index < names.length ? index : 0;
const timestamp = typeof state.lastWallpaperChange === 'string' ? Date.parse(state.lastWallpaperChange) : NaN;
const lastChange = Number.isFinite(timestamp) && timestamp <= now && timestamp >= 0 ? timestamp : now;
return { currentIndex, lastChange };
};
export const saveWallpaperState = (names: string[], currentIndex: number, lastChange: number): void => {
const payload = JSON.stringify({
currentIndex,
currentName: names[currentIndex],
lastWallpaperChange: new Date(lastChange).toISOString(),
});
if (localStorage.getItem('wallpaperState') !== payload) {
localStorage.setItem('wallpaperState', payload);
}
};
+6 -6
View File
@@ -58,13 +58,13 @@ The startpage is composed of widgets and a configuration panel:
- **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`).
- 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.
- **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 from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service. Tiles opportunistically cache CORS-readable icon responses as data URLs in `chrome.storage.local` and retain the original URL when caching is unavailable.
- **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).
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles.
- **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:
- Website tile icons check a deterministic `vision-start:icon:<encoded-source-url>` entry in `chrome.storage.local` before loading the external URL. Cache population uses ordinary CORS-enabled `fetch`, 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. 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. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load. `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.
- `Clock` updates on the minute boundary (one `setTimeout``setInterval(60_000)`) instead of every second.
@@ -183,7 +183,7 @@ Storage layout (browser-side):
| `config` | `localStorage` | The full `Config` JSON |
| `categories` | `localStorage` | `Category[]` JSON |
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names plus URLs for remote wallpapers) |
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex }` for rotation |
| `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 |
| `vision-start:icon:<encoded-source-url>` | `chrome.storage.local` (when available) | CORS-readable website icon data URL, capped at 256KiB |
@@ -225,7 +225,7 @@ Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked"
`Dockerfile` builds in Node 22 Alpine (`npm ci` → runs `scripts/prepare_release.sh``npm run build`) and serves `/app/dist` + `manifest.json` via nginx:alpine on port 80.
### CI/CD (Gitea Actions)
- `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.
- `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 containing `dist/`, unpacks that archive 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.
- **`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`.
@@ -243,9 +243,9 @@ 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, both of which share the `ModalShell` component.
- **`@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. 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.
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, selects a random non-current wallpaper when the frequency window has elapsed, and writes its index 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 "Random Wallpaper" button in the Theme tab also picks a random non-current wallpaper and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
- **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 148 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 `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
- **Website icon cache** is persistent only in `chrome.storage.local`; it is rebuilt from website icon URLs after configuration import. Only CORS-readable `http`/`https` image responses no larger than 256KiB are cached.
- **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.
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.