fix(todo): pixel-perfect skeleton loaders, PillNav tab group, and SSR head fix

- Rewrite TaskListSkeleton to match notepad design (spiral holes, red margin line, cream background)
- Rewrite TaskItemSkeleton as flat rows with border-bottom instead of card style
- Rewrite KanbanColumnSkeleton with glassmorphism and pill-shaped task cards
- Update KanbanBoardSkeleton with matching border-radius and dark mode support
- Group navigation pills (Liste/Kanban/Tags) into PillTabGroup for clear UX distinction from toggle pills (Filter)
- Add Phosphor icon support to PillTabGroup component
- Fix %sveltekit.head% appearing as literal text in production by removing duplicate placeholder from HTML comment
- Disable PWA service worker in dev mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 20:51:41 +01:00
parent f5842ea50e
commit 45db42720c
7 changed files with 313 additions and 114 deletions

View file

@ -8,7 +8,7 @@
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<!-- PWA meta tags (manifest injected by @vite-pwa/sveltekit via %sveltekit.head%) -->
<!-- PWA meta tags -->
<meta name="theme-color" content="#8b5cf6" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />

View file

@ -1,6 +1,6 @@
<script lang="ts">
/**
* KanbanBoardSkeleton - Skeleton for kanban board loading
* KanbanBoardSkeleton - Skeleton matching the real kanban board layout
*/
import KanbanColumnSkeleton from './KanbanColumnSkeleton.svelte';
@ -11,6 +11,7 @@
<KanbanColumnSkeleton taskCount={3} />
<KanbanColumnSkeleton taskCount={4} />
<KanbanColumnSkeleton taskCount={2} />
<!-- Add column button placeholder -->
<div class="add-column-skeleton">
<div class="add-column-btn"></div>
</div>
@ -28,6 +29,7 @@
gap: 1rem;
padding: 0 1rem;
height: 100%;
min-height: 100%;
align-items: flex-start;
overflow-x: auto;
}
@ -40,12 +42,16 @@
width: 300px;
min-width: 300px;
height: 48px;
background: hsl(var(--muted) / 0.5);
border: 2px dashed hsl(var(--border));
border-radius: 1rem;
background: transparent;
border: 2px dashed rgba(0, 0, 0, 0.12);
border-radius: 1.5rem;
animation: pulse 2s ease-in-out infinite;
}
:global(.dark) .add-column-btn {
border-color: rgba(255, 255, 255, 0.12);
}
@keyframes pulse {
0%,
100% {

View file

@ -1,6 +1,6 @@
<script lang="ts">
/**
* KanbanColumnSkeleton - Skeleton for a single kanban column
* KanbanColumnSkeleton - Skeleton matching the glassmorphism kanban column
*/
import { SkeletonBox } from '@manacore/shared-ui';
@ -13,32 +13,47 @@
</script>
<div class="column-skeleton">
<!-- Column Header -->
<!-- Column Header: color dot with ring + name + count -->
<div class="column-header">
<div class="header-left">
<SkeletonBox width="8px" height="100%" borderRadius="4px" />
<SkeletonBox width="100px" height="18px" />
<SkeletonBox width="24px" height="20px" borderRadius="10px" />
<!-- Color indicator dot with glow ring -->
<div class="color-dot-wrapper">
<SkeletonBox width="12px" height="12px" borderRadius="9999px" />
</div>
<!-- Column name -->
<SkeletonBox width="90px" height="16px" borderRadius="4px" />
<!-- Task count -->
<SkeletonBox width="20px" height="16px" borderRadius="4px" />
</div>
<!-- Menu button -->
<SkeletonBox width="24px" height="24px" borderRadius="6px" />
</div>
<!-- Tasks -->
<!-- Tasks (pill-shaped cards) -->
<div class="tasks">
{#each Array(taskCount) as _, i}
<div class="task-card" style="opacity: {Math.max(0.4, 1 - i * 0.2)};">
<SkeletonBox width="75%" height="16px" />
<div class="task-meta">
<SkeletonBox width="60px" height="14px" />
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
<div class="task-card" style="opacity: {Math.max(0.4, 1 - i * 0.15)};">
<!-- Priority dot -->
<SkeletonBox width="8px" height="8px" borderRadius="9999px" />
<!-- Checkbox -->
<SkeletonBox width="20px" height="20px" borderRadius="9999px" />
<!-- Content -->
<div class="task-content">
<SkeletonBox width="{65 + (i % 3) * 10}%" height="14px" borderRadius="4px" />
{#if i === 0}
<div class="task-meta">
<SkeletonBox width="40px" height="12px" borderRadius="4px" />
<SkeletonBox width="36px" height="14px" borderRadius="9999px" />
</div>
{/if}
</div>
</div>
{/each}
</div>
<!-- Add Task Button -->
<!-- Quick add task area -->
<div class="add-task">
<SkeletonBox width="100%" height="36px" borderRadius="8px" />
<SkeletonBox width="100%" height="40px" borderRadius="9999px" />
</div>
</div>
@ -46,54 +61,89 @@
.column-skeleton {
width: 300px;
min-width: 300px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 1rem;
padding: 1rem;
max-width: 340px;
min-height: 250px;
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 1.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
:global(.dark) .column-skeleton {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Header: matches KanbanColumnHeader px-3.5 py-3 */
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
padding: 0.75rem 0.875rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
height: 24px;
gap: 0.625rem;
}
.color-dot-wrapper {
position: relative;
}
/* Tasks container: matches px-3 pb-3 */
.tasks {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 100px;
gap: 0.625rem;
padding: 0 0.75rem;
flex: 1;
min-height: 80px;
}
/* Task card: pill-shaped glassmorphism card */
.task-card {
padding: 0.75rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .task-card {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.task-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.25rem;
min-width: 0;
}
.task-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.375rem;
}
/* Quick add: matches px-3 pb-3 pt-2 */
.add-task {
margin-top: auto;
padding-top: 0.5rem;
padding: 0.5rem 0.75rem 0.75rem;
}
</style>

View file

@ -1,80 +1,67 @@
<script lang="ts">
/**
* TaskItemSkeleton - Skeleton for a single task item
* TaskItemSkeleton - Skeleton for a single task item in list view
* Matches the flat row style inside the notepad container
*/
import { SkeletonBox } from '@manacore/shared-ui';
interface Props {
showSubtasks?: boolean;
}
let { showSubtasks = false }: Props = $props();
</script>
<div class="task-item-skeleton">
<div class="task-main">
<SkeletonBox width="22px" height="22px" borderRadius="6px" />
<div class="task-content">
<SkeletonBox width="65%" height="18px" />
<div class="task-meta">
<SkeletonBox width="80px" height="14px" />
<SkeletonBox width="60px" height="20px" borderRadius="10px" />
</div>
</div>
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
<!-- Drag handle -->
<div class="drag-handle">
<SkeletonBox width="16px" height="16px" borderRadius="2px" />
</div>
{#if showSubtasks}
<div class="subtasks">
{#each Array(2) as _}
<div class="subtask-item">
<SkeletonBox width="16px" height="16px" borderRadius="4px" />
<SkeletonBox width="50%" height="14px" />
</div>
{/each}
<!-- Priority dot -->
<SkeletonBox width="8px" height="8px" borderRadius="9999px" />
<!-- Checkbox (round) -->
<SkeletonBox width="20px" height="20px" borderRadius="9999px" />
<!-- Content -->
<div class="task-content">
<SkeletonBox width="60%" height="16px" borderRadius="4px" />
<div class="task-meta">
<SkeletonBox width="40px" height="12px" borderRadius="4px" />
<SkeletonBox width="48px" height="16px" borderRadius="9999px" />
</div>
{/if}
</div>
<!-- Due date / expand area -->
<SkeletonBox width="50px" height="14px" borderRadius="4px" />
</div>
<style>
.task-item-skeleton {
padding: 0.875rem 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
background: transparent;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.task-main {
display: flex;
align-items: flex-start;
gap: 0.75rem;
:global(.dark) .task-item-skeleton {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.drag-handle {
opacity: 0.3;
flex-shrink: 0;
}
.task-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.25rem;
min-width: 0;
}
.task-meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.subtasks {
margin-top: 0.75rem;
padding-left: 2rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.subtask-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>

View file

@ -1,6 +1,6 @@
<script lang="ts">
/**
* TaskListSkeleton - Skeleton for task list with sections
* TaskListSkeleton - Skeleton matching the notepad list view with CollapsibleSections
*/
import { SkeletonBox } from '@manacore/shared-ui';
@ -12,62 +12,177 @@
}
let { sections = 2, tasksPerSection = 3 }: Props = $props();
// Section widths to vary the title lengths
const sectionTitleWidths = [60, 80, 100, 70, 90];
</script>
<div class="task-list-skeleton" role="status" aria-label="Aufgaben werden geladen...">
{#each Array(sections) as _, sectionIndex}
<div class="section" style="opacity: {Math.max(0.5, 1 - sectionIndex * 0.25)};">
<!-- Section header -->
<div class="section-header">
<div class="section-title">
<SkeletonBox width="20px" height="20px" borderRadius="6px" />
<SkeletonBox width="{100 + sectionIndex * 20}px" height="18px" />
<SkeletonBox width="28px" height="22px" borderRadius="11px" />
</div>
<SkeletonBox width="24px" height="24px" borderRadius="6px" />
</div>
<div class="notepad">
<!-- Spiral binding holes -->
<div class="notepad-holes" aria-hidden="true">
<span class="hole"></span>
<span class="hole"></span>
<span class="hole"></span>
<span class="hole"></span>
<span class="hole"></span>
<span class="hole"></span>
</div>
<!-- Tasks -->
<div class="tasks">
{#each Array(tasksPerSection) as _, taskIndex}
<div style="opacity: {Math.max(0.4, 1 - taskIndex * 0.2)};">
<TaskItemSkeleton />
<div class="notepad-content">
<div class="sections">
{#each Array(sections) as _, sectionIndex}
<div class="section" style="opacity: {Math.max(0.5, 1 - sectionIndex * 0.2)};">
<!-- CollapsibleSection header: icon + title + (count) + chevron -->
<button class="section-header">
<!-- Section icon -->
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
<!-- Section title -->
<SkeletonBox
width="{sectionTitleWidths[sectionIndex % sectionTitleWidths.length]}px"
height="16px"
borderRadius="4px"
/>
<!-- Count in parentheses -->
<SkeletonBox width="24px" height="14px" borderRadius="4px" />
<!-- Chevron (right-aligned) -->
<div class="chevron-spacer"></div>
<SkeletonBox width="18px" height="18px" borderRadius="4px" />
</button>
<!-- Task items -->
<div class="tasks">
{#each Array(tasksPerSection) as _, taskIndex}
<div style="opacity: {Math.max(0.4, 1 - taskIndex * 0.15)};">
<TaskItemSkeleton />
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
<style>
.task-list-skeleton {
padding-bottom: 100px;
}
/* Notepad container - matches +page.svelte .notepad */
.notepad {
max-width: 560px;
margin: 0 auto;
background: #fffef5;
border-radius: 0.5rem 0.5rem 0.75rem 0.75rem;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08),
0 1px 2px rgba(0, 0, 0, 0.04);
position: relative;
padding-top: 1.25rem;
}
:global(.dark) .notepad {
background: #2a2520;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.3),
0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Spiral binding holes */
.notepad-holes {
position: absolute;
top: -6px;
left: 10%;
right: 10%;
display: flex;
justify-content: space-evenly;
pointer-events: none;
z-index: 2;
}
.hole {
width: 14px;
height: 14px;
border-radius: 50%;
background: hsl(var(--color-background, 0 0% 100%));
border: 2px solid #c4c4c4;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
:global(.dark) .hole {
background: hsl(var(--color-background, 0 0% 7%));
border-color: #555;
}
/* Notepad content with red margin line */
.notepad-content {
position: relative;
padding: 0.75rem 1rem 1.5rem 3.25rem;
min-height: 200px;
}
.notepad-content::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 2.5rem;
width: 2px;
background: #e8b4b8;
z-index: 1;
}
:global(.dark) .notepad-content::before {
background: rgba(232, 180, 184, 0.4);
}
.sections {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.75rem;
}
.section {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
/* CollapsibleSection header style */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
}
.section-title {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
cursor: default;
background: none;
border: none;
}
.chevron-spacer {
flex: 1;
}
.tasks {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
padding-left: 0.25rem;
}
@media (max-width: 640px) {
.notepad {
max-width: 100%;
border-radius: 0;
}
.notepad-content {
padding-left: 2.75rem;
}
.notepad-content::before {
left: 2rem;
}
}
</style>

View file

@ -16,6 +16,7 @@ export default defineConfig({
shortName: 'Todo',
description: 'Aufgaben und Projekte verwalten mit Kanban-Board, Subtasks und mehr',
themeColor: '#8b5cf6',
devEnabled: false,
shortcuts: [
{
name: 'Neue Aufgabe',

View file

@ -1,5 +1,42 @@
<script lang="ts">
import type { PillTabOption } from './types';
import {
List,
Columns,
Tag,
Heart,
House,
Gear,
GridFour,
Clock,
Timer,
Target,
CalendarBlank,
Fire,
MagnifyingGlass,
CheckSquare,
Funnel,
} from '@manacore/shared-icons';
// Map icon names to Phosphor components
const phosphorIcons: Record<string, any> = {
list: List,
columns: Columns,
kanban: Columns,
tag: Tag,
heart: Heart,
home: House,
settings: Gear,
grid: GridFour,
clock: Clock,
timer: Timer,
target: Target,
calendar: CalendarBlank,
fire: Fire,
search: MagnifyingGlass,
'check-square': CheckSquare,
filter: Funnel,
};
interface Props {
/** Tab options to display */
@ -76,6 +113,9 @@
{#if option.icon}
{#if option.iconSvg}
{@html option.iconSvg}
{:else if phosphorIcons[option.icon]}
{@const IconComponent = phosphorIcons[option.icon]}
<IconComponent size={18} class="tab-icon" />
{:else}
<svg class="tab-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path