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",
"dependencies": {
"@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": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
@ -1421,6 +1422,15 @@
"@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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",

View file

@ -20,6 +20,7 @@
},
"dependencies": {
"@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]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-os = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
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()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_os::init())
.manage(Mutex::new(AppState { config, repo: None }))
.invoke_handler(tauri::generate_handler![
get_config,

View file

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

View file

@ -11,12 +11,14 @@
</script>
<div class={app.darkMode ? "dark" : ""}>
<div class="h-screen w-screen p-2">
<div
class="h-screen w-screen overflow-hidden bg-surface-light text-text-light dark:bg-surface-dark dark:text-text-dark"
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"
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)"
>
{#if app.error}
<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"
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"
>
<span>{app.error}</span>
<button onclick={() => app.clearError()} class="ml-2 font-bold"></button>
@ -29,4 +31,5 @@
<TasksScreen />
{/if}
</div>
</div>
</div>

View file

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

View file

@ -1,8 +1,28 @@
<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 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 showSettings = $state(false);
@ -23,8 +43,8 @@
window.addEventListener("mousedown", handleWindowClick);
}
let newListName = $state("");
let showCompleted = $state(true);
let completedVisible = $state(true);
let showCompleted = $state(false);
let completedVisible = $state(false);
let listMenuId = $state<string | null>(null);
let wsMenuName = $state<string | null>(null);
let dragId = $state<string | null>(null);
@ -113,19 +133,25 @@
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 translateX = $derived(showDrawer ? '0' : '-80vw');
let translateX = $derived(showDrawer ? '0' : '-80cqi');
</script>
<!-- Viewport clip -->
<div class="h-screen w-screen overflow-hidden">
<div class="h-full w-full overflow-hidden">
<!-- Sliding container: left drawer + main content -->
<div
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 -->
<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 -->
<div class="flex-1 overflow-y-auto py-2">
{#each app.lists as list (list.id)}
@ -287,7 +313,7 @@
</div>
<!-- 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 -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
@ -296,17 +322,28 @@
onclick={closeDrawer}
onkeydown={(e) => { if (e.key === "Escape") closeDrawer(); }}
></div>
<!-- Header -->
<!-- Sliding inner: task list + detail view -->
<div
class="flex h-full {resizing ? '' : 'transition-transform duration-250'} ease-out"
style="width: 200%; transform: translateX({selectedTask ? '-50%' : '0'})"
>
<!-- Sub-panel: Task list -->
<div class="relative flex h-full w-1/2 flex-col">
<!-- Header / drag region -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<header
onmousedown={handleHeaderMouseDown}
ondblclick={() => { if (isDesktop) appWindow.toggleMaximize(); }}
class="relative flex items-center border-b border-border-light px-4 py-3 dark:border-border-dark"
>
<!-- Back arrow (left) -->
<!-- Drawer toggle (left) -->
<button
onclick={() => (showDrawer = !showDrawer)}
class="absolute left-2 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 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" />
<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>
@ -318,9 +355,36 @@
<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>
<!-- 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>
{/if}
</header>
@ -346,7 +410,7 @@
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} />
<TaskItem {task} onopen={openTask} />
</div>
{/each}
@ -383,7 +447,7 @@
{#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} />
<TaskItem {task} onopen={openTask} />
{/each}
</div>
{/if}
@ -393,7 +457,7 @@
<!-- FAB button -->
<div
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'}"
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; }}
@ -406,13 +470,27 @@
</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>
<!-- 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>
<!-- Settings popup overlay -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<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%"
>
<!-- Backdrop -->
@ -431,6 +509,6 @@
</div>
<!-- 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 />
</div>