diff --git a/public/assets/desktop/desktop.css b/public/assets/desktop/desktop.css
index 6e69a957..42f9bc14 100644
--- a/public/assets/desktop/desktop.css
+++ b/public/assets/desktop/desktop.css
@@ -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%;
diff --git a/public/assets/desktop/desktop.js b/public/assets/desktop/desktop.js
index 3ef3df56..44ee4fef 100644
--- a/public/assets/desktop/desktop.js
+++ b/public/assets/desktop/desktop.js
@@ -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 `${escapeHtml(app.icon || 'AP')}`;
};
+ 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 = `
+
+ `;
+
+ 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 = `
${buildAppIconMarkup(app, 'desktop-app-icon')}
${app.title}
`;
- 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;
}
diff --git a/public/assets/desktop/skins/apple/skin.css b/public/assets/desktop/skins/apple/skin.css
index a36c561e..4f2de2b4 100644
--- a/public/assets/desktop/skins/apple/skin.css
+++ b/public/assets/desktop/skins/apple/skin.css
@@ -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 {
diff --git a/src/App/App.php b/src/App/App.php
index a510c6c4..51cd9e91 100644
--- a/src/App/App.php
+++ b/src/App/App.php
@@ -39,6 +39,9 @@ final class App
$windows = (new WindowManager($registry))->defaultWindows();
$activeWidgetIds = DesktopState::defaultWidgetIds();
$displayName = (string) ($authUser['name'] ?? $authUser['username'] ?? 'Gast');
+ $storageScope = $isAuthenticated
+ ? strtolower((string) ($authUser['username'] ?? $authUser['sub'] ?? 'user'))
+ : 'guest';
return [
'meta' => [
@@ -51,7 +54,7 @@ final class App
'desktop' => [
'greeting' => 'Neue Desktop-Shell fuer Kusche.Berlin',
'wallpaper' => SkinResolver::wallpaper($activeSkin),
- 'storage_scope' => 'guest',
+ 'storage_scope' => preg_replace('/[^a-z0-9._-]+/i', '-', $storageScope) ?: 'guest',
'workspace' => DesktopState::defaultWorkspace(),
'login' => [
'authenticated' => $isAuthenticated,