onyx-tasks/apps/tauri/src/lib/screens/TasksScreen.svelte
Tristan Michael 67eb90c2a0 Introduce windowDecorations enum with three options
Replace the previous boolean systemDecorations with a windowDecorations
enum ("custom", "none", "system") to allow three distinct decoration
modes. Update UI to use a select control in Settings, adapt App.svelte
conditionals to check for the "custom" value when applying custom
borders/rounded styles, and rename localStorage key to persist the new
enum. Also ensure the native system decorations are enabled when the
setting is "system" and export the new setter name setWindowDecorations
in the store.
Enable setting window decorations

Add the "core🪟allow-set-decorations" capability to allow the app
to control window decorations (e.g., system titlebar). This fixes the
issue where the system titlebar was not working by permitting the code
to modify decoration state.

Also ensure the capability list remains properly comma-separated after
insertion.
Add outline support for borderless mode

Allow border outline in non-system decoration modes on Linux. The change
updates the condition for applying the linux-window-border class so the
outline is applied when window decorations are custom or any non-system
mode, enabling an outline for borderless windows while keeping
system-decorated windows unaffected.
Respect borderless mode: remove rounded corners

Remove rounded corners from popups and overlays when window decorations
are disabled. The App component now passes the windowDecorations state
into the root div via a data-decorations attribute, and the CSS adds a
rule that forces square corners for all elements under
[data-decorations="none"] to ensure borderless mode yields true square
UI without rounded popups/toasts.
Do not override button/input/select border-radius in borderless mode

Prevent borderless mode from removing rounded corners on interactive
form elements. The previous selector applied border-radius: 0 to all
descendants, unintentionally flattening buttons, inputs, and selects.
Restrict the rule so it excludes button, input, and select elements to
preserve their expected styling.
Strengthen dropdown drop-shadows

Make the kebab and other dropdown menu shadows more pronounced by
replacing the tailwind shadow-lg class with shadow-2xl across
TaskDetailView.svelte and TasksScreen.svelte. This improves visual
contrast and makes menus stand out more clearly against the background,
addressing the request to make the dropshadow on the kebab menus
stronger.
Use consistent menu shadow class

Replace multiple inline shadow utility classes with a shared
"menu-shadow" class for dropdown and context menus across
TaskDetailView, SettingsScreen, and TasksScreen. This unifies styling,
reduces duplication, and allows easier tweaks to the menu drop-shadow
(potentially addressing blurry rendering) by centralizing the shadow
definition.
Add subtle shadow for dropdown/kebab menus

Soften dropdown/kebab menu appearance by adding a subtle box-shadow
utility and a darker variant for dark mode. Also refine the borderless
mode rule to avoid overriding elements that use rounded-full classes so
those intentional circular controls keep their shape.
Make dropdown buttons square-cornered in borderless mode

Ensure dropdown menu buttons use square corners when decorations are
disabled. The CSS update adds a selector to force border-radius: 0 for
buttons inside .dropdown-menu under [data-decorations="none"]. Several
dropdown container divs across TaskDetailView, SettingsScreen,
TasksScreen, and related components are given a .dropdown-menu class so
the new rule applies consistently and makes dropdown buttons match the
rest of the borderless UI.
when workspace settings is up, can we make it so I can drag the header to move the app around?

- Added data-tauri-drag-region to the header and title in SettingsScreen.svelte so the settings header can be used to drag/move the window
<task-notification>
<task-id>bzp4p2vsp</task-id>
<summary>Monitor event: "Tauri rebuild — Finished or error"</summary>
<event>[Monitor timed out — re-arm if needed.]</event>
</task-notification>


Apply decorations-none class for borderless mode

Ensure the "decorations-none" CSS class is applied/removed on the
document root when window decorations are set to "none" so borderless
mode styles take effect. The CSS selector was also adjusted to
explicitly list rounded utility classes so the rule targets known
rounded elements while keeping dropdown-menu buttons included.
2026-04-14 06:02:30 -07:00

735 lines
33 KiB
Svelte

<script lang="ts">
import { app } from "../stores/app.svelte";
import TaskItem from "../components/TaskItem.svelte";
import TaskDetailView from "../components/TaskDetailView.svelte";
import NewTaskInput, { newTaskState } from "../components/NewTaskInput.svelte";
import ConfirmDialog from "../components/ConfirmDialog.svelte";
import SettingsScreen from "./SettingsScreen.svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
import type { Task } from "../types";
const appWindow = getCurrentWindow();
const currentPlatform = platform();
const isDesktop = currentPlatform === "linux" || currentPlatform === "windows";
const isWindows = currentPlatform === "windows";
let taskStack = $state<string[]>([]);
let parentTask = $derived(taskStack.length >= 1 ? app.tasks.find(t => t.id === taskStack[0]) ?? null : null);
let subtaskDetail = $derived(taskStack.length >= 2 ? app.tasks.find(t => t.id === taskStack[1]) ?? null : null);
// Clear taskStack when the viewed task no longer exists (e.g. deleted or list switched)
$effect(() => {
if (taskStack.length > 0 && !parentTask) {
taskStack = [];
}
});
function openTask(task: Task) {
taskStack = [task.id];
}
function pushTask(task: Task) {
taskStack = [...taskStack, task.id];
}
function closeDetail() {
if (taskStack.length > 1)
taskStack = taskStack.slice(0, -1);
else
taskStack = [];
}
let showDrawer = $state(false);
let showSettings = $state(false);
let settingsWorkspace = $state<string | null>(null);
let showNewList = $state(false);
let showWorkspacePicker = $state(false);
let workspacePickerEl = $state<HTMLDivElement | null>(null);
function handleWindowClick(e: MouseEvent) {
if (showWorkspacePicker && workspacePickerEl && !workspacePickerEl.contains(e.target as Node))
showWorkspacePicker = false;
if (showListMenu && listMenuEl && !listMenuEl.contains(e.target as Node))
showListMenu = false;
}
let newListName = $state("");
let showCompleted = $state(false);
let completedVisible = $state(false);
let renamingListId = $state<string | null>(null);
let renameValue = $state("");
let showListMenu = $state(false);
let listMenuEl = $state<HTMLDivElement | null>(null);
let confirmDeleteList = $state(false);
let confirmDeleteCompleted = $state(false);
let confirmRemoveWorkspace = $state<string | null>(null);
let dragId = $state<string | null>(null);
let dragOverId = $state<string | null>(null);
let resizing = $state(false);
let resizeTimer: ReturnType<typeof setTimeout>;
$effect(() => {
window.addEventListener("mousedown", handleWindowClick);
const handleResize = () => {
resizing = true;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => (resizing = false), 150);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("mousedown", handleWindowClick);
window.removeEventListener("resize", handleResize);
};
});
async function handleNewList() {
if (!newListName.trim()) return;
await app.createList(newListName.trim());
newListName = "";
showNewList = false;
}
function promptDeleteCompleted() {
showListMenu = false;
confirmDeleteCompleted = true;
}
async function executeDeleteCompleted() {
confirmDeleteCompleted = false;
for (var t of app.completedTasks) await app.deleteTask(t.id);
}
function promptDeleteList() {
showListMenu = false;
confirmDeleteList = true;
}
async function executeDeleteList() {
confirmDeleteList = false;
if (app.activeListId) await app.deleteList(app.activeListId);
}
function startRenameList() {
showListMenu = false;
if (!app.activeListId) return;
var list = app.lists.find(l => l.id === app.activeListId);
if (!list) return;
renamingListId = app.activeListId;
renameValue = list.title;
}
async function handleRenameList() {
if (!renamingListId || !renameValue.trim()) { renamingListId = null; return; }
var list = app.lists.find(l => l.id === renamingListId);
if (renameValue.trim() !== list?.title)
await app.renameList(renamingListId, renameValue.trim());
renamingListId = null;
}
async function handleToggleGroupByDueDate() {
showListMenu = false;
if (!app.activeListId) return;
var list = app.lists.find(l => l.id === app.activeListId);
if (!list) return;
await app.setGroupByDueDate(app.activeListId, !list.group_by_due_date);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
if (showSettings) { showSettings = false; return; }
if (taskStack.length > 0) { closeDetail(); return; }
if (showListMenu) { showListMenu = false; return; }
if (showDrawer) { closeDrawer(); return; }
if (showWorkspacePicker) { showWorkspacePicker = false; return; }
}
function handleDragStart(e: DragEvent, taskId: string) {
dragId = taskId;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", taskId);
const el = (e.target as HTMLElement).closest("[draggable]") as HTMLElement;
if (el) {
const clone = el.cloneNode(true) as HTMLElement;
clone.style.width = `${el.offsetWidth}px`;
clone.style.position = "absolute";
clone.style.top = "-9999px";
clone.style.left = "-9999px";
if (app.isDark) {
clone.classList.add("dark");
clone.style.backgroundColor = "var(--color-surface-dark)";
clone.style.color = "var(--color-text-dark)";
}
clone.style.opacity = "0.85";
clone.style.borderRadius = "8px";
clone.style.overflow = "hidden";
clone.style.boxShadow = "0 4px 12px rgba(0,0,0,0.3)";
document.body.appendChild(clone);
e.dataTransfer.setDragImage(clone, e.offsetX, e.offsetY);
requestAnimationFrame(() => clone.remove());
}
}
}
function handleDragOver(e: DragEvent, taskId: string) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
dragOverId = taskId;
}
function handleDragEnd() {
dragId = null;
dragOverId = null;
}
async function handleDrop(e: DragEvent, targetId: string) {
e.preventDefault();
if (!dragId || dragId === targetId) { handleDragEnd(); return; }
const targetIndex = app.pendingTasks.findIndex((t) => t.id === targetId);
if (targetIndex >= 0) await app.reorderTask(dragId, targetIndex);
handleDragEnd();
}
function closeDrawer() {
showDrawer = false;
showNewList = false;
}
function closeSettings() {
showSettings = false;
settingsWorkspace = null;
}
function handleHeaderMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("button")) return;
if (isDesktop) appWindow.startDragging();
}
let workspaceIds = $derived(app.config ? Object.keys(app.config.workspaces).sort((a, b) => (app.config!.workspaces[a].name).localeCompare(app.config!.workspaces[b].name)) : []);
let translateX = $derived(showDrawer ? '0' : '-80cqi');
let _edgeSwipeStartX = 0;
let _edgeSwipeStartY = 0;
let _edgeSwipeActive = false;
function handleEdgeTouchStart(e: TouchEvent) {
const t = e.touches[0];
if (t.clientX > 20) return;
_edgeSwipeStartX = t.clientX;
_edgeSwipeStartY = t.clientY;
_edgeSwipeActive = true;
}
function handleEdgeTouchMove(e: TouchEvent) {
if (!_edgeSwipeActive) return;
const dx = e.touches[0].clientX - _edgeSwipeStartX;
const dy = e.touches[0].clientY - _edgeSwipeStartY;
// Cancel if gesture is more vertical than horizontal
if (Math.abs(dy) > Math.abs(dx)) { _edgeSwipeActive = false; return; }
// Prevent scroll interference while swiping right from edge
e.preventDefault();
}
function handleEdgeTouchEnd(e: TouchEvent) {
if (!_edgeSwipeActive) return;
_edgeSwipeActive = false;
const dx = e.changedTouches[0].clientX - _edgeSwipeStartX;
if (dx < 60) return;
if (taskStack.length > 0) closeDetail();
else if (!showDrawer) showDrawer = true;
}
let _viewportEl = $state<HTMLDivElement | null>(null);
// Register touchmove as non-passive so preventDefault() works
$effect(() => {
if (!_viewportEl) return;
_viewportEl.addEventListener('touchmove', handleEdgeTouchMove, { passive: false });
return () => _viewportEl!.removeEventListener('touchmove', handleEdgeTouchMove);
});
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- Viewport clip -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="h-full w-full overflow-hidden"
role="region"
bind:this={_viewportEl}
ontouchstart={handleEdgeTouchStart}
ontouchend={handleEdgeTouchEnd}
>
<!-- Sliding container: left drawer + main content -->
<div
class="flex h-full ease-out {resizing ? '' : 'transition-transform duration-250'}"
style="width: calc(100cqi + 80cqi); transform: translateX({translateX})"
>
<!-- Drawer panel -->
<div class="flex h-full shrink-0 flex-col bg-surface-light dark:bg-surface-dark" style="width: 80cqi">
<div class="shrink-0" style="height: var(--safe-top)"></div>
<!-- Drawer header: workspace switcher + settings -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onmousedown={handleHeaderMouseDown}
class="flex h-11 shrink-0 items-center justify-between border-b border-border-light px-3 dark:border-border-dark"
>
<div class="relative min-w-0 flex-1" bind:this={workspacePickerEl}>
<button
onclick={() => (showWorkspacePicker = !showWorkspacePicker)}
class="flex items-center gap-1.5 rounded-lg px-2 py-1 text-sm font-semibold hover:bg-black/5 dark:hover:bg-white/10"
>
<span class="truncate">{app.config?.current_workspace ? app.config.workspaces[app.config.current_workspace]?.name ?? "Workspace" : "Workspace"}</span>
<svg class="h-3.5 w-3.5 shrink-0 transition-transform {showWorkspacePicker ? 'rotate-180' : ''}" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" />
</svg>
</button>
{#if showWorkspacePicker}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="dropdown-menu absolute left-0 top-full z-40 mt-1 w-full rounded-lg border border-border-light bg-surface-light py-1 menu-shadow dark:border-border-dark dark:bg-surface-dark"
>
{#each workspaceIds as wsId}
{@const ws = app.config?.workspaces[wsId]}
<div class="group flex items-center px-1 hover:bg-black/5 dark:hover:bg-white/10">
<button
onclick={() => { if (wsId !== app.config?.current_workspace) app.switchWorkspace(wsId); showWorkspacePicker = false; }}
class="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left {wsId === app.config?.current_workspace ? 'font-bold' : ''}"
>
{#if wsId === app.config?.current_workspace}
<svg class="h-4 w-4 shrink-0 opacity-50" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
</svg>
{/if}
<div class="min-w-0 flex-1">
<p class="truncate text-sm">{ws?.name}</p>
<p class="truncate text-xs opacity-40">{ws?.mode === "webdav" ? ws.webdav_url ?? "WebDAV" : ws?.path?.replace(/\/[^/]+\/?$/, "") ?? ""}</p>
</div>
</button>
<button
onclick={(e) => { e.stopPropagation(); settingsWorkspace = wsId; showSettings = true; showWorkspacePicker = false; }}
class="shrink-0 rounded p-1 opacity-0 transition-opacity group-hover:opacity-40 hover:!opacity-80"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
</svg>
</button>
</div>
{/each}
<div class="mt-1 border-t border-border-light px-1 pt-1 dark:border-border-dark">
<button
onclick={() => { showWorkspacePicker = false; app.setScreen("setup"); }}
class="w-full rounded-md px-2 py-1.5 text-left text-sm text-primary hover:bg-primary/5"
>
+ Add workspace
</button>
</div>
</div>
{/if}
</div>
{#if app.config?.current_workspace}
<button
onclick={() => { settingsWorkspace = app.config!.current_workspace; showSettings = true; }}
class="shrink-0 rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
aria-label="Workspace settings"
>
<svg class="h-4 w-4 opacity-50" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</div>
<!-- List items + new list button -->
<div class="flex-1 overflow-y-auto py-2">
{#each app.lists as list (list.id)}
<button
onclick={() => { app.selectList(list.id); taskStack = []; closeDrawer(); }}
class="group flex w-full items-center gap-2 px-5 py-2.5 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10 {list.id === app.activeListId ? 'font-bold' : ''}"
>
{#if list.id === app.activeListId}
<svg class="h-4 w-4 shrink-0 opacity-50" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
</svg>
{/if}
<span class="flex-1">{list.title}</span>
<svg class="h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-30" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" />
</svg>
</button>
{/each}
<!-- New list inline -->
<div class="px-2 mt-1">
{#if showNewList}
<div class="flex gap-2 px-1">
<input
type="text"
bind:value={newListName}
placeholder="List name"
class="min-w-0 flex-1 rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
onkeydown={(e) => { if (e.key === "Enter") handleNewList(); if (e.key === "Escape") { showNewList = false; newListName = ""; } }}
/>
<button
onclick={handleNewList}
disabled={!newListName.trim()}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-40"
>
Add
</button>
</div>
{:else}
<button
onclick={() => (showNewList = true)}
class="w-full rounded-lg px-3 py-2.5 text-left text-sm text-primary hover:bg-primary/5"
>
+ New list
</button>
{/if}
</div>
</div>
<!-- Drawer footer: sync status -->
<div class="shrink-0 px-4 py-2.5" style="padding-bottom: max(1.25rem, var(--safe-bottom))">
{#if app.isWebdav}
<div class="flex items-center gap-2">
<!-- Status dot -->
<span
class="inline-block h-2 w-2 rounded-full {app.syncing ? 'animate-pulse bg-primary' : app.syncStatus === 'synced' || app.syncStatus === 'idle' ? 'bg-green-500' : app.syncStatus === 'error' ? 'bg-red-500' : 'bg-gray-400'}"
></span>
<span class="flex-1 text-xs opacity-60">
{app.syncing ? "Syncing..." : app.syncStatus === "synced" || app.syncStatus === "idle" ? "Synced" : app.syncStatus === "error" ? "Sync error" : "Offline"}{#if !app.syncing && app.lastSyncResult && (app.lastSyncResult.uploaded > 0 || app.lastSyncResult.downloaded > 0)}&nbsp;&nbsp;{#if app.lastSyncResult.uploaded > 0}{app.lastSyncResult.uploaded}{/if}{#if app.lastSyncResult.uploaded > 0 && app.lastSyncResult.downloaded > 0} {/if}{#if app.lastSyncResult.downloaded > 0}{app.lastSyncResult.downloaded}{/if}{/if}
</span>
<!-- Manual sync button -->
<button
onclick={() => app.triggerSync()}
disabled={app.syncing}
class="rounded-lg p-1.5 hover:bg-black/5 disabled:opacity-30 dark:hover:bg-white/10"
title="Sync now"
>
<svg class="h-4 w-4" style={app.syncing ? 'animation: spin 1s linear infinite reverse' : ''} viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
</svg>
</button>
</div>
{:else}
<span class="text-xs opacity-40">Local workspace</span>
{/if}
</div>
</div>
<!-- Main content panel -->
<div class="relative h-full shrink-0 overflow-hidden bg-surface-light dark:bg-surface-dark" style="width: 100cqi">
<!-- Dim overlay when drawer is open -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 z-30 transition-opacity duration-250 ease-out {showDrawer ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}"
style="box-shadow: inset 8px 0 24px rgba(0,0,0,0.4); background: rgba(0,0,0,0.4)"
onclick={closeDrawer}
onkeydown={(e) => { if (e.key === "Escape") closeDrawer(); }}
></div>
<!-- Sliding inner: task list + detail view -->
<div
class="flex h-full {resizing ? '' : 'transition-transform duration-250'} ease-out"
style="width: 300%; transform: translateX({taskStack.length === 0 ? '0' : taskStack.length === 1 ? '-33.333%' : '-66.666%'})"
>
<!-- Sub-panel: Task list -->
<div class="relative flex h-full w-1/3 flex-col">
<div class="shrink-0" style="height: var(--safe-top)"></div>
<!-- Header / drag region -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<header
onmousedown={handleHeaderMouseDown}
class="relative flex h-11 items-center border-b border-border-light px-4 dark:border-border-dark"
>
<!-- Drawer toggle (left) -->
<button
onclick={() => (showDrawer = !showDrawer)}
class="rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<svg class="h-5 w-5 opacity-60" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h8a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" />
</svg>
</button>
<div class="flex-1"></div>
<!-- Window controls (right) -->
{#if isDesktop}
<div class="flex items-center gap-0.5">
{#if isWindows}
<button
onclick={() => appWindow.minimize()}
class="rounded p-1.5 opacity-50 hover:bg-black/10 hover:opacity-80 dark:hover:bg-white/10"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M4 10a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1z" />
</svg>
</button>
{/if}
<button
onclick={() => appWindow.close()}
class="rounded p-1.5 opacity-50 hover:bg-danger/20 hover:opacity-100 hover:text-danger dark:hover:bg-danger/20"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
{/if}
</header>
<!-- List name + kebab (below header bar, like task detail) -->
<div class="relative px-4 pt-3 pb-2">
{#if app.activeListId}
<!-- Kebab menu -->
<div class="absolute right-3 top-1" bind:this={listMenuEl}>
<button
onclick={() => (showListMenu = !showListMenu)}
class="rounded-lg p-1.5 opacity-50 hover:bg-black/5 hover:opacity-80 dark:hover:bg-white/10"
>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
{#if showListMenu}
<div class="dropdown-menu absolute right-0 top-full z-40 mt-1 min-w-[200px] rounded-lg border border-border-light bg-surface-light py-1 menu-shadow dark:border-border-dark dark:bg-surface-dark">
<button
onclick={startRenameList}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
Rename
</button>
<button
onclick={handleToggleGroupByDueDate}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" />
</svg>
Group by due date
{#if app.activeList?.group_by_due_date}
<svg class="ml-auto h-4 w-4 text-primary" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
</svg>
{/if}
</button>
{#if app.completedTasks.length > 0}
<button
onclick={promptDeleteCompleted}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
Delete completed
</button>
{/if}
<button
onclick={promptDeleteList}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
Delete list
</button>
</div>
{/if}
</div>
{/if}
{#if renamingListId === app.activeListId}
<input
type="text"
bind:value={renameValue}
class="w-full bg-transparent text-xl font-bold outline-none"
onkeydown={(e) => { if (e.key === "Enter") handleRenameList(); if (e.key === "Escape") renamingListId = null; }}
onblur={handleRenameList}
autofocus
/>
{:else}
<p class="text-xl font-bold">{app.activeList?.title ?? "Tasks"}</p>
{/if}
</div>
<!-- Task list -->
<main class="flex-1 overflow-y-auto" style="padding-bottom: max(6rem, var(--safe-bottom))">
{#key app.activeListId}
{#if app.lists.length === 0}
<div class="flex h-full flex-col items-center justify-center p-8 text-center">
<p class="text-lg font-medium opacity-60">No lists yet</p>
<p class="mt-1 text-sm opacity-40">Tap the list name above to create one</p>
</div>
{:else if !app.activeListId}
<div class="flex h-full items-center justify-center opacity-40">
Select a list
</div>
{:else}
{#each app.pendingTasks as task (task.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
draggable="true"
ondragstart={(e) => handleDragStart(e, task.id)}
ondragover={(e) => handleDragOver(e, task.id)}
ondragend={handleDragEnd}
ondrop={(e) => handleDrop(e, task.id)}
class="{dragId === task.id ? 'opacity-30' : ''} {dragOverId === task.id && dragId !== task.id ? 'border-t-2 border-t-primary' : ''}"
>
<TaskItem {task} onopen={openTask} />
</div>
{/each}
{#if app.pendingTasks.length === 0}
<div class="p-8 text-center text-sm opacity-40">No tasks. Add one below.</div>
{/if}
{#if app.completedTasks.length > 0}
<div class="h-4"></div>
<button
onclick={() => {
if (showCompleted) {
showCompleted = false;
setTimeout(() => (completedVisible = false), 300);
} else {
completedVisible = true;
requestAnimationFrame(() => (showCompleted = true));
}
}}
class="relative z-10 flex w-full items-center justify-between border-t border-border-light bg-surface-light px-4 py-3 text-sm font-medium text-text-secondary-light transition-colors hover:bg-black/5 dark:border-border-dark dark:bg-surface-dark dark:text-text-secondary-dark dark:hover:bg-white/5"
>
Completed ({app.completedTasks.length})
<svg
class="h-4 w-4 transition-transform {showCompleted ? 'rotate-90' : ''}"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
/>
</svg>
</button>
{#if completedVisible}
<div class="transition-all duration-300 ease-out {showCompleted ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'}">
{#each app.completedTasks as task (task.id)}
<TaskItem {task} onopen={openTask} />
{/each}
</div>
{/if}
{/if}
{/if}
{/key}
</main>
<!-- FAB button -->
<div
class="pointer-events-none absolute left-0 right-0 z-20 flex justify-center transition-all duration-250 ease-out {newTaskState.open ? 'opacity-0 scale-75' : ''} {showDrawer || taskStack.length > 0 ? 'translate-y-24 opacity-0' : 'translate-y-0 opacity-100'}"
style="bottom: max(1.5rem, var(--safe-bottom))"
>
<button
onclick={() => { if (app.activeListId) newTaskState.open = true; }}
disabled={!app.activeListId}
class="pointer-events-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary text-white shadow-lg transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:shadow-none"
>
<svg class="h-7 w-7" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" />
</svg>
</button>
</div>
</div>
<!-- Sub-panel: Task detail -->
<div class="relative flex h-full w-1/3 flex-col bg-surface-light dark:bg-surface-dark">
<div class="shrink-0" style="height: var(--safe-top)"></div>
{#if parentTask}
{#key parentTask.id}
<TaskDetailView task={parentTask} onback={closeDetail} onopen={pushTask} />
{/key}
{/if}
</div>
<!-- Sub-panel: Subtask detail -->
<div class="relative flex h-full w-1/3 flex-col bg-surface-light dark:bg-surface-dark">
<div class="shrink-0" style="height: var(--safe-top)"></div>
{#if subtaskDetail}
{#key subtaskDetail.id}
<TaskDetailView task={subtaskDetail} onback={closeDetail} />
{/key}
{/if}
</div>
</div>
</div>
</div>
</div>
<!-- Settings popup overlay -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 z-50 flex transition-opacity duration-200 {showSettings ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}"
style="padding: 4%; padding-top: max(4%, env(safe-area-inset-top)); padding-bottom: max(4%, env(safe-area-inset-bottom))"
>
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/50"
onclick={closeSettings}
onkeydown={(e) => { if (e.key === "Escape") closeSettings(); }}
></div>
<!-- Settings card -->
<div
class="relative flex h-full w-full flex-col overflow-hidden rounded-2xl bg-surface-light transition-transform duration-200 dark:bg-surface-dark {showSettings ? 'scale-100' : 'scale-95'}"
style="border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 25px 60px rgba(0,0,0,0.7), 0 10px 20px rgba(0,0,0,0.5)"
>
<SettingsScreen onclose={closeSettings} workspaceId={settingsWorkspace ?? app.config?.current_workspace ?? ""} ondelete={(id) => { closeSettings(); confirmRemoveWorkspace = id; }} />
</div>
</div>
<!-- Toast overlay (outside sliding container so it stays centered) -->
<div class="pointer-events-none absolute inset-0 z-50">
<NewTaskInput />
</div>
<!-- Delete list confirmation -->
{#if confirmDeleteList}
<ConfirmDialog
message='Delete list "{app.activeList?.title}" and all its tasks?'
confirmText="Delete"
danger
onconfirm={executeDeleteList}
oncancel={() => (confirmDeleteList = false)}
/>
{/if}
<!-- Remove workspace confirmation -->
{#if confirmRemoveWorkspace}
<ConfirmDialog
message='Remove workspace "{app.config?.workspaces[confirmRemoveWorkspace]?.name ?? confirmRemoveWorkspace}"?'
detail="Files remain on disk."
confirmText="Remove"
danger
onconfirm={() => { const id = confirmRemoveWorkspace; confirmRemoveWorkspace = null; if (id) app.removeWorkspace(id); }}
oncancel={() => (confirmRemoveWorkspace = null)}
/>
{/if}
<!-- Delete completed tasks confirmation -->
{#if confirmDeleteCompleted}
<ConfirmDialog
message="Delete {app.completedTasks.length} completed task{app.completedTasks.length === 1 ? '' : 's'}?"
confirmText="Delete"
danger
onconfirm={executeDeleteCompleted}
oncancel={() => (confirmDeleteCompleted = false)}
/>
{/if}