desktop
All checks were successful
Deploy / deploy-staging (push) Successful in 23s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-17 22:11:44 +02:00
parent 8e2c12d9cb
commit 4aeecc9faa
4 changed files with 550 additions and 25 deletions

View File

@@ -109,16 +109,16 @@ h1 {
.desktop-icons {
position: absolute;
top: var(--icon-top);
left: var(--icon-left);
inset: 0;
z-index: 10;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 16px;
width: min(720px, calc(100% - 80px));
}
.desktop-icons.is-arranging {
cursor: progress;
}
.desktop-icon {
position: absolute;
width: 96px;
padding: 12px 10px;
border: 1px solid transparent;
@@ -126,7 +126,9 @@ h1 {
text-align: center;
background: rgba(255, 255, 255, 0.05);
cursor: pointer;
transition: transform 140ms ease, background-color 140ms ease, border-color 140ms ease;
user-select: none;
touch-action: none;
transition: transform 140ms ease, background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
}
.desktop-icon:hover {
@@ -135,6 +137,13 @@ h1 {
border-color: var(--shell-border);
}
.desktop-icon.is-dragging {
transform: scale(1.03);
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.28);
background: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.28);
}
.desktop-icon-badge {
display: grid;
place-items: center;
@@ -154,11 +163,10 @@ h1 {
padding: 3px 8px;
border-radius: 6px;
font-size: 14px;
color: #ffffff;
color: var(--desktop-icon-label-color, #0f172a);
line-height: 1.25;
text-shadow: none;
mix-blend-mode: difference;
isolation: isolate;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.22);
transition: color 140ms ease, text-shadow 140ms ease;
}
.desktop-icon-label::before {
@@ -167,9 +175,56 @@ h1 {
inset: -2px -4px;
z-index: -1;
border-radius: 8px;
background: rgba(255, 255, 255, 0.18);
background: var(--desktop-icon-label-bg, rgba(255, 255, 255, 0.48));
backdrop-filter: blur(8px) saturate(1.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px var(--desktop-icon-label-border, rgba(255, 255, 255, 0.12));
}
.desktop-icon.is-contrast-light .desktop-icon-label {
--desktop-icon-label-color: #f8fafc;
--desktop-icon-label-bg: rgba(15, 23, 42, 0.44);
--desktop-icon-label-border: rgba(255, 255, 255, 0.12);
text-shadow: 0 1px 2px rgba(2, 6, 23, 0.38);
}
.desktop-icon.is-contrast-dark .desktop-icon-label {
--desktop-icon-label-color: #0f172a;
--desktop-icon-label-bg: rgba(255, 255, 255, 0.52);
--desktop-icon-label-border: rgba(255, 255, 255, 0.22);
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.28);
}
.desktop-context-menu {
position: fixed;
z-index: 180;
min-width: 220px;
padding: 10px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(20, 28, 45, 0.82);
color: #f8fafc;
backdrop-filter: blur(20px);
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.32);
}
.desktop-context-menu[hidden] {
display: none;
}
.desktop-context-menu-button {
width: 100%;
padding: 11px 12px;
border: 0;
border-radius: 12px;
background: transparent;
color: inherit;
text-align: left;
font: inherit;
cursor: pointer;
}
.desktop-context-menu-button:hover {
background: rgba(255, 255, 255, 0.12);
}
.app-icon-image,
@@ -784,12 +839,24 @@ h1 {
.desktop-icons {
position: relative;
top: auto;
left: auto;
inset: auto;
margin: 24px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 16px;
width: calc(100% - 48px);
}
.desktop-icon {
position: relative;
left: auto !important;
top: auto !important;
}
.desktop-context-menu {
display: none !important;
}
.widget-zone {
position: static;
width: 100%;

View File

@@ -2,6 +2,7 @@ const payloadNode = document.getElementById('desktop-payload');
if (payloadNode) {
const payload = JSON.parse(payloadNode.textContent);
const desktopStage = document.querySelector('.desktop-stage');
const iconsRoot = document.getElementById('desktop-icons');
const widgetZoneRoot = document.getElementById('widget-zone');
const windowsRoot = document.getElementById('window-layer');
@@ -22,11 +23,14 @@ if (payloadNode) {
const maximizeOverSystemBar = Boolean(skinProfile.maximize_over_system_bar);
const storageScope = payload.desktop.storage_scope || 'guest';
const storageKey = `desktop.kusche.berlin.window-state.${storageScope}`;
const iconStorageKey = `desktop.kusche.berlin.icon-state.${storageScope}.${activeSkin}`;
let zIndex = 20;
let topDesktopInset = 0;
let sideDesktopInset = 0;
let bottomDesktopInset = 120;
let iconContextMenu = null;
const windowControlsMode = document.body.dataset.windowControls || 'windows';
let wallpaperImagePromise = null;
const escapeHtml = (value) => String(value)
.replaceAll('&', '&')
@@ -45,6 +49,122 @@ if (payloadNode) {
return `<span class="app-icon-fallback${safeClassName}">${escapeHtml(app.icon || 'AP')}</span>`;
};
const extractWallpaperImageUrl = () => {
const wallpaper = getComputedStyle(document.body).getPropertyValue('--wallpaper') || '';
const match = wallpaper.match(/url\((['"]?)(.*?)\1\)/i);
return match && match[2] ? match[2] : null;
};
const loadWallpaperImage = () => {
const wallpaperUrl = extractWallpaperImageUrl();
if (!wallpaperUrl) {
return Promise.resolve(null);
}
if (wallpaperImagePromise) {
return wallpaperImagePromise;
}
wallpaperImagePromise = new Promise((resolve) => {
const image = new Image();
image.decoding = 'async';
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => resolve(null);
image.src = wallpaperUrl;
});
return wallpaperImagePromise;
};
const computeCoverSamplePoint = (image, viewportX, viewportY) => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const scale = Math.max(viewportWidth / image.naturalWidth, viewportHeight / image.naturalHeight);
const renderWidth = image.naturalWidth * scale;
const renderHeight = image.naturalHeight * scale;
const offsetX = (viewportWidth - renderWidth) / 2;
const offsetY = (viewportHeight - renderHeight) / 2;
const imageX = Math.max(0, Math.min(image.naturalWidth - 1, Math.round((viewportX - offsetX) / scale)));
const imageY = Math.max(0, Math.min(image.naturalHeight - 1, Math.round((viewportY - offsetY) / scale)));
return { imageX, imageY };
};
const createWallpaperSampler = (image) => {
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext('2d', { willReadFrequently: true });
if (!context) {
return null;
}
context.drawImage(image, 0, 0);
const sampleLuminance = (rect) => {
const samplePoints = [
[rect.left + rect.width * 0.2, rect.top + rect.height * 0.2],
[rect.left + rect.width * 0.8, rect.top + rect.height * 0.2],
[rect.left + rect.width * 0.5, rect.top + rect.height * 0.5],
[rect.left + rect.width * 0.2, rect.top + rect.height * 0.8],
[rect.left + rect.width * 0.8, rect.top + rect.height * 0.8],
];
let luminanceTotal = 0;
samplePoints.forEach(([x, y]) => {
const { imageX, imageY } = computeCoverSamplePoint(image, x, y);
const pixel = context.getImageData(imageX, imageY, 1, 1).data;
luminanceTotal += (0.2126 * pixel[0]) + (0.7152 * pixel[1]) + (0.0722 * pixel[2]);
});
return luminanceTotal / samplePoints.length;
};
return { sampleLuminance };
};
const applyDesktopIconContrast = async () => {
const icons = Array.from(document.querySelectorAll('.desktop-icon'));
if (icons.length === 0) {
return;
}
const image = await loadWallpaperImage();
if (!image) {
icons.forEach((icon) => {
icon.classList.remove('is-contrast-light');
icon.classList.add('is-contrast-dark');
});
return;
}
const sampler = createWallpaperSampler(image);
if (!sampler) {
return;
}
icons.forEach((icon) => {
const label = icon.querySelector('.desktop-icon-label');
if (!(label instanceof HTMLElement)) {
return;
}
const rect = label.getBoundingClientRect();
const luminance = sampler.sampleLuminance(rect);
const useDarkText = luminance >= 150;
icon.classList.toggle('is-contrast-dark', useDarkText);
icon.classList.toggle('is-contrast-light', !useDarkText);
});
};
const updateSessionDisplay = () => {
const accountNode = document.querySelector('.tray-pill[data-tray-id="account"]');
@@ -166,6 +286,43 @@ if (payloadNode) {
};
const persistedWindowState = loadWindowState();
const loadIconState = () => {
try {
const raw = window.localStorage.getItem(iconStorageKey);
const parsed = raw ? JSON.parse(raw) : null;
if (!parsed || typeof parsed !== 'object') {
return {
mode: 'auto',
positions: {},
};
}
return {
mode: parsed.mode === 'manual' ? 'manual' : 'auto',
positions: typeof parsed.positions === 'object' && parsed.positions !== null ? parsed.positions : {},
};
} catch {
return {
mode: 'auto',
positions: {},
};
}
};
const persistedIconState = loadIconState();
const iconState = {
mode: persistedIconState.mode,
positions: { ...persistedIconState.positions },
};
const saveIconState = () => {
try {
window.localStorage.setItem(iconStorageKey, JSON.stringify(iconState));
} catch {
// Ignore storage failures for now.
}
};
const measureDesktopBounds = () => {
if (!windowsRoot) {
@@ -185,6 +342,137 @@ if (payloadNode) {
: 120;
};
const shouldUseDesktopGrid = () => !window.matchMedia('(max-width: 1100px)').matches;
const getDesktopStageRect = () => desktopStage instanceof HTMLElement
? desktopStage.getBoundingClientRect()
: new DOMRect(0, 0, window.innerWidth, window.innerHeight);
const getIconMetrics = () => ({
width: 112,
height: 108,
marginX: 18,
marginY: 18,
});
const getIconLayoutBounds = () => {
const stageRect = getDesktopStageRect();
const { width, height, marginX, marginY } = getIconMetrics();
const top = topDesktopInset + Math.max(24, marginY);
const left = Math.max(24, marginX);
const right = Math.max(24, marginX);
const bottom = Math.max(24, bottomDesktopInset + 12);
const availableWidth = Math.max(width, Math.round(stageRect.width - left - right));
const availableHeight = Math.max(height, Math.round(stageRect.height - top - bottom));
return {
top,
left,
right,
bottom,
width,
height,
availableWidth,
availableHeight,
maxX: Math.max(left, Math.round(stageRect.width - right - width)),
maxY: Math.max(top, Math.round(stageRect.height - bottom - height)),
};
};
const buildAutoIconPositions = () => {
const bounds = getIconLayoutBounds();
const metrics = getIconMetrics();
const columns = Math.max(1, Math.floor((bounds.availableWidth + metrics.marginX) / (metrics.width + metrics.marginX)));
const rows = Math.max(1, Math.floor((bounds.availableHeight + metrics.marginY) / (metrics.height + metrics.marginY)));
const rightAligned = activeSkin === 'apple' || skinProfile.family === 'apple';
const positions = {};
payload.apps.forEach((app, index) => {
const column = Math.floor(index / rows);
const row = index % rows;
const x = rightAligned
? bounds.maxX - (column * (metrics.width + metrics.marginX))
: bounds.left + (column * (metrics.width + metrics.marginX));
const y = bounds.top + (row * (metrics.height + metrics.marginY));
positions[app.app_id] = {
x: Math.max(bounds.left, Math.min(bounds.maxX, x)),
y: Math.max(bounds.top, Math.min(bounds.maxY, y)),
};
});
return positions;
};
const clampIconPosition = (position) => {
const bounds = getIconLayoutBounds();
return {
x: Math.max(bounds.left, Math.min(bounds.maxX, Math.round(position.x))),
y: Math.max(bounds.top, Math.min(bounds.maxY, Math.round(position.y))),
};
};
const applyIconPosition = (icon, position) => {
const next = clampIconPosition(position);
icon.style.left = `${next.x}px`;
icon.style.top = `${next.y}px`;
};
const getIconPositionMap = () => {
if (iconState.mode === 'manual') {
const autoPositions = buildAutoIconPositions();
const positions = {};
payload.apps.forEach((app) => {
positions[app.app_id] = clampIconPosition(iconState.positions[app.app_id] || autoPositions[app.app_id] || { x: 24, y: 24 });
});
return positions;
}
return buildAutoIconPositions();
};
const applyStoredIconLayout = () => {
if (!iconsRoot) {
return;
}
const icons = Array.from(iconsRoot.querySelectorAll('.desktop-icon'));
if (!shouldUseDesktopGrid()) {
icons.forEach((icon) => {
icon.style.left = '';
icon.style.top = '';
});
applyDesktopIconContrast();
return;
}
const positions = getIconPositionMap();
icons.forEach((icon) => {
const appId = icon.dataset.appId;
const position = appId ? positions[appId] : null;
if (!position) {
return;
}
applyIconPosition(icon, position);
});
applyDesktopIconContrast();
};
const resetIconsToAutoLayout = () => {
iconState.mode = 'auto';
iconState.positions = {};
saveIconState();
applyStoredIconLayout();
};
const setStartMenuOpen = (isOpen) => {
if (!startMenu) {
return;
@@ -199,6 +487,60 @@ if (payloadNode) {
startButton?.setAttribute('aria-expanded', String(isOpen));
};
const ensureDesktopContextMenu = () => {
if (iconContextMenu || !(desktopStage instanceof HTMLElement)) {
return iconContextMenu;
}
const menu = document.createElement('div');
menu.className = 'desktop-context-menu';
menu.hidden = true;
menu.innerHTML = `
<button class="desktop-context-menu-button" type="button" data-action="auto-arrange-icons">
Symbole automatisch anordnen
</button>
`;
menu.addEventListener('click', (event) => {
const action = event.target instanceof Element
? event.target.closest('.desktop-context-menu-button')?.dataset.action
: null;
if (action === 'auto-arrange-icons') {
resetIconsToAutoLayout();
}
menu.hidden = true;
});
desktopStage.appendChild(menu);
iconContextMenu = menu;
return menu;
};
const closeDesktopContextMenu = () => {
if (iconContextMenu) {
iconContextMenu.hidden = true;
}
};
const openDesktopContextMenu = (clientX, clientY) => {
const menu = ensureDesktopContextMenu();
if (!(menu instanceof HTMLElement)) {
return;
}
menu.hidden = false;
const menuRect = menu.getBoundingClientRect();
const maxLeft = Math.max(12, window.innerWidth - menuRect.width - 12);
const maxTop = Math.max(12, window.innerHeight - menuRect.height - 12);
menu.style.left = `${Math.min(clientX, maxLeft)}px`;
menu.style.top = `${Math.min(clientY, maxTop)}px`;
};
const renderClock = () => {
const formatted = new Intl.DateTimeFormat('de-DE', {
hour: '2-digit',
@@ -489,6 +831,89 @@ if (payloadNode) {
});
};
const wireDesktopIconDragging = (icon, app) => {
let startX = 0;
let startY = 0;
let originX = 0;
let originY = 0;
let dragging = false;
let moved = false;
icon.addEventListener('pointerdown', (event) => {
if (event.button !== 0 || !shouldUseDesktopGrid()) {
return;
}
startX = event.clientX;
startY = event.clientY;
originX = parseFloat(icon.style.left || '0');
originY = parseFloat(icon.style.top || '0');
dragging = true;
moved = false;
icon.classList.add('is-dragging');
icon.setPointerCapture(event.pointerId);
closeDesktopContextMenu();
});
icon.addEventListener('pointermove', (event) => {
if (!dragging) {
return;
}
const nextX = originX + (event.clientX - startX);
const nextY = originY + (event.clientY - startY);
const distance = Math.abs(event.clientX - startX) + Math.abs(event.clientY - startY);
if (distance > 4) {
moved = true;
}
applyIconPosition(icon, {
x: nextX,
y: nextY,
});
});
icon.addEventListener('pointerup', () => {
if (!dragging) {
return;
}
dragging = false;
icon.classList.remove('is-dragging');
if (moved) {
const nextPosition = clampIconPosition({
x: parseFloat(icon.style.left || '0'),
y: parseFloat(icon.style.top || '0'),
});
iconState.mode = 'manual';
iconState.positions[app.app_id] = nextPosition;
saveIconState();
applyIconPosition(icon, nextPosition);
icon.dataset.suppressClick = 'true';
applyDesktopIconContrast();
}
});
icon.addEventListener('pointercancel', () => {
dragging = false;
icon.classList.remove('is-dragging');
});
icon.addEventListener('click', (event) => {
if (icon.dataset.suppressClick === 'true') {
icon.dataset.suppressClick = 'false';
event.preventDefault();
event.stopPropagation();
return;
}
openApp(app);
});
};
const createWindow = (definition) => {
const node = document.createElement('article');
node.className = 'window';
@@ -763,17 +1188,43 @@ if (payloadNode) {
payload.apps.forEach((app) => {
const icon = document.createElement('button');
icon.type = 'button';
icon.className = 'desktop-icon';
icon.className = 'desktop-icon is-contrast-dark';
icon.dataset.appId = app.app_id;
icon.innerHTML = `
<span class="desktop-icon-badge">${buildAppIconMarkup(app, 'desktop-app-icon')}</span>
<span class="desktop-icon-label">${app.title}</span>
`;
icon.addEventListener('click', () => openApp(app));
wireDesktopIconDragging(icon, app);
iconsRoot?.appendChild(icon);
});
updateSessionDisplay();
desktopStage?.addEventListener('contextmenu', (event) => {
if (!shouldUseDesktopGrid()) {
return;
}
if (event.target instanceof Element && event.target.closest('.window, .taskbar, .start-menu, .system-bar')) {
return;
}
event.preventDefault();
openDesktopContextMenu(event.clientX, event.clientY);
});
document.addEventListener('click', (event) => {
if (!(event.target instanceof Element) || !event.target.closest('.desktop-context-menu')) {
closeDesktopContextMenu();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeDesktopContextMenu();
}
});
startButton?.setAttribute('aria-expanded', 'false');
startButton?.addEventListener('click', () => {
if (!startMenu || !startButton) {
@@ -789,6 +1240,7 @@ if (payloadNode) {
renderTraySkins();
setStartMenuOpen(false);
measureDesktopBounds();
applyStoredIconLayout();
payload.windows.forEach(createWindow);
renderClock();
window.setInterval(renderClock, 1000);
@@ -799,12 +1251,13 @@ if (payloadNode) {
clampWindowLayout(record);
if (record.state.maximized) {
const maximizedTopInset = maximizeOverSystemBar ? 0 : topDesktopInset;
record.node.style.left = `${sideDesktopInset}px`;
record.node.style.top = `${topDesktopInset}px`;
record.node.style.top = `${maximizedTopInset}px`;
record.node.style.width = sideDesktopInset === 0 ? '100%' : `calc(100% - ${sideDesktopInset * 2}px)`;
record.node.style.height = activeSkin === 'apple'
record.node.style.height = maximizeOverSystemBar
? '100%'
: `calc(100% - ${topDesktopInset + bottomDesktopInset}px)`;
: `calc(100% - ${maximizedTopInset + bottomDesktopInset}px)`;
return;
}
@@ -812,5 +1265,9 @@ if (payloadNode) {
});
saveWindowState();
applyStoredIconLayout();
closeDesktopContextMenu();
});
window.refreshDesktopIconContrast = applyDesktopIconContrast;
}

View File

@@ -27,10 +27,8 @@ body[data-skin="apple"] .system-bar {
}
body[data-skin="apple"] .desktop-icons {
left: auto;
right: 24px;
justify-items: end;
width: min(160px, calc(100% - 48px));
inset: 0;
width: auto;
}
body[data-skin="apple"] .taskbar {