Compare commits
11 Commits
1dfbe74280
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| fee538f044 | |||
| 48ec764880 | |||
| babd31548c | |||
| 95ae04ecd2 | |||
| 7a137abb66 | |||
| 023b2ffdc5 | |||
| c9dafd76d1 | |||
| 254d8e26b6 | |||
| c59b7edd82 | |||
| 52d954d9ca | |||
| f35ea01c71 |
@@ -15,27 +15,38 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
name: Build Vision Start
|
||||
if: gitea.event_name == 'push'
|
||||
runs-on: ubuntu-amd64
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup required tools
|
||||
run: sudo apt-get install zip jq curl -y
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
|
||||
- name: Package dist as zip
|
||||
run: |
|
||||
cd dist
|
||||
zip -r ../vision-start-build.zip .
|
||||
bash scripts/prepare_release.sh
|
||||
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
|
||||
with:
|
||||
name: vision-start-build
|
||||
path: vision-start-build.zip
|
||||
retention-days: 30
|
||||
name: release-zip
|
||||
path: vision-start-${{ gitea.ref_name }}.zip
|
||||
|
||||
build_vision_start:
|
||||
name: Build Vision Start Image
|
||||
|
||||
@@ -19,19 +19,29 @@ jobs:
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup required tools
|
||||
run: sudo apt-get install zip jq curl -y
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
- name: Prepare release
|
||||
run: |
|
||||
bash scripts/prepare_release.sh
|
||||
npm run build
|
||||
|
||||
- name: Prepare release
|
||||
run: |
|
||||
mv dist vision-start/
|
||||
mv extension vision-start/
|
||||
mv manifest.json vision-start/
|
||||
|
||||
- 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
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
||||
@@ -14,7 +14,7 @@ Guidance for AI agents (and humans) working on Vision Start. Read before making
|
||||
|
||||
## 1. Styling guidance
|
||||
|
||||
The design language is **light liquid glass**: translucent bright surfaces, refractive edge highlights, backdrop blur, soft shadows, cyan accents, and iOS-like easing.
|
||||
The design language is **soft liquid glass**: lightly translucent matte surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan accents, and iOS-like easing.
|
||||
|
||||
### Surface recipe (use consistently)
|
||||
- **Tiles / floating controls / light surfaces:** `liquid-surface` plus `liquid-control` / `liquid-tile` / `liquid-focus` as appropriate.
|
||||
@@ -23,6 +23,7 @@ The design language is **light liquid glass**: translucent bright surfaces, refr
|
||||
- **Buttons:** `liquid-button` plus one of `liquid-button-primary`, `liquid-button-success`, `liquid-button-secondary`, or `liquid-button-danger`.
|
||||
- **Add/edit surfaces:** add tiles use `liquid-ghost-tile`; tile edit controls use `liquid-edit-toolbar` and `liquid-edit-action`.
|
||||
- **Full-screen modal overlay:** `liquid-modal-backdrop`.
|
||||
- Keep the glass effect subtle: avoid high-opacity white highlights, heavy blur, strong saturation, or oversized glow unless a specific component needs emphasis.
|
||||
|
||||
### Color tokens
|
||||
- Accent / focus / selection: cyan via `liquid-focus`, `cyan-400`, `cyan-200`, and `liquid-button-primary`
|
||||
|
||||
18
App.tsx
@@ -177,23 +177,15 @@ const App: React.FC = () => {
|
||||
const websiteIndex = category.websites.findIndex(w => w.id === website.id);
|
||||
if (websiteIndex === -1) return;
|
||||
|
||||
const newCategories = [...categories];
|
||||
const targetIndex = direction === 'left' ? websiteIndex - 1 : websiteIndex + 1;
|
||||
if (targetIndex < 0 || targetIndex >= category.websites.length) return;
|
||||
|
||||
const newWebsites = [...category.websites];
|
||||
const [movedWebsite] = newWebsites.splice(websiteIndex, 1);
|
||||
newWebsites.splice(targetIndex, 0, movedWebsite);
|
||||
|
||||
if (direction === 'left') {
|
||||
const newCategoryIndex = (categoryIndex - 1 + categories.length) % categories.length;
|
||||
const newCategories = [...categories];
|
||||
newCategories[categoryIndex] = { ...category, websites: newWebsites };
|
||||
const destCategory = newCategories[newCategoryIndex];
|
||||
const destWebsites = [...destCategory.websites, { ...movedWebsite, categoryId: destCategory.id }];
|
||||
newCategories[newCategoryIndex] = { ...destCategory, websites: destWebsites };
|
||||
} else {
|
||||
const newCategoryIndex = (categoryIndex + 1) % categories.length;
|
||||
newCategories[categoryIndex] = { ...category, websites: newWebsites };
|
||||
const destCategory = newCategories[newCategoryIndex];
|
||||
const destWebsites = [...destCategory.websites, { ...movedWebsite, categoryId: destCategory.id }];
|
||||
newCategories[newCategoryIndex] = { ...destCategory, websites: destWebsites };
|
||||
}
|
||||
|
||||
setCategories(newCategories);
|
||||
}, [categories]);
|
||||
|
||||
13
README.md
@@ -1,19 +1,18 @@
|
||||
<div style="display: flex; justify-content: center; font-size: 2rem; font-weight: bold;">
|
||||
Vision Start
|
||||
<div style="display: flex; justify-content: center; align-items: center; font-size: 2rem; font-weight: bold;">
|
||||
<img src="extension/icons/vision-48.png" alt="Vision Start" width="32" height="32" style="margin-right: 1rem;"> Vision Start
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Installing
|
||||
|
||||
|
||||
@@ -13,15 +13,18 @@ interface WallpaperProps {
|
||||
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 => {
|
||||
if (!freq) return 24 * 60 * 60 * 1000; // default 1 day
|
||||
const match = freq.match(/(\d+)(h|d)/);
|
||||
if (!match) return 24 * 60 * 60 * 1000;
|
||||
if (!freq) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
||||
const match = freq.match(/^(\d+)(h|d)$/);
|
||||
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
if (unit === 'h') return value * 60 * 60 * 1000;
|
||||
if (unit === 'd') return value * 24 * 60 * 60 * 1000;
|
||||
return 24 * 60 * 60 * 1000;
|
||||
const frequencyMs = unit === 'd' ? value * 24 * 60 * 60 * 1000 : value * 60 * 60 * 1000;
|
||||
return Math.min(MAX_WALLPAPER_FREQUENCY_MS, Math.max(MIN_WALLPAPER_FREQUENCY_MS, frequencyMs));
|
||||
};
|
||||
|
||||
const wallpaperUrlCache = new Map<string, string | undefined>();
|
||||
|
||||
@@ -33,12 +33,16 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
const [icon, setIcon] = useState(website ? website.icon : '');
|
||||
const [iconQuery, setIconQuery] = useState('');
|
||||
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
|
||||
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
|
||||
const [iconsFetched, setIconsFetched] = useState(false);
|
||||
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>(() => iconMetadataCache ?? []);
|
||||
const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
|
||||
const debounceRef = useRef<number | null>(null);
|
||||
|
||||
const ensureIconMetadata = () => {
|
||||
if (iconMetadataCache || iconsFetched) return;
|
||||
if (iconMetadataCache) {
|
||||
setIconMetadata(iconMetadataCache);
|
||||
return;
|
||||
}
|
||||
if (iconsFetched) return;
|
||||
setIconsFetched(true);
|
||||
fetch('/icon-metadata.json', { cache: 'force-cache' })
|
||||
.then(response => response.json())
|
||||
|
||||
@@ -8,6 +8,13 @@ interface ServerWidgetTabProps {
|
||||
onChange: (updates: Partial<Config>) => void;
|
||||
}
|
||||
|
||||
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
||||
|
||||
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
||||
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
||||
return { '--range-progress': `${progress}%` };
|
||||
};
|
||||
|
||||
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
||||
const [newServerName, setNewServerName] = useState('');
|
||||
const [newServerAddress, setNewServerAddress] = useState('');
|
||||
@@ -63,6 +70,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
||||
value={config.serverWidget.pingFrequency}
|
||||
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(config.serverWidget.pingFrequency, 5, 60)}
|
||||
/>
|
||||
<span className="w-12 text-right text-sm text-slate-200">{config.serverWidget.pingFrequency}s</span>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,31 @@ interface ThemeTabProps {
|
||||
onNextWallpaper: () => void;
|
||||
}
|
||||
|
||||
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
||||
|
||||
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
||||
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
||||
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> = ({
|
||||
config,
|
||||
onChange,
|
||||
@@ -28,6 +53,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
const [newWallpaperName, setNewWallpaperName] = useState('');
|
||||
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency);
|
||||
|
||||
const handleAddWallpaper = async () => {
|
||||
if (newWallpaperUrl.trim() === '') return;
|
||||
@@ -68,19 +94,25 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
||||
<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>
|
||||
<Dropdown
|
||||
name="wallpaperFrequency"
|
||||
value={config.wallpaperFrequency}
|
||||
onChange={(e) => onChange({ wallpaperFrequency: e.target.value as string })}
|
||||
options={[
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '3h', label: '3 hours' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
{ value: '1d', label: '1 day' },
|
||||
{ value: '2d', label: '2 days' },
|
||||
]}
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_WALLPAPER_FREQUENCY_HOURS}
|
||||
max={MAX_WALLPAPER_FREQUENCY_HOURS}
|
||||
step="1"
|
||||
value={wallpaperFrequencyHours}
|
||||
onChange={(e) => onChange({ wallpaperFrequency: `${Number(e.target.value)}h` })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(
|
||||
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 className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -93,6 +125,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
value={config.wallpaperBlur}
|
||||
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(config.wallpaperBlur, 0, 50)}
|
||||
/>
|
||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBlur}px</span>
|
||||
</div>
|
||||
@@ -107,6 +140,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
value={config.wallpaperBrightness}
|
||||
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(config.wallpaperBrightness, 0, 200)}
|
||||
/>
|
||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBrightness}%</span>
|
||||
</div>
|
||||
@@ -121,6 +155,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
value={config.wallpaperOpacity}
|
||||
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(config.wallpaperOpacity, 1, 100)}
|
||||
/>
|
||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperOpacity}%</span>
|
||||
</div>
|
||||
|
||||
@@ -43,13 +43,37 @@ const safeParse = (value: string | null): unknown => {
|
||||
const toStorageString = (value: unknown): string =>
|
||||
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 = {
|
||||
loadConfig(): Config {
|
||||
try {
|
||||
const stored = localStorage.getItem('config');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
return { ...DEFAULT_CONFIG, ...parsed };
|
||||
return normalizeConfig(parsed);
|
||||
}
|
||||
} catch (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(', ')}`);
|
||||
}
|
||||
|
||||
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>)
|
||||
.userWallpapers as Wallpaper[];
|
||||
.userWallpapers;
|
||||
|
||||
return {
|
||||
config: importedConfig || { ...DEFAULT_CONFIG },
|
||||
config: normalizeConfig(importedConfig),
|
||||
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
||||
};
|
||||
},
|
||||
|
||||
BIN
extension/icons/vision-128.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
extension/icons/vision-16.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
extension/icons/vision-48.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
264
index.css
@@ -27,9 +27,9 @@ body {
|
||||
isolation: isolate;
|
||||
overflow-x: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.16), transparent 28rem),
|
||||
radial-gradient(circle at 82% 20%, rgba(34, 211, 238, 0.13), transparent 26rem),
|
||||
linear-gradient(135deg, rgba(241, 245, 249, 0.1), rgba(15, 23, 42, 0.18) 52%, rgba(8, 13, 20, 0.34));
|
||||
radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.06), transparent 28rem),
|
||||
radial-gradient(circle at 82% 20%, rgba(34, 211, 238, 0.05), transparent 26rem),
|
||||
linear-gradient(135deg, rgba(241, 245, 249, 0.04), rgba(15, 23, 42, 0.16) 52%, rgba(8, 13, 20, 0.32));
|
||||
}
|
||||
|
||||
.vision-shell::before {
|
||||
@@ -39,8 +39,8 @@ body {
|
||||
z-index: -15;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.18) 0%, rgba(255, 255, 255, 0.04) 38%, rgba(2, 6, 23, 0.34) 100%),
|
||||
radial-gradient(ellipse at center, transparent 0%, rgba(2, 6, 23, 0.2) 78%, rgba(2, 6, 23, 0.42) 100%);
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.02) 38%, rgba(2, 6, 23, 0.3) 100%),
|
||||
radial-gradient(ellipse at center, transparent 0%, rgba(2, 6, 23, 0.18) 78%, rgba(2, 6, 23, 0.36) 100%);
|
||||
}
|
||||
|
||||
.wallpaper-transition {
|
||||
@@ -65,24 +65,24 @@ body {
|
||||
z-index: -25;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0.04) 34%, rgba(5, 12, 20, 0.38)),
|
||||
radial-gradient(circle at 50% 38%, rgba(255, 255, 255, 0.16), transparent 28rem),
|
||||
radial-gradient(circle at 50% 100%, rgba(8, 13, 24, 0.42), transparent 36rem);
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02) 34%, rgba(5, 12, 20, 0.34)),
|
||||
radial-gradient(circle at 50% 38%, rgba(255, 255, 255, 0.05), transparent 28rem),
|
||||
radial-gradient(circle at 50% 100%, rgba(8, 13, 24, 0.36), transparent 36rem);
|
||||
}
|
||||
|
||||
.liquid-surface {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0.07) 46%, rgba(15, 23, 42, 0.2)),
|
||||
rgba(8, 13, 23, 0.22);
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.04) 46%, rgba(15, 23, 42, 0.18)),
|
||||
rgba(8, 13, 23, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.34),
|
||||
inset 0 -18px 30px rgba(2, 6, 23, 0.18),
|
||||
0 18px 42px rgba(2, 6, 23, 0.24);
|
||||
backdrop-filter: blur(22px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(150%);
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||
inset 0 -10px 20px rgba(2, 6, 23, 0.12),
|
||||
0 12px 28px rgba(2, 6, 23, 0.2);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(112%);
|
||||
backdrop-filter: blur(10px) saturate(112%);
|
||||
}
|
||||
|
||||
.liquid-surface::before {
|
||||
@@ -91,23 +91,23 @@ body {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(120deg, rgba(255, 255, 255, 0.36), transparent 34%),
|
||||
radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.22), transparent 42%);
|
||||
opacity: 0.7;
|
||||
linear-gradient(120deg, rgba(255, 255, 255, 0.1), transparent 34%),
|
||||
radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.07), transparent 42%);
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.liquid-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.08) 48%, rgba(5, 12, 22, 0.36)),
|
||||
rgba(8, 13, 23, 0.38);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04) 48%, rgba(5, 12, 22, 0.34)),
|
||||
rgba(8, 13, 23, 0.48);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.28),
|
||||
0 28px 80px rgba(2, 6, 23, 0.34);
|
||||
backdrop-filter: blur(30px) saturate(155%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(155%);
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.12),
|
||||
0 22px 56px rgba(2, 6, 23, 0.3);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(115%);
|
||||
backdrop-filter: blur(14px) saturate(115%);
|
||||
}
|
||||
|
||||
.liquid-control {
|
||||
@@ -129,12 +129,12 @@ body {
|
||||
.liquid-control:hover {
|
||||
transform: translateY(-1px) scale(1.025);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.1)),
|
||||
rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.36);
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05)),
|
||||
rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.44),
|
||||
0 18px 40px rgba(2, 6, 23, 0.28);
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
0 14px 30px rgba(2, 6, 23, 0.22);
|
||||
}
|
||||
|
||||
.liquid-control:active {
|
||||
@@ -153,8 +153,8 @@ body {
|
||||
.liquid-input {
|
||||
width: 100%;
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.075);
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
border-radius: 0.875rem;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
transition:
|
||||
@@ -168,8 +168,8 @@ body {
|
||||
}
|
||||
|
||||
.liquid-input:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.liquid-input:focus {
|
||||
@@ -223,65 +223,164 @@ body {
|
||||
}
|
||||
|
||||
.liquid-range {
|
||||
width: 12rem;
|
||||
height: 1.25rem;
|
||||
--range-progress: 50%;
|
||||
width: clamp(10rem, 36vw, 13.5rem);
|
||||
height: 1.6rem;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
accent-color: #22d3ee;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.liquid-range:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.liquid-range:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.liquid-range::-webkit-slider-runnable-track {
|
||||
height: 0.38rem;
|
||||
height: 0.68rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(34, 211, 238, 0.9), rgba(255, 255, 255, 0.7));
|
||||
box-shadow: inset 0 1px 2px rgba(2, 6, 23, 0.24);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(34, 211, 238, 0.92), rgba(103, 232, 249, 0.78)) 0 / var(--range-progress) 100% no-repeat,
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.13), rgba(255, 255, 255, 0.06)),
|
||||
rgba(8, 13, 23, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15),
|
||||
inset 0 -1px 2px rgba(2, 6, 23, 0.24),
|
||||
0 8px 18px rgba(2, 6, 23, 0.14);
|
||||
transition:
|
||||
border-color 180ms var(--ease-ios),
|
||||
box-shadow 180ms var(--ease-ios);
|
||||
}
|
||||
|
||||
.liquid-range:hover::-webkit-slider-runnable-track {
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
inset 0 -1px 2px rgba(2, 6, 23, 0.2),
|
||||
0 10px 22px rgba(2, 6, 23, 0.18);
|
||||
}
|
||||
|
||||
.liquid-range::-webkit-slider-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
width: 1.18rem;
|
||||
height: 1.18rem;
|
||||
margin-top: -0.31rem;
|
||||
appearance: none;
|
||||
border-radius: 999px;
|
||||
background: white;
|
||||
border: 2px solid rgba(34, 211, 238, 0.9);
|
||||
box-shadow: 0 6px 16px rgba(2, 6, 23, 0.3);
|
||||
background: linear-gradient(145deg, #ffffff, #e5fbff 48%, #a5f3fc);
|
||||
border: 1px solid rgba(255, 255, 255, 0.86);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(34, 211, 238, 0.1),
|
||||
0 8px 18px rgba(2, 6, 23, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
transition:
|
||||
transform 180ms var(--ease-ios),
|
||||
box-shadow 180ms var(--ease-ios),
|
||||
border-color 180ms var(--ease-ios);
|
||||
}
|
||||
|
||||
.liquid-range:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.06);
|
||||
box-shadow:
|
||||
0 0 0 5px rgba(34, 211, 238, 0.15),
|
||||
0 10px 22px rgba(2, 6, 23, 0.32),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.liquid-range:active::-webkit-slider-thumb {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.liquid-range:focus-visible::-webkit-slider-thumb {
|
||||
border-color: rgba(34, 211, 238, 0.9);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(34, 211, 238, 0.24),
|
||||
0 0 0 7px rgba(255, 255, 255, 0.08),
|
||||
0 10px 22px rgba(2, 6, 23, 0.32),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.liquid-range::-moz-range-track {
|
||||
height: 0.38rem;
|
||||
height: 0.68rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(34, 211, 238, 0.9), rgba(255, 255, 255, 0.7));
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.13), rgba(255, 255, 255, 0.06)),
|
||||
rgba(8, 13, 23, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15),
|
||||
inset 0 -1px 2px rgba(2, 6, 23, 0.24),
|
||||
0 8px 18px rgba(2, 6, 23, 0.14);
|
||||
}
|
||||
|
||||
.liquid-range::-moz-range-progress {
|
||||
height: 0.68rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(34, 211, 238, 0.92), rgba(103, 232, 249, 0.78));
|
||||
}
|
||||
|
||||
.liquid-range::-moz-range-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
width: 1.18rem;
|
||||
height: 1.18rem;
|
||||
border-radius: 999px;
|
||||
background: white;
|
||||
border: 2px solid rgba(34, 211, 238, 0.9);
|
||||
background: linear-gradient(145deg, #ffffff, #e5fbff 48%, #a5f3fc);
|
||||
border: 1px solid rgba(255, 255, 255, 0.86);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(34, 211, 238, 0.1),
|
||||
0 8px 18px rgba(2, 6, 23, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
transition:
|
||||
transform 180ms var(--ease-ios),
|
||||
box-shadow 180ms var(--ease-ios),
|
||||
border-color 180ms var(--ease-ios);
|
||||
}
|
||||
|
||||
.liquid-range:hover::-moz-range-thumb {
|
||||
transform: scale(1.06);
|
||||
box-shadow:
|
||||
0 0 0 5px rgba(34, 211, 238, 0.15),
|
||||
0 10px 22px rgba(2, 6, 23, 0.32),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.liquid-range:active::-moz-range-thumb {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.liquid-range:focus-visible::-moz-range-thumb {
|
||||
border-color: rgba(34, 211, 238, 0.9);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(34, 211, 238, 0.24),
|
||||
0 0 0 7px rgba(255, 255, 255, 0.08),
|
||||
0 10px 22px rgba(2, 6, 23, 0.32),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.liquid-tile {
|
||||
border-radius: 1.35rem;
|
||||
transition:
|
||||
transform 220ms var(--ease-liquid),
|
||||
background 220ms var(--ease-ios),
|
||||
border-color 220ms var(--ease-ios),
|
||||
box-shadow 220ms var(--ease-liquid);
|
||||
transform 240ms var(--ease-liquid),
|
||||
background 260ms var(--ease-ios),
|
||||
border-color 260ms var(--ease-ios),
|
||||
box-shadow 340ms var(--ease-liquid);
|
||||
will-change: transform, box-shadow;
|
||||
}
|
||||
|
||||
.liquid-tile:hover {
|
||||
transform: translateY(-4px) scale(1.035);
|
||||
border-color: rgba(255, 255, 255, 0.42);
|
||||
transform: translateY(-3px) scale(1.025);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.08) 46%, rgba(34, 211, 238, 0.08)),
|
||||
rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.42),
|
||||
0 24px 46px rgba(2, 6, 23, 0.28),
|
||||
0 0 34px rgba(34, 211, 238, 0.12);
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.16),
|
||||
0 18px 34px rgba(2, 6, 23, 0.24),
|
||||
0 0 20px rgba(34, 211, 238, 0.04);
|
||||
}
|
||||
|
||||
.liquid-tile:active {
|
||||
@@ -294,16 +393,16 @@ body {
|
||||
border-style: dashed;
|
||||
border-color: rgba(255, 255, 255, 0.32);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.04)),
|
||||
rgba(255, 255, 255, 0.04);
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.03)),
|
||||
rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.liquid-ghost-tile:hover {
|
||||
color: white;
|
||||
border-color: rgba(34, 211, 238, 0.62);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(34, 211, 238, 0.18), rgba(255, 255, 255, 0.08)),
|
||||
rgba(255, 255, 255, 0.08);
|
||||
linear-gradient(145deg, rgba(34, 211, 238, 0.08), rgba(255, 255, 255, 0.04)),
|
||||
rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.liquid-edit-toolbar {
|
||||
@@ -358,22 +457,22 @@ body {
|
||||
|
||||
.liquid-drawer {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.05) 38%, rgba(7, 12, 22, 0.48)),
|
||||
rgba(7, 12, 22, 0.54);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.18);
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03) 38%, rgba(7, 12, 22, 0.44)),
|
||||
rgba(7, 12, 22, 0.68);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow:
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.14),
|
||||
-32px 0 80px rgba(2, 6, 23, 0.32);
|
||||
backdrop-filter: blur(34px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(34px) saturate(160%);
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.08),
|
||||
-26px 0 64px rgba(2, 6, 23, 0.3);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(112%);
|
||||
backdrop-filter: blur(16px) saturate(112%);
|
||||
}
|
||||
|
||||
.liquid-modal-backdrop {
|
||||
background:
|
||||
radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.12), transparent 32rem),
|
||||
radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.04), transparent 32rem),
|
||||
rgba(3, 7, 18, 0.72);
|
||||
backdrop-filter: blur(10px) saturate(130%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(130%);
|
||||
-webkit-backdrop-filter: blur(6px) saturate(108%);
|
||||
backdrop-filter: blur(6px) saturate(108%);
|
||||
}
|
||||
|
||||
.liquid-modal-card {
|
||||
@@ -382,6 +481,9 @@ body {
|
||||
}
|
||||
|
||||
.liquid-dropdown-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
animation: liquid-drop 150ms var(--ease-ios);
|
||||
@@ -429,7 +531,19 @@ body {
|
||||
.liquid-control:hover,
|
||||
.liquid-tile:hover,
|
||||
.liquid-button:hover,
|
||||
.liquid-edit-action:hover {
|
||||
.liquid-edit-action:hover,
|
||||
.liquid-range:hover::-webkit-slider-thumb,
|
||||
.liquid-range:active::-webkit-slider-thumb,
|
||||
.liquid-range:hover::-moz-range-thumb,
|
||||
.liquid-range:active::-moz-range-thumb {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.liquid-range::-webkit-slider-runnable-track,
|
||||
.liquid-range::-webkit-slider-thumb,
|
||||
.liquid-range::-moz-range-track,
|
||||
.liquid-range::-moz-range-progress,
|
||||
.liquid-range::-moz-range-thumb {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Vision Startpage",
|
||||
"version": "1.0",
|
||||
"description": "A beautiful and customizable startpage for your browser.",
|
||||
"version": "1.0.0",
|
||||
"description": "A light, modern and customizable startpage.",
|
||||
"chrome_url_overrides": {
|
||||
"newtab": "index.html"
|
||||
},
|
||||
"permissions": [
|
||||
"storage"
|
||||
],
|
||||
"icons": {
|
||||
"16": "extension/icons/vision-16.png",
|
||||
"48": "extension/icons/vision-48.png",
|
||||
"128": "extension/icons/vision-128.png"
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self';"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
project_name: Vision Start
|
||||
date: 2026-07-03
|
||||
date: 2026-07-08
|
||||
type: general_overview
|
||||
---
|
||||
|
||||
@@ -49,18 +49,18 @@ Entry points: `index.html` → `index.tsx` → `App.tsx`.
|
||||
|
||||
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 across categories 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.
|
||||
- **Clock** — Optional header clock with selectable size, font, and 12h/24h format.
|
||||
- **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.
|
||||
- **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`).
|
||||
- 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.
|
||||
- **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** — Light translucent surfaces, refractive edge highlights, 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:
|
||||
- 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.
|
||||
@@ -68,7 +68,7 @@ Performance notes:
|
||||
- `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.
|
||||
- `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`.
|
||||
|
||||
Planned / To-do (tracked in `README.md`):
|
||||
@@ -88,7 +88,6 @@ vision-start/
|
||||
├── types.ts # Core domain types (Config, Category, Website, Server, Wallpaper)
|
||||
├── constants.tsx # DEFAULT_CATEGORIES seed data
|
||||
├── manifest.json # Chrome MV3 manifest (newtab override, storage permission)
|
||||
├── icon.png # Extension icon source
|
||||
│
|
||||
├── components/
|
||||
│ ├── Clock.tsx # Header clock widget
|
||||
@@ -109,7 +108,7 @@ vision-start/
|
||||
│ │
|
||||
│ ├── configuration/
|
||||
│ │ ├── 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
|
||||
│ │ └── 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`
|
||||
- **`Category`** — `id`, `name`, `websites: Website[]`
|
||||
- **`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):
|
||||
|
||||
@@ -222,9 +221,9 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
||||
## 8. Notable Behaviors & Quirks
|
||||
|
||||
- **`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 via simple left/right buttons, 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.
|
||||
- **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 1–48 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.
|
||||
- **`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.).
|
||||
@@ -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._
|
||||
|
||||
|
Before Width: | Height: | Size: 763 KiB After Width: | Height: | Size: 265 KiB |
BIN
screenshots/configuration.png
Normal file
|
After Width: | Height: | Size: 609 KiB |
BIN
screenshots/editing.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
screenshots/home.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 37 KiB |