feat(splitscreen): add split-screen feature for multi-app side-by-side view

Add new @manacore/shared-splitscreen package enabling iFrame-based
split-screen functionality across Calendar, Todo, and Contacts apps.

Features:
- SplitPaneContainer with CSS Grid layout
- AppPanel with iFrame sandbox permissions and loading/error states
- ResizeHandle with mouse, touch, and keyboard support (20-80% range)
- PanelControls for swap and close actions
- Svelte 5 runes-based store with Context API
- URL persistence (?panel=todo&split=60)
- localStorage persistence with versioning
- Mobile auto-disable (<1024px breakpoint)

Integration:
- PillNavigation: added onOpenInPanel prop and Ctrl/Cmd+click support
- PillDropdown: added split button per app item
- Calendar, Todo, Contacts layouts wrapped with SplitPaneContainer

Also fixes:
- WeekView.svelte: fixed {@const} placement error
- MultiDayView.svelte: fixed {@const} placement error

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-12 13:00:26 +01:00
parent f51708d75a
commit f2ac3e245e
27 changed files with 2770 additions and 531 deletions

View file

@ -0,0 +1,39 @@
{
"name": "@manacore/shared-splitscreen",
"version": "0.1.0",
"private": true,
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./store": {
"svelte": "./src/stores/split-panel.svelte.ts",
"default": "./src/stores/split-panel.svelte.ts"
},
"./types": {
"types": "./src/types.ts",
"default": "./src/types.ts"
},
"./utils": {
"default": "./src/utils/index.ts"
}
},
"scripts": {
"lint": "eslint .",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"peerDependencies": {
"svelte": "^5.0.0"
},
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0"
}
}

View file

@ -0,0 +1,155 @@
<script lang="ts">
/**
* AppPanel Component
* iFrame container for displaying an app in split-screen.
*/
import type { PanelConfig } from '../types.js';
interface Props {
panel: PanelConfig;
class?: string;
}
let { panel, class: className = '' }: Props = $props();
let isLoading = $state(true);
let hasError = $state(false);
function handleLoad() {
isLoading = false;
hasError = false;
}
function handleError() {
isLoading = false;
hasError = true;
}
// iFrame sandbox permissions
const sandboxPermissions = [
'allow-same-origin',
'allow-scripts',
'allow-forms',
'allow-popups',
'allow-popups-to-escape-sandbox',
'allow-storage-access-by-user-activation',
].join(' ');
</script>
<div class="app-panel {className}">
{#if isLoading}
<div class="loading-state">
<div class="spinner"></div>
<span>Loading {panel.name || panel.appId}...</span>
</div>
{/if}
{#if hasError}
<div class="error-state">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>Failed to load {panel.name || panel.appId}</span>
<button
onclick={() => {
isLoading = true;
hasError = false;
}}
>
Retry
</button>
</div>
{/if}
<iframe
src={panel.url}
title={panel.name || panel.appId}
sandbox={sandboxPermissions}
class:hidden={hasError}
onload={handleLoad}
onerror={handleError}
></iframe>
</div>
<style>
.app-panel {
position: relative;
width: 100%;
height: 100%;
background: var(--color-bg-secondary, #1a1a1a);
overflow: hidden;
border-radius: 8px;
}
iframe {
width: 100%;
height: 100%;
border: none;
background: var(--color-bg-primary, #0a0a0a);
}
iframe.hidden {
display: none;
}
.loading-state,
.error-state {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--color-text-secondary, #888);
font-size: 14px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-border, rgba(255, 255, 255, 0.1));
border-top-color: var(--color-primary, #3b82f6);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-state {
color: var(--color-error, #ef4444);
}
.error-state button {
margin-top: 8px;
padding: 8px 16px;
background: var(--color-primary, #3b82f6);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: opacity 0.15s ease;
}
.error-state button:hover {
opacity: 0.9;
}
</style>

View file

@ -0,0 +1,117 @@
<script lang="ts">
/**
* PanelControls Component
* Controls overlay for split panel (swap, close).
*/
interface Props {
panelName: string;
onSwap: () => void;
onClose: () => void;
}
let { panelName, onSwap, onClose }: Props = $props();
</script>
<div class="panel-controls">
<span class="panel-label">{panelName}</span>
<div class="control-buttons">
<button class="control-btn" title="Swap panels" onclick={onSwap}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 3l4 4-4 4" />
<path d="M20 7H4" />
<path d="M8 21l-4-4 4-4" />
<path d="M4 17h16" />
</svg>
</button>
<button class="control-btn close" title="Close panel" onclick={onClose}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
<style>
.panel-controls {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
z-index: 20;
opacity: 0;
transition: opacity 0.2s ease;
}
.app-panel:hover .panel-controls,
.panel-controls:focus-within {
opacity: 1;
}
.panel-label {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
padding: 0 4px;
}
.control-buttons {
display: flex;
gap: 4px;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 6px;
cursor: pointer;
color: rgba(255, 255, 255, 0.8);
transition: all 0.15s ease;
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.control-btn.close:hover {
background: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
</style>

View file

@ -0,0 +1,197 @@
<script lang="ts">
/**
* ResizeHandle Component
* Draggable divider for resizing split panels.
*/
import { DIVIDER_CONSTRAINTS } from '../types.js';
interface Props {
position: number;
onResize: (position: number) => void;
onReset: () => void;
}
let { position, onResize, onReset }: Props = $props();
let isDragging = $state(false);
let containerRef: HTMLElement | null = null;
function handleMouseDown(event: MouseEvent) {
event.preventDefault();
isDragging = true;
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef) return;
const container = containerRef.closest('.split-pane-container');
if (!container) return;
const rect = container.getBoundingClientRect();
const newPosition = ((e.clientX - rect.left) / rect.width) * 100;
const clamped = Math.max(
DIVIDER_CONSTRAINTS.MIN,
Math.min(DIVIDER_CONSTRAINTS.MAX, newPosition)
);
onResize(clamped);
};
const handleMouseUp = () => {
isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
function handleTouchStart(event: TouchEvent) {
event.preventDefault();
isDragging = true;
const handleTouchMove = (e: TouchEvent) => {
if (!containerRef || !e.touches[0]) return;
const container = containerRef.closest('.split-pane-container');
if (!container) return;
const rect = container.getBoundingClientRect();
const newPosition = ((e.touches[0].clientX - rect.left) / rect.width) * 100;
const clamped = Math.max(
DIVIDER_CONSTRAINTS.MIN,
Math.min(DIVIDER_CONSTRAINTS.MAX, newPosition)
);
onResize(clamped);
};
const handleTouchEnd = () => {
isDragging = false;
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
}
function handleDoubleClick() {
onReset();
}
function handleKeyDown(event: KeyboardEvent) {
const step = event.shiftKey ? 10 : 2;
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
onResize(Math.max(DIVIDER_CONSTRAINTS.MIN, position - step));
break;
case 'ArrowRight':
event.preventDefault();
onResize(Math.min(DIVIDER_CONSTRAINTS.MAX, position + step));
break;
case 'Home':
event.preventDefault();
onResize(DIVIDER_CONSTRAINTS.DEFAULT);
break;
}
}
</script>
<div
bind:this={containerRef}
class="resize-handle"
class:dragging={isDragging}
role="separator"
aria-orientation="vertical"
aria-valuenow={position}
aria-valuemin={DIVIDER_CONSTRAINTS.MIN}
aria-valuemax={DIVIDER_CONSTRAINTS.MAX}
tabindex="0"
onmousedown={handleMouseDown}
ontouchstart={handleTouchStart}
ondblclick={handleDoubleClick}
onkeydown={handleKeyDown}
>
<div class="handle-line"></div>
<div class="handle-grip">
<span></span>
<span></span>
<span></span>
</div>
</div>
<style>
.resize-handle {
position: relative;
width: 6px;
cursor: col-resize;
background: transparent;
transition: background 0.15s ease;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.resize-handle:hover,
.resize-handle.dragging {
background: var(--color-primary, #3b82f6);
background: linear-gradient(
to bottom,
transparent 0%,
var(--color-primary, #3b82f6) 20%,
var(--color-primary, #3b82f6) 80%,
transparent 100%
);
}
.resize-handle:focus {
outline: none;
}
.resize-handle:focus-visible {
background: var(--color-primary, #3b82f6);
}
.handle-line {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
background: var(--color-border, rgba(255, 255, 255, 0.1));
transform: translateX(-50%);
}
.resize-handle:hover .handle-line,
.resize-handle.dragging .handle-line {
background: transparent;
}
.handle-grip {
display: flex;
flex-direction: column;
gap: 3px;
opacity: 0;
transition: opacity 0.15s ease;
}
.resize-handle:hover .handle-grip,
.resize-handle.dragging .handle-grip {
opacity: 1;
}
.handle-grip span {
width: 4px;
height: 4px;
border-radius: 50%;
background: white;
}
</style>

View file

@ -0,0 +1,112 @@
<script lang="ts">
/**
* SplitPaneContainer Component
* Main container that handles split-screen layout.
*/
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
import { getSplitPanelContext } from '../stores/split-panel.svelte.js';
import AppPanel from './AppPanel.svelte';
import PanelControls from './PanelControls.svelte';
import ResizeHandle from './ResizeHandle.svelte';
interface Props {
children: Snippet;
class?: string;
}
let { children, class: className = '' }: Props = $props();
const splitPanel = getSplitPanelContext();
// Grid template based on divider position
let gridTemplate = $derived(
splitPanel.isActive && splitPanel.rightPanel ? `${splitPanel.dividerPosition}% 6px 1fr` : '1fr'
);
function handleResize(position: number) {
splitPanel.setDividerPosition(position);
}
function handleReset() {
splitPanel.resetDividerPosition();
}
</script>
<div
class="split-pane-container {className}"
class:split-active={splitPanel.isActive && splitPanel.rightPanel}
style:--grid-template={gridTemplate}
>
<div class="main-panel">
{@render children()}
</div>
{#if splitPanel.isActive && splitPanel.rightPanel}
<ResizeHandle
position={splitPanel.dividerPosition}
onResize={handleResize}
onReset={handleReset}
/>
<div class="side-panel">
<AppPanel panel={splitPanel.rightPanel} />
<PanelControls
panelName={splitPanel.rightPanel.name || splitPanel.rightPanel.appId}
onSwap={() => splitPanel.swapPanels()}
onClose={() => splitPanel.closePanel()}
/>
</div>
{/if}
</div>
<style>
.split-pane-container {
display: grid;
grid-template-columns: var(--grid-template, 1fr);
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.main-panel {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.side-panel {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
/* Ensure proper stacking */
.split-active .main-panel {
z-index: 1;
}
.split-active .side-panel {
z-index: 1;
}
/* Hide side panel on mobile via media query as fallback */
@media (max-width: 1023px) {
.split-pane-container {
grid-template-columns: 1fr !important;
}
.side-panel,
.split-pane-container :global(.resize-handle) {
display: none;
}
}
</style>

View file

@ -0,0 +1,46 @@
/**
* @manacore/shared-splitscreen
*
* Split-screen panel system for ManaCore apps.
* Enables displaying two apps side-by-side using iFrames.
*/
// Types
export type {
PanelConfig,
SplitScreenState,
AppDefinition,
PanelEvent,
StorageConfig,
UrlState,
} from './types.js';
export { DIVIDER_CONSTRAINTS, MOBILE_BREAKPOINT } from './types.js';
// Store
export {
createSplitPanelStore,
setSplitPanelContext,
getSplitPanelContext,
hasSplitPanelContext,
DEFAULT_APPS,
type SplitPanelStore,
} from './stores/split-panel.svelte.js';
// Utils
export {
parseUrlState,
updateUrlState,
clearUrlState,
getCurrentUrlState,
savePanelState,
loadPanelState,
clearPanelState,
createStorageConfig,
} from './utils/index.js';
// Components (will be added)
export { default as SplitPaneContainer } from './components/SplitPaneContainer.svelte';
export { default as AppPanel } from './components/AppPanel.svelte';
export { default as PanelControls } from './components/PanelControls.svelte';
export { default as ResizeHandle } from './components/ResizeHandle.svelte';

View file

@ -0,0 +1,251 @@
/**
* Split-Panel Store
* Svelte 5 runes-based state management for split-screen panels.
*/
import { getContext, setContext } from 'svelte';
import type { PanelConfig, AppDefinition, StorageConfig } from '../types.js';
import { DIVIDER_CONSTRAINTS, MOBILE_BREAKPOINT } from '../types.js';
import { savePanelState, loadPanelState, createStorageConfig } from '../utils/local-storage.js';
import { updateUrlState, clearUrlState, getCurrentUrlState } from '../utils/url-state.js';
const SPLIT_PANEL_CONTEXT_KEY = Symbol('split-panel');
/**
* Available apps that can be opened in split-screen.
*/
export const DEFAULT_APPS: AppDefinition[] = [
{
id: 'calendar',
name: 'Calendar',
baseUrl: 'http://localhost:5179',
icon: 'calendar',
color: '#3b82f6',
},
{
id: 'todo',
name: 'Todo',
baseUrl: 'http://localhost:5188',
icon: 'check-square',
color: '#10b981',
},
{
id: 'contacts',
name: 'Contacts',
baseUrl: 'http://localhost:5184',
icon: 'users',
color: '#8b5cf6',
},
{
id: 'clock',
name: 'Clock',
baseUrl: 'http://localhost:5187',
icon: 'clock',
color: '#f59e0b',
},
];
export interface SplitPanelStore {
// State
readonly isActive: boolean;
readonly rightPanel: PanelConfig | null;
readonly dividerPosition: number;
readonly isMobile: boolean;
// Available apps (excluding current)
readonly availableApps: AppDefinition[];
// Actions
openPanel: (appId: string, path?: string) => void;
closePanel: () => void;
swapPanels: () => void;
setDividerPosition: (position: number) => void;
resetDividerPosition: () => void;
initialize: () => void;
}
/**
* Create a split-panel store for an app.
*/
export function createSplitPanelStore(
currentAppId: string,
apps: AppDefinition[] = DEFAULT_APPS
): SplitPanelStore {
// Reactive state using Svelte 5 runes
let isActive = $state(false);
let rightPanel = $state<PanelConfig | null>(null);
let dividerPosition = $state(DIVIDER_CONSTRAINTS.DEFAULT);
let isMobile = $state(false);
// Storage config for persistence
const storageConfig: StorageConfig = createStorageConfig(currentAppId);
// Filter out current app from available apps
const availableApps = $derived(apps.filter((app) => app.id !== currentAppId));
/**
* Open an app in the right panel.
*/
function openPanel(appId: string, path = '/'): void {
if (isMobile) return;
const app = apps.find((a) => a.id === appId);
if (!app || app.id === currentAppId) return;
const url = `${app.baseUrl}${path}`;
rightPanel = {
appId: app.id,
url,
name: app.name,
};
isActive = true;
// Persist to URL and localStorage
updateUrlState({ panel: appId, split: dividerPosition });
savePanelState(storageConfig, { rightPanel, dividerPosition, isActive: true });
}
/**
* Close the split panel.
*/
function closePanel(): void {
rightPanel = null;
isActive = false;
// Clear persistence
clearUrlState();
savePanelState(storageConfig, { rightPanel: null, dividerPosition, isActive: false });
}
/**
* Swap left and right panels (navigate to the right panel app).
*/
function swapPanels(): void {
if (!rightPanel) return;
// Navigate to the other app
const targetUrl = rightPanel.url;
window.location.href = targetUrl;
}
/**
* Set the divider position.
*/
function setDividerPosition(position: number): void {
const clamped = Math.max(DIVIDER_CONSTRAINTS.MIN, Math.min(DIVIDER_CONSTRAINTS.MAX, position));
dividerPosition = clamped;
// Persist
if (isActive) {
updateUrlState({ panel: rightPanel?.appId, split: clamped });
savePanelState(storageConfig, { rightPanel, dividerPosition: clamped, isActive });
}
}
/**
* Reset divider to default position.
*/
function resetDividerPosition(): void {
setDividerPosition(DIVIDER_CONSTRAINTS.DEFAULT);
}
/**
* Initialize from URL and localStorage.
*/
function initialize(): void {
if (typeof window === 'undefined') return;
// Check mobile
const checkMobile = () => {
isMobile = window.innerWidth < MOBILE_BREAKPOINT;
if (isMobile && isActive) {
closePanel();
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
// Load from URL first, then localStorage
const urlState = getCurrentUrlState();
const storedState = loadPanelState(storageConfig);
const panelAppId = urlState.panel || storedState?.rightPanel?.appId;
const savedPosition = urlState.split || storedState?.dividerPosition;
if (panelAppId && !isMobile) {
const app = apps.find((a) => a.id === panelAppId);
if (app && app.id !== currentAppId) {
openPanel(panelAppId);
if (savedPosition) {
setDividerPosition(savedPosition);
}
}
}
}
// Return the store interface with getters for reactive access
return {
get isActive() {
return isActive;
},
get rightPanel() {
return rightPanel;
},
get dividerPosition() {
return dividerPosition;
},
get isMobile() {
return isMobile;
},
get availableApps() {
return availableApps;
},
openPanel,
closePanel,
swapPanels,
setDividerPosition,
resetDividerPosition,
initialize,
};
}
/**
* Set the split-panel store in Svelte context.
* Call this in your layout component.
*/
export function setSplitPanelContext(
currentAppId: string,
apps: AppDefinition[] = DEFAULT_APPS
): SplitPanelStore {
const store = createSplitPanelStore(currentAppId, apps);
setContext(SPLIT_PANEL_CONTEXT_KEY, store);
return store;
}
/**
* Get the split-panel store from Svelte context.
* Call this in child components.
*/
export function getSplitPanelContext(): SplitPanelStore {
const store = getContext<SplitPanelStore>(SPLIT_PANEL_CONTEXT_KEY);
if (!store) {
throw new Error(
'[SplitScreen] No split-panel context found. Did you call setSplitPanelContext in a parent component?'
);
}
return store;
}
/**
* Check if split-panel context exists.
*/
export function hasSplitPanelContext(): boolean {
try {
getContext(SPLIT_PANEL_CONTEXT_KEY);
return true;
} catch {
return false;
}
}

View file

@ -0,0 +1,88 @@
/**
* Split-Screen Types
* Type definitions for the split-screen panel system.
*/
/**
* Configuration for a panel showing an app in an iFrame.
*/
export interface PanelConfig {
/** Unique identifier for the app (e.g., 'calendar', 'todo', 'contacts') */
appId: string;
/** Full URL to load in the iFrame */
url: string;
/** Display name for the app */
name?: string;
}
/**
* State of the split-screen system.
*/
export interface SplitScreenState {
/** Whether split-screen mode is active */
isActive: boolean;
/** Configuration for the right panel (null when not in split mode) */
rightPanel: PanelConfig | null;
/** Position of the divider as percentage (20-80) */
dividerPosition: number;
}
/**
* App registration for the split-screen system.
* Used to define which apps can be opened in panels.
*/
export interface AppDefinition {
/** Unique app identifier */
id: string;
/** Display name */
name: string;
/** Base URL for the app */
baseUrl: string;
/** Icon name (Lucide icon) */
icon?: string;
/** App theme color */
color?: string;
}
/**
* Event payload for panel operations.
*/
export interface PanelEvent {
type: 'open' | 'close' | 'swap' | 'resize';
panel?: PanelConfig;
dividerPosition?: number;
}
/**
* Storage key configuration.
*/
export interface StorageConfig {
/** Key prefix for localStorage */
prefix: string;
/** Current app ID for scoped storage */
currentAppId: string;
}
/**
* URL state parameters for split-screen.
*/
export interface UrlState {
/** App ID for the right panel */
panel?: string;
/** Divider position percentage */
split?: number;
}
/**
* Minimum and maximum constraints for divider position.
*/
export const DIVIDER_CONSTRAINTS = {
MIN: 20,
MAX: 80,
DEFAULT: 50,
} as const;
/**
* Breakpoint for disabling split-screen on mobile.
*/
export const MOBILE_BREAKPOINT = 1024;

View file

@ -0,0 +1,13 @@
/**
* Split-Screen Utilities
* Re-export all utility functions.
*/
export { parseUrlState, updateUrlState, clearUrlState, getCurrentUrlState } from './url-state.js';
export {
savePanelState,
loadPanelState,
clearPanelState,
createStorageConfig,
} from './local-storage.js';

View file

@ -0,0 +1,97 @@
/**
* LocalStorage Utilities
* Handle persistent storage for split-screen preferences.
*/
import type { SplitScreenState, StorageConfig } from '../types.js';
import { DIVIDER_CONSTRAINTS } from '../types.js';
const STORAGE_VERSION = 1;
interface StoredState {
version: number;
state: Partial<SplitScreenState>;
}
/**
* Generate storage key for an app.
*/
function getStorageKey(config: StorageConfig): string {
return `${config.prefix}-splitscreen-${config.currentAppId}`;
}
/**
* Save split-screen state to localStorage.
*/
export function savePanelState(config: StorageConfig, state: Partial<SplitScreenState>): void {
if (typeof window === 'undefined') return;
try {
const stored: StoredState = {
version: STORAGE_VERSION,
state: {
dividerPosition: state.dividerPosition,
rightPanel: state.rightPanel,
},
};
localStorage.setItem(getStorageKey(config), JSON.stringify(stored));
} catch (_error) {
// localStorage not available or quota exceeded
}
}
/**
* Load split-screen state from localStorage.
*/
export function loadPanelState(config: StorageConfig): Partial<SplitScreenState> | null {
if (typeof window === 'undefined') return null;
try {
const raw = localStorage.getItem(getStorageKey(config));
if (!raw) return null;
const stored: StoredState = JSON.parse(raw);
// Version check for future migrations
if (stored.version !== STORAGE_VERSION) {
clearPanelState(config);
return null;
}
// Validate divider position
if (stored.state.dividerPosition !== undefined) {
stored.state.dividerPosition = Math.max(
DIVIDER_CONSTRAINTS.MIN,
Math.min(DIVIDER_CONSTRAINTS.MAX, stored.state.dividerPosition)
);
}
return stored.state;
} catch (_error) {
// localStorage not available or corrupted data
return null;
}
}
/**
* Clear split-screen state from localStorage.
*/
export function clearPanelState(config: StorageConfig): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(getStorageKey(config));
} catch (_error) {
// localStorage not available
}
}
/**
* Get default storage config with manacore prefix.
*/
export function createStorageConfig(currentAppId: string): StorageConfig {
return {
prefix: 'manacore',
currentAppId,
};
}

View file

@ -0,0 +1,65 @@
/**
* URL State Utilities
* Handle URL-based state persistence for split-screen.
*/
import type { UrlState } from '../types.js';
/**
* Parse split-screen state from URL search params.
* Reads `?panel=todo&split=60` format.
*/
export function parseUrlState(searchParams: URLSearchParams): UrlState {
const panel = searchParams.get('panel') || undefined;
const splitStr = searchParams.get('split');
const split = splitStr ? parseInt(splitStr, 10) : undefined;
return {
panel,
split: split && !isNaN(split) ? split : undefined,
};
}
/**
* Update URL with split-screen state without page reload.
* Uses replaceState to avoid adding to browser history.
*/
export function updateUrlState(state: UrlState): void {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
if (state.panel) {
url.searchParams.set('panel', state.panel);
} else {
url.searchParams.delete('panel');
}
if (state.split && state.split !== 50) {
url.searchParams.set('split', state.split.toString());
} else {
url.searchParams.delete('split');
}
window.history.replaceState({}, '', url.toString());
}
/**
* Clear split-screen state from URL.
*/
export function clearUrlState(): void {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
url.searchParams.delete('panel');
url.searchParams.delete('split');
window.history.replaceState({}, '', url.toString());
}
/**
* Get current URL state.
*/
export function getCurrentUrlState(): UrlState {
if (typeof window === 'undefined') return {};
return parseUrlState(new URLSearchParams(window.location.search));
}

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -72,13 +72,21 @@
openSubmenuId = openSubmenuId === itemId ? null : itemId;
}
function handleItemClick(item: PillDropdownItem) {
function handleItemClick(item: PillDropdownItem, event: MouseEvent) {
if (item.submenu && item.submenu.length > 0) {
toggleSubmenu(item.id);
return;
}
if (item.onClick) {
item.onClick();
item.onClick(event);
}
close();
}
function handleSplitClick(item: PillDropdownItem, event: MouseEvent) {
event.stopPropagation();
if (item.onSplitClick) {
item.onSplitClick();
}
close();
}
@ -186,58 +194,79 @@
style="animation-delay: {(header ? i + 1 : i) * 15}ms"
></div>
{:else}
<button
onclick={() => handleItemClick(item)}
class="pill glass-pill fan-pill"
class:danger-pill={item.danger}
class:active-pill={item.active}
class:has-submenu={item.submenu && item.submenu.length > 0}
class:submenu-open={openSubmenuId === item.id}
<div
class="fan-pill-wrapper"
class:has-split-button={item.showSplitButton}
style="animation-delay: {(header ? i + 1 : i) * 15}ms"
>
{#if item.imageUrl}
<img src={item.imageUrl} alt="" class="pill-image-icon" />
{:else if item.icon === 'mana'}
<svg class="pill-icon" viewBox="0 0 24 24" fill="currentColor">
<path d={getIcon('mana')} />
</svg>
{:else if item.icon}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIcon(item.icon)}
/>
</svg>
{/if}
<span class="pill-label">{item.label}</span>
{#if item.active}
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIcon('check')}
/>
</svg>
{:else if item.submenu && item.submenu.length > 0}
<svg
class="chevron-submenu"
class:rotated={openSubmenuId === item.id}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<button
onclick={(e) => handleItemClick(item, e)}
class="pill glass-pill fan-pill"
class:danger-pill={item.danger}
class:active-pill={item.active}
class:has-submenu={item.submenu && item.submenu.length > 0}
class:submenu-open={openSubmenuId === item.id}
>
{#if item.imageUrl}
<img src={item.imageUrl} alt="" class="pill-image-icon" />
{:else if item.icon === 'mana'}
<svg class="pill-icon" viewBox="0 0 24 24" fill="currentColor">
<path d={getIcon('mana')} />
</svg>
{:else if item.icon}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIcon(item.icon)}
/>
</svg>
{/if}
<span class="pill-label">{item.label}</span>
{#if item.active}
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIcon('check')}
/>
</svg>
{:else if item.submenu && item.submenu.length > 0}
<svg
class="chevron-submenu"
class:rotated={openSubmenuId === item.id}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIcon('chevronDown')}
/>
</svg>
{/if}
</button>
{#if item.showSplitButton && item.onSplitClick}
<button
onclick={(e) => handleSplitClick(item, e)}
class="split-button glass-pill"
title="Open in split panel (Ctrl/Cmd+Click)"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIcon('chevronDown')}
/>
</svg>
<svg class="split-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 4H5a1 1 0 00-1 1v14a1 1 0 001 1h4a1 1 0 001-1V5a1 1 0 00-1-1zM19 4h-4a1 1 0 00-1 1v14a1 1 0 001 1h4a1 1 0 001-1V5a1 1 0 00-1-1z"
/>
</svg>
</button>
{/if}
</button>
</div>
<!-- Submenu items -->
{#if item.submenu && item.submenu.length > 0 && openSubmenuId === item.id}
<div class="submenu-container">
@ -516,6 +545,61 @@
text-align: left;
}
/* Split button wrapper */
.fan-pill-wrapper {
display: flex;
align-items: stretch;
gap: 2px;
animation: fanIn 0.15s ease-out forwards;
opacity: 0;
transform: translateY(10px);
}
.fan-up .fan-pill-wrapper {
transform: translateY(-10px);
}
.fan-pill-wrapper .fan-pill {
animation: none;
opacity: 1;
transform: none;
}
.fan-pill-wrapper.has-split-button .fan-pill {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
flex: 1;
}
.split-button {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-top-right-radius: 9999px;
border-bottom-right-radius: 9999px;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.split-button:hover {
background: var(--color-primary-100, rgba(59, 130, 246, 0.15));
border-color: var(--color-primary-200, rgba(59, 130, 246, 0.3));
}
:global(.dark) .split-button:hover {
background: var(--color-primary-900, rgba(59, 130, 246, 0.2));
border-color: var(--color-primary-800, rgba(59, 130, 246, 0.4));
}
.split-icon {
width: 0.875rem;
height: 0.875rem;
}
/* Footer for custom content (e.g., a11y toggles) */
.dropdown-footer {
animation: fanIn 0.15s ease-out forwards;

View file

@ -109,7 +109,8 @@
function createAppDropdownItems(
apps: PillAppItem[],
allAppsUrl?: string,
allAppsText?: string
allAppsText?: string,
openInPanelHandler?: (appId: string, url: string) => void
): PillDropdownItem[] {
const items: PillDropdownItem[] = apps.map((app) => ({
id: app.id,
@ -117,7 +118,19 @@
// Use image icon if available, otherwise use grid as fallback
imageUrl: app.icon,
icon: app.icon ? undefined : 'grid',
onClick: () => {
onClick: (event?: MouseEvent) => {
// Check for modifier keys (Ctrl/Cmd + Click opens in panel)
if (
event &&
(event.ctrlKey || event.metaKey) &&
openInPanelHandler &&
app.url &&
!app.isCurrent
) {
openInPanelHandler(app.id, app.url);
return;
}
if (app.isCurrent) {
// Navigate to home route for current app
window.location.href = '/';
@ -127,6 +140,10 @@
},
active: app.isCurrent,
disabled: false,
// Show split button if handler is provided and app is not current
showSplitButton: !!openInPanelHandler && !app.isCurrent && !!app.url,
onSplitClick:
openInPanelHandler && app.url ? () => openInPanelHandler(app.id, app.url!) : undefined,
}));
// Add "All Apps" link at the end if href is provided
@ -228,6 +245,10 @@
showA11yQuickToggles?: boolean;
/** Desktop navigation position (mobile always at bottom) */
desktopPosition?: 'top' | 'bottom';
/** Called when an app should be opened in a split panel */
onOpenInPanel?: (appId: string, url: string) => void;
/** Toolbar content snippet (shown in sidebar mode) */
toolbarContent?: Snippet;
}
let {
@ -270,6 +291,8 @@
onA11yReduceMotionChange,
showA11yQuickToggles = false,
desktopPosition = 'top',
onOpenInPanel,
toolbarContent,
}: Props = $props();
// Type guards for elements
@ -320,8 +343,15 @@
}
});
// Dropdown direction: up on mobile (nav at bottom), down on desktop/sidebar
const dropdownDirection = $derived<'up' | 'down'>(isMobile && !isSidebarMode ? 'up' : 'down');
// Dropdown direction: up when nav is at bottom (mobile or desktop-bottom), down otherwise
const dropdownDirection = $derived<'up' | 'down'>(
// Mobile: always up (nav at bottom) unless in sidebar mode
(isMobile && !isSidebarMode) ||
// Desktop with bottom position: up unless in sidebar mode
(!isMobile && desktopPosition === 'bottom' && !isSidebarMode)
? 'up'
: 'down'
);
function toggleSidebarMode() {
const newValue = !isSidebarMode;
@ -412,7 +442,14 @@
chevronDown: 'M19 9l-7 7-7-7',
chevronUp: 'M5 15l7-7 7 7',
chevronLeft: 'M15 19l-7-7 7-7',
chevronRight: 'M9 5l7 7-7 7',
menu: 'M4 6h16M4 12h16M4 18h16',
// Layout icons
sidebar: 'M3 3h7v18H3V3zm9 0h9v18h-9V3z', // Sidebar layout icon
layoutBottom: 'M3 3h18v9H3V3zm0 12h18v6H3v-6z', // Bottom bar layout icon
panelRight: 'M9 3h12v18H9V3zM3 3h3v18H3V3z', // Panel right icon
minimize: 'M4 12h16', // Minimize (minus) icon
maximize: 'M4 8h16M4 16h16', // Two lines for expand
fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
grid: 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z',
gridSmall:
@ -442,7 +479,7 @@
<!-- Logo pill / App Switcher -->
{#if showAppSwitcher && appItems.length > 0}
<PillDropdown
items={createAppDropdownItems(appItems, allAppsHref, allAppsLabel)}
items={createAppDropdownItems(appItems, allAppsHref, allAppsLabel, onOpenInPanel)}
direction={dropdownDirection}
label={appName}
icon="grid"
@ -770,28 +807,24 @@
<!-- Control Button (right position in horizontal mode, bottom in sidebar mode) -->
{#if !isSidebarMode}
<div class="pill glass-pill segmented-control">
<button onclick={collapseNav} class="segment-btn" title="Collapse navigation">
<button onclick={collapseNav} class="segment-btn" title="Navigation minimieren">
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath('chevronLeft')}
d={getIconPath('chevronRight')}
/>
</svg>
</button>
<div class="segment-divider"></div>
<button
onclick={toggleSidebarMode}
class="segment-btn"
title="Switch to sidebar navigation"
>
<button onclick={toggleSidebarMode} class="segment-btn" title="Zur Sidebar wechseln">
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath('chevronDown')}
d={getIconPath('sidebar')}
/>
</svg>
</button>
@ -800,26 +833,37 @@
<!-- Control Button (bottom position in sidebar mode) -->
{#if isSidebarMode}
<!-- Toolbar content (if provided) -->
{#if toolbarContent}
<div class="pill-divider sidebar-divider"></div>
<div class="sidebar-toolbar-content">
{@render toolbarContent()}
</div>
{/if}
<div class="sidebar-spacer"></div>
<div class="pill glass-pill segmented-control sidebar-segmented">
<button onclick={toggleSidebarMode} class="segment-btn" title="Switch to top navigation">
<button
onclick={toggleSidebarMode}
class="segment-btn"
title="Zur Bottom-Navigation wechseln"
>
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath('chevronUp')}
d={getIconPath('layoutBottom')}
/>
</svg>
</button>
<div class="segment-divider"></div>
<button onclick={collapseNav} class="segment-btn" title="Collapse navigation">
<button onclick={collapseNav} class="segment-btn" title="Sidebar minimieren">
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath('chevronLeft')}
d={getIconPath('chevronRight')}
/>
</svg>
</button>
@ -1097,6 +1141,89 @@
height: 100%;
}
/* Toolbar content in sidebar mode */
.sidebar-toolbar-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0;
max-height: 40vh;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
.sidebar-toolbar-content::-webkit-scrollbar {
width: 4px;
}
.sidebar-toolbar-content::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-toolbar-content::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
:global(.dark) .sidebar-toolbar-content {
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
:global(.dark) .sidebar-toolbar-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
.sidebar-toolbar-content :global(.toolbar-bar) {
flex-direction: column;
background: transparent;
backdrop-filter: none;
border: none;
box-shadow: none;
border-radius: 0;
padding: 0;
gap: 0.5rem;
}
.sidebar-toolbar-content :global(.toolbar-content) {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.sidebar-toolbar-content :global(.pill-toolbar-btn),
.sidebar-toolbar-content :global(.pill-dropdown .trigger-button) {
width: 100%;
justify-content: flex-start;
background: transparent;
border: 1px solid transparent;
box-shadow: none;
}
.sidebar-toolbar-content :global(.pill-toolbar-btn:hover),
.sidebar-toolbar-content :global(.pill-dropdown .trigger-button:hover) {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .sidebar-toolbar-content :global(.pill-toolbar-btn:hover),
:global(.dark) .sidebar-toolbar-content :global(.pill-dropdown .trigger-button:hover) {
background: rgba(255, 255, 255, 0.1);
}
/* Style for PillViewSwitcher in sidebar */
.sidebar-toolbar-content :global(.pill-view-switcher) {
flex-direction: column;
gap: 0.25rem;
}
.sidebar-toolbar-content :global(.pill-view-switcher .view-option) {
width: 100%;
justify-content: flex-start;
border-radius: 9999px;
}
/* Mobile: Sidebar container adjustments */
@media (max-width: 768px) {
.sidebar-container {
@ -1286,17 +1413,17 @@
margin: 0;
}
/* FAB for collapsed state */
/* FAB for collapsed state - positioned at right */
.nav-fab {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
padding: 0.875rem;
border-radius: 0 0 1rem 0;
border-radius: 0 0 0 1rem;
cursor: pointer;
border: none;
}
@ -1306,17 +1433,17 @@
.nav-fab.desktop-bottom {
top: auto;
bottom: 0;
border-radius: 0 1rem 0 0;
border-radius: 1rem 0 0 0;
}
}
/* Mobile: FAB always at bottom left */
/* Mobile: FAB always at bottom right */
@media (max-width: 768px) {
.nav-fab {
top: auto;
bottom: 0;
left: 0;
border-radius: 0 1rem 0 0;
right: 0;
border-radius: 1rem 0 0 0;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 0.875rem);
}
}

View file

@ -31,8 +31,8 @@ export interface PillDropdownItem {
icon?: string;
/** Image URL for icon (data URL or regular URL) */
imageUrl?: string;
/** Click handler */
onClick?: () => void;
/** Click handler (receives optional MouseEvent for modifier key detection) */
onClick?: (event?: MouseEvent) => void;
/** Whether item is disabled */
disabled?: boolean;
/** Whether item should be styled as danger/destructive */
@ -43,6 +43,10 @@ export interface PillDropdownItem {
divider?: boolean;
/** Nested submenu items */
submenu?: PillDropdownItem[];
/** Whether to show a split button for opening in panel */
showSplitButton?: boolean;
/** Click handler for split button */
onSplitClick?: () => void;
}
export interface PillAppItem {

View file

@ -25,6 +25,7 @@ export const MANACORE_SHARED_PACKAGES = [
'@manacore/shared-profile-ui',
'@manacore/shared-i18n',
'@manacore/shared-api-client',
'@manacore/shared-splitscreen',
] as const;
export interface ViteConfigOptions {