Frameless transparent window with custom title bar

Remove native window decorations and add transparent background with
rounded corners, border, and drop shadow. Add custom close button
(Linux) and minimize/maximize/close (Windows) in the header. Add
programmatic window dragging via mousedown and double-click to maximize.
Install tauri-plugin-os for platform detection. Move sync spinner to
bottom-right corner. Convert drawer layout from vw to cqi units to
support the padding-based shadow approach.
This commit is contained in:
Tristan Michael 2026-03-30 07:54:17 -07:00
parent e5c78ddfde
commit ea76f65579
9 changed files with 250 additions and 128 deletions

View file

@ -9,7 +9,8 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0" "@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-os": "^2.3.2"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
@ -1421,6 +1422,15 @@
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
} }
}, },
"node_modules/@tauri-apps/plugin-os": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz",
"integrity": "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",

View file

@ -20,6 +20,7 @@
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0" "@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-os": "^2.3.2"
} }
} }

View file

@ -13,6 +13,7 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-os = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
bevy-tasks-core = { path = "../../../crates/bevy-tasks-core" } bevy-tasks-core = { path = "../../../crates/bevy-tasks-core" }

View file

@ -0,0 +1,15 @@
{
"identifier": "default",
"description": "Default capabilities for Bevy Tasks",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"os:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-start-dragging",
"core:window:allow-is-maximized"
]
}

View file

@ -353,6 +353,7 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init())
.manage(Mutex::new(AppState { config, repo: None })) .manage(Mutex::new(AppState { config, repo: None }))
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_config, get_config,

View file

@ -18,7 +18,9 @@
"height": 700, "height": 700,
"minWidth": 320, "minWidth": 320,
"minHeight": 500, "minHeight": 500,
"resizable": true "resizable": true,
"decorations": false,
"transparent": true
} }
], ],
"security": { "security": {

View file

@ -11,22 +11,25 @@
</script> </script>
<div class={app.darkMode ? "dark" : ""}> <div class={app.darkMode ? "dark" : ""}>
<div <div class="h-screen w-screen p-2">
class="h-screen w-screen overflow-hidden bg-surface-light text-text-light dark:bg-surface-dark dark:text-text-dark" <div
> class="relative h-full w-full overflow-hidden rounded-xl border border-black/15 bg-surface-light text-text-light dark:border-white/15 dark:bg-surface-dark dark:text-text-dark"
{#if app.error} style="container-type: inline-size; box-shadow: 0 2px 8px rgba(0,0,0,0.25), 0 0 2px rgba(0,0,0,0.1)"
<div >
class="fixed top-0 left-0 right-0 z-50 flex items-center justify-between bg-danger px-4 py-2 text-sm text-white" {#if app.error}
> <div
<span>{app.error}</span> class="absolute top-0 left-0 right-0 z-50 flex items-center justify-between bg-danger px-4 py-2 text-sm text-white"
<button onclick={() => app.clearError()} class="ml-2 font-bold"></button> >
</div> <span>{app.error}</span>
{/if} <button onclick={() => app.clearError()} class="ml-2 font-bold"></button>
</div>
{/if}
{#if app.screen === "setup"} {#if app.screen === "setup"}
<SetupScreen /> <SetupScreen />
{:else} {:else}
<TasksScreen /> <TasksScreen />
{/if} {/if}
</div>
</div> </div>
</div> </div>

View file

@ -24,11 +24,16 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
html {
background: transparent;
}
body { body {
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
background: transparent;
} }
/* Scrollbar styling */ /* Scrollbar styling */
@ -42,3 +47,9 @@ body {
.dark ::-webkit-scrollbar-thumb { .dark ::-webkit-scrollbar-thumb {
background: #4b5563; background: #4b5563;
} }
/* Select dropdown theming */
.dark select option {
background-color: #242424;
color: #e5e7eb;
}

View file

@ -1,8 +1,28 @@
<script lang="ts"> <script lang="ts">
import { app } from "../stores/app.svelte"; import { app } from "../stores/app.svelte";
import TaskItem from "../components/TaskItem.svelte"; import TaskItem from "../components/TaskItem.svelte";
import TaskDetailView from "../components/TaskDetailView.svelte";
import NewTaskInput, { newTaskState } from "../components/NewTaskInput.svelte"; import NewTaskInput, { newTaskState } from "../components/NewTaskInput.svelte";
import SettingsScreen from "./SettingsScreen.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 selectedTaskId = $state<string | null>(null);
let selectedTask = $derived(selectedTaskId ? app.tasks.find(t => t.id === selectedTaskId) ?? null : null);
function openTask(task: Task) {
selectedTaskId = task.id;
}
function closeDetail() {
selectedTaskId = null;
}
let showDrawer = $state(false); let showDrawer = $state(false);
let showSettings = $state(false); let showSettings = $state(false);
@ -23,8 +43,8 @@
window.addEventListener("mousedown", handleWindowClick); window.addEventListener("mousedown", handleWindowClick);
} }
let newListName = $state(""); let newListName = $state("");
let showCompleted = $state(true); let showCompleted = $state(false);
let completedVisible = $state(true); let completedVisible = $state(false);
let listMenuId = $state<string | null>(null); let listMenuId = $state<string | null>(null);
let wsMenuName = $state<string | null>(null); let wsMenuName = $state<string | null>(null);
let dragId = $state<string | null>(null); let dragId = $state<string | null>(null);
@ -113,19 +133,25 @@
showSettings = false; showSettings = false;
} }
function handleHeaderMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("button")) return;
if (isDesktop) appWindow.startDragging();
}
let workspaceNames = $derived(app.config ? Object.keys(app.config.workspaces) : []); let workspaceNames = $derived(app.config ? Object.keys(app.config.workspaces) : []);
let translateX = $derived(showDrawer ? '0' : '-80vw'); let translateX = $derived(showDrawer ? '0' : '-80cqi');
</script> </script>
<!-- Viewport clip --> <!-- Viewport clip -->
<div class="h-screen w-screen overflow-hidden"> <div class="h-full w-full overflow-hidden">
<!-- Sliding container: left drawer + main content --> <!-- Sliding container: left drawer + main content -->
<div <div
class="flex h-full ease-out {resizing ? '' : 'transition-transform duration-250'}" class="flex h-full ease-out {resizing ? '' : 'transition-transform duration-250'}"
style="width: calc(100vw + 80vw); transform: translateX({translateX})" style="width: calc(100cqi + 80cqi); transform: translateX({translateX})"
> >
<!-- Drawer panel --> <!-- Drawer panel -->
<div class="flex h-full w-[80vw] shrink-0 flex-col bg-surface-light dark:bg-surface-dark"> <div class="flex h-full shrink-0 flex-col bg-surface-light dark:bg-surface-dark" style="width: 80cqi">
<!-- List items + new list button --> <!-- List items + new list button -->
<div class="flex-1 overflow-y-auto py-2"> <div class="flex-1 overflow-y-auto py-2">
{#each app.lists as list (list.id)} {#each app.lists as list (list.id)}
@ -287,7 +313,7 @@
</div> </div>
<!-- Main content panel --> <!-- Main content panel -->
<div class="relative flex h-full w-screen shrink-0 flex-col bg-surface-light dark:bg-surface-dark"> <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 --> <!-- Dim overlay when drawer is open -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
@ -296,115 +322,167 @@
onclick={closeDrawer} onclick={closeDrawer}
onkeydown={(e) => { if (e.key === "Escape") closeDrawer(); }} onkeydown={(e) => { if (e.key === "Escape") closeDrawer(); }}
></div> ></div>
<!-- Header -->
<header <!-- Sliding inner: task list + detail view -->
class="relative flex items-center border-b border-border-light px-4 py-3 dark:border-border-dark" <div
class="flex h-full {resizing ? '' : 'transition-transform duration-250'} ease-out"
style="width: 200%; transform: translateX({selectedTask ? '-50%' : '0'})"
> >
<!-- Back arrow (left) --> <!-- Sub-panel: Task list -->
<button <div class="relative flex h-full w-1/2 flex-col">
onclick={() => (showDrawer = !showDrawer)} <!-- Header / drag region -->
class="absolute left-2 rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10" <!-- svelte-ignore a11y_no_static_element_interactions -->
> <header
<svg class="h-5 w-5 opacity-60" viewBox="0 0 20 20" fill="currentColor"> onmousedown={handleHeaderMouseDown}
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" /> ondblclick={() => { if (isDesktop) appWindow.toggleMaximize(); }}
</svg> class="relative flex items-center border-b border-border-light px-4 py-3 dark:border-border-dark"
</button> >
<!-- Drawer toggle (left) -->
<!-- Centered title -->
<div class="flex-1 text-center">
<p class="text-xs text-text-secondary-light dark:text-text-secondary-dark">
{app.config?.current_workspace ?? ""}
</p>
<p class="text-lg font-bold">{app.activeList?.title ?? "Tasks"}</p>
</div>
<!-- Sync spinner (right) -->
{#if app.syncing}
<div class="absolute right-4 h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
{/if}
</header>
<!-- Task list -->
<main class="flex-1 overflow-y-auto">
{#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} />
</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 <button
onclick={() => { onclick={() => (showDrawer = !showDrawer)}
if (showCompleted) { class="absolute left-2 rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
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-5 w-5 opacity-60" viewBox="0 0 20 20" fill="currentColor">
<svg <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" />
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> </svg>
</button> </button>
{#if completedVisible}
<div class="transition-all duration-300 ease-out {showCompleted ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'}"> <!-- Centered title -->
{#each app.completedTasks as task (task.id)} <div class="flex-1 text-center">
<TaskItem {task} /> <p class="text-xs text-text-secondary-light dark:text-text-secondary-dark">
{/each} {app.config?.current_workspace ?? ""}
</p>
<p class="text-lg font-bold">{app.activeList?.title ?? "Tasks"}</p>
</div>
<!-- Window controls (right) -->
{#if isDesktop}
<div class="absolute right-1.5 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>
<button
onclick={() => appWindow.toggleMaximize()}
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="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="14" height="14" rx="1" />
</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> </div>
{/if} {/if}
{/if} </header>
{/if}
</main>
<!-- FAB button --> <!-- Task list -->
<div <main class="flex-1 overflow-y-auto">
class="pointer-events-none absolute bottom-6 left-0 right-0 z-30 flex justify-center transition-all duration-250 ease-out {newTaskState.open ? 'opacity-0 scale-75' : ''} {showDrawer ? 'translate-y-24 opacity-0' : 'translate-y-0 opacity-100'}" {#if app.lists.length === 0}
> <div class="flex h-full flex-col items-center justify-center p-8 text-center">
<button <p class="text-lg font-medium opacity-60">No lists yet</p>
onclick={() => { if (app.activeListId) newTaskState.open = true; }} <p class="mt-1 text-sm opacity-40">Tap the list name above to create one</p>
disabled={!app.activeListId} </div>
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" {:else if !app.activeListId}
> <div class="flex h-full items-center justify-center opacity-40">
<svg class="h-7 w-7" viewBox="0 0 20 20" fill="currentColor"> Select a list
<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" /> </div>
</svg> {:else}
</button> {#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}
</main>
<!-- FAB button -->
<div
class="pointer-events-none absolute bottom-6 left-0 right-0 z-20 flex justify-center transition-all duration-250 ease-out {newTaskState.open ? 'opacity-0 scale-75' : ''} {showDrawer || selectedTask ? 'translate-y-24 opacity-0' : 'translate-y-0 opacity-100'}"
>
<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/2 flex-col bg-surface-light dark:bg-surface-dark">
{#if selectedTask}
<TaskDetailView task={selectedTask} onback={closeDetail} />
{/if}
</div>
</div> </div>
<!-- Sync spinner -->
{#if app.syncing}
<div class="absolute bottom-4 right-4 z-20 h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
{/if}
</div> </div>
</div> </div>
</div> </div>
@ -412,7 +490,7 @@
<!-- Settings popup overlay --> <!-- Settings popup overlay -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="fixed inset-0 z-50 flex transition-opacity duration-200 {showSettings ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}" 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%" style="padding: 4%"
> >
<!-- Backdrop --> <!-- Backdrop -->
@ -431,6 +509,6 @@
</div> </div>
<!-- Toast overlay (outside sliding container so it stays centered) --> <!-- Toast overlay (outside sliding container so it stays centered) -->
<div class="pointer-events-none fixed inset-0 z-50"> <div class="pointer-events-none absolute inset-0 z-50">
<NewTaskInput /> <NewTaskInput />
</div> </div>