feat(ux): notepad design, keyboard navigation, contenteditable across todo/calendar/contacts

Todo: Notepad redesign (A4 width, lined paper, red margin), contenteditable titles,
Arrow/Tab navigation between tasks, circular navigation with QuickInputBar,
debug borders (Ctrl+Shift+D), removed hover effects, collapsed completed section.

Calendar Agenda: contenteditable event titles, Arrow/Tab navigation, circular
navigation with InputBar, removed hover effects, separate detail button.

Contacts Alphabet: contenteditable contact names, Arrow/Tab navigation, circular
navigation with InputBar, removed card hover, avatar as detail button.

Guidelines: new sections for list navigation, contenteditable patterns, debug borders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-30 21:37:56 +02:00
parent b9232438cf
commit 4a5fe3bee8
14 changed files with 1150 additions and 365 deletions

View file

@ -346,6 +346,132 @@ Text: "Diese Aufgabe wird unwiderruflich gelöscht."
| Abbrechen/Schließen | `Escape` | | Abbrechen/Schließen | `Escape` |
| Navigation | `Tab` / `Shift+Tab` | | Navigation | `Tab` / `Shift+Tab` |
### Listen-Navigation mit Tastatur
In Listen (Tasks, Kontakte, Favoriten etc.) soll der Nutzer sich nahtlos per Tastatur bewegen können, ohne dass Fokus auf nicht-editierbare Elemente (Checkboxes, Drag-Handles) springt.
**Prinzip: Zirkuläre Navigation zwischen Input und Liste**
```
InputBar → ArrowUp/Tab → Erster Listeneintrag
↕ ArrowUp/Down/Tab/Shift+Tab
Letzter Eintrag → ArrowDown/Tab → InputBar
```
**Implementierung mit `contenteditable`:**
```svelte
<!-- Immer editierbar, kein Mode-Switch -->
<span
contenteditable="true"
role="textbox"
spellcheck="true"
onkeydown={handleKeydown}
onblur={handleBlur}
>
{item.title}
</span>
```
**Keydown-Handler für Listen-Navigation:**
```typescript
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
currentRef?.blur(); // Speichert via onblur
} else if (e.key === 'Escape') {
currentRef.textContent = originalValue; // Verwerfen
currentRef?.blur();
} else if (e.key === 'Tab' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const direction = e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey) ? -1 : 1;
e.preventDefault();
// Alle editierbaren Elemente auf der Seite sammeln
const allEditable = Array.from(
document.querySelectorAll<HTMLElement>('.item-title[contenteditable]')
);
const currentIndex = allEditable.indexOf(currentRef!);
const next = allEditable[currentIndex + direction];
currentRef?.blur();
if (next) {
next.focus();
} else {
// Am Rand der Liste: Fokus zur InputBar
document.querySelector<HTMLInputElement>('.quick-input-bar input')?.focus();
}
}
}
```
**Von der InputBar in die Liste (Page-Level Handler):**
```svelte
<svelte:window onkeydown={(e) => {
const target = e.target as HTMLElement;
if (target.closest('.quick-input-bar') && (e.key === 'ArrowUp' || e.key === 'Tab')) {
const first = document.querySelector<HTMLElement>('.item-title[contenteditable]');
if (first) {
e.preventDefault();
first.focus();
}
}
}} />
```
**Regeln:**
- `contenteditable` statt Input-Toggle: Cursor landet direkt an Klick-Position
- Kein visueller Unterschied zwischen Lese- und Edit-Modus (kein Border, kein Hintergrund)
- Speichern via `blur`-Event (Auto-Save), nicht via explizitem Save-Button
- ArrowDown/Up navigiert vertikal, Tab/Shift+Tab ebenfalls (gleiche Richtung)
- Am Listenende/-anfang springt Fokus zurück zur InputBar (zirkulär)
- Hover-Effekte auf Listeneinträgen vermeiden (ruhige UI)
### Inline-Editing mit contenteditable
**Bevorzuge `contenteditable` statt Input-Toggle** für Texte, die direkt in der Ansicht editiert werden.
| Aspekt | Input-Toggle | contenteditable |
| -------------------- | ------------------------------ | ---------------------------- |
| Klicks zum Editieren | 2 (aktivieren + positionieren) | 1 (Cursor an Klick-Position) |
| DOM-Wechsel | `<span>``<input>` | Keiner |
| Mehrzeilig | Braucht `<textarea>` | Nativ |
| Styling | Muss Input an Span anpassen | Gleich |
**Wann Input statt contenteditable:**
- Formular-Felder mit Validierung (Datum, Zahl, E-Mail)
- Wenn `type="date"`, `type="number"` etc. benötigt wird
- In Expanded-Forms/Modals (nicht inline)
### Debug-Borders (Entwickler-Tool)
`Ctrl+Shift+D` aktiviert farbkodierte Outlines für alle UI-Elemente. Nutzt `outline` statt `border` um Layout nicht zu beeinflussen. State wird in localStorage persistiert.
```css
.debug-mode * {
outline: 1px solid rgba(255, 0, 0, 0.3) !important;
}
.debug-mode div {
outline-color: rgba(255, 0, 0, 0.4) !important;
}
.debug-mode section {
outline-color: rgba(0, 100, 255, 0.5) !important;
}
.debug-mode nav {
outline-color: rgba(0, 180, 0, 0.5) !important;
}
.debug-mode button {
outline-color: rgba(255, 220, 0, 0.6) !important;
}
.debug-mode input,
.debug-mode textarea {
outline-color: rgba(139, 92, 246, 0.6) !important;
}
```
Aktuell in Todo implementiert, geplant als `@manacore/shared-debug` Package.
## Z-Index-Hierarchie ## Z-Index-Hierarchie
Konsistente Schichtung für Overlays: Konsistente Schichtung für Overlays:
@ -390,6 +516,10 @@ font-bold: 700 /* Wichtige Titel */
- [ ] Inline-Interaktion statt Modal möglich? - [ ] Inline-Interaktion statt Modal möglich?
- [ ] Loading/Error/Empty States vorhanden? - [ ] Loading/Error/Empty States vorhanden?
- [ ] Tastatur-Navigation funktioniert? - [ ] Tastatur-Navigation funktioniert?
- [ ] Listen: Arrow/Tab-Navigation zwischen Einträgen?
- [ ] Listen: Zirkuläre Navigation zu InputBar?
- [ ] Inline-Editing: contenteditable statt Input-Toggle?
- [ ] Keine Hover-Effekte auf Listeneinträgen?
- [ ] Animations sparsam und sinnvoll? - [ ] Animations sparsam und sinnvoll?
- [ ] CSS-Variablen statt hardcoded Farben? - [ ] CSS-Variablen statt hardcoded Farben?
- [ ] Konsistente Spacing-Werte? - [ ] Konsistente Spacing-Werte?

View file

@ -103,6 +103,41 @@
} }
} }
// Inline title editing
function handleTitleBlur(event: CalendarEvent, el: HTMLSpanElement) {
const trimmed = (el.textContent || '').trim();
if (trimmed && trimmed !== event.title) {
eventsStore.updateEvent(event.id, { title: trimmed });
} else {
el.textContent = event.title;
}
}
function handleTitleKeydown(e: KeyboardEvent, event: CalendarEvent) {
const target = e.target as HTMLSpanElement;
if (e.key === 'Enter') {
e.preventDefault();
target.blur();
} else if (e.key === 'Escape') {
target.textContent = event.title;
target.blur();
} else if (e.key === 'Tab' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const direction = e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey) ? -1 : 1;
e.preventDefault();
const allTitles = Array.from(
document.querySelectorAll<HTMLElement>('.agenda-event-title[contenteditable]')
);
const currentIndex = allTitles.indexOf(target);
const next = allTitles[currentIndex + direction];
target.blur();
if (next) {
next.focus();
} else {
document.querySelector<HTMLInputElement>('.quick-input-bar input')?.focus();
}
}
}
// Context menu state // Context menu state
let contextMenuVisible = $state(false); let contextMenuVisible = $state(false);
let contextMenuX = $state(0); let contextMenuX = $state(0);
@ -180,9 +215,9 @@
<div class="events-for-date"> <div class="events-for-date">
{#each group.events as event} {#each group.events as event}
<button <!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="event-item" class="event-item"
onclick={() => handleEventClick(event)}
oncontextmenu={(e) => { oncontextmenu={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -207,7 +242,18 @@
)} )}
{/if} {/if}
</div> </div>
<div class="event-title">{event.title}</div> <!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="event-title agenda-event-title"
contenteditable="true"
role="textbox"
spellcheck="true"
onkeydown={(e) => handleTitleKeydown(e, event)}
onblur={(e) => handleTitleBlur(event, e.target as HTMLSpanElement)}
onclick={(e) => e.stopPropagation()}
>
{event.title}
</span>
{#if event.location} {#if event.location}
<div class="event-location"> <div class="event-location">
<svg <svg
@ -233,15 +279,22 @@
</div> </div>
{/if} {/if}
</div> </div>
<svg class="chevron-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <button
<path class="expand-btn"
stroke-linecap="round" onclick={() => handleEventClick(event)}
stroke-linejoin="round" title="Details öffnen"
stroke-width="2" aria-label="Details öffnen"
d="M9 5l7 7-7 7" >
/> <svg class="chevron-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <path
</button> stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
{/each} {/each}
</div> </div>
</div> </div>
@ -329,25 +382,14 @@
.event-item { .event-item {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
cursor: pointer;
border: 1px solid hsl(var(--color-border)); border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md); border-radius: var(--radius-md);
text-align: left; text-align: left;
width: 100%; width: 100%;
background: hsl(var(--color-surface)); background: hsl(var(--color-surface));
transition:
transform 150ms ease,
box-shadow 150ms ease,
border-color 150ms ease;
}
.event-item:hover {
transform: translateX(4px);
border-color: hsl(var(--color-border-hover, var(--color-border)));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
} }
.color-bar { .color-bar {
@ -376,9 +418,13 @@
font-weight: 500; font-weight: 500;
font-size: 0.9375rem; font-size: 0.9375rem;
color: hsl(var(--color-foreground)); color: hsl(var(--color-foreground));
white-space: nowrap; white-space: normal;
overflow: hidden; word-break: break-word;
text-overflow: ellipsis; cursor: text;
outline: none;
border-radius: 0.25rem;
padding: 0.0625rem 0.125rem;
margin: -0.0625rem -0.125rem;
} }
.event-location { .event-location {
@ -396,15 +442,30 @@
flex-shrink: 0; flex-shrink: 0;
} }
.expand-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
margin-top: 0.25rem;
border: none;
background: transparent;
border-radius: 0.375rem;
cursor: pointer;
flex-shrink: 0;
opacity: 0.4;
transition: opacity 0.15s;
}
.expand-btn:hover {
opacity: 1;
background: hsl(var(--color-surface-hover, var(--color-surface)));
}
.chevron-icon { .chevron-icon {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
color: hsl(var(--color-muted-foreground)); color: hsl(var(--color-muted-foreground));
opacity: 0.5;
flex-shrink: 0; flex-shrink: 0;
} }
.event-item:hover .chevron-icon {
opacity: 1;
}
</style> </style>

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte'; import { eventsStore } from '$lib/stores/events.svelte';
import { settingsStore } from '$lib/stores/settings.svelte'; import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore } from '$lib/stores/todos.svelte'; import { todosStore } from '$lib/stores/todos.svelte';
@ -142,6 +143,23 @@
<title>{$_('app.name')}</title> <title>{$_('app.name')}</title>
</svelte:head> </svelte:head>
<svelte:window
onkeydown={(e) => {
if (viewStore.viewType !== 'agenda') return;
const target = e.target as HTMLElement;
const isInQuickInput = target.closest('.quick-input-bar');
if (isInQuickInput && (e.key === 'ArrowUp' || (e.key === 'Tab' && !e.shiftKey))) {
const firstTitle = document.querySelector<HTMLElement>(
'.agenda-event-title[contenteditable]'
);
if (firstTitle) {
e.preventDefault();
firstTitle.focus();
}
}
}}
/>
<div class="service-banners"> <div class="service-banners">
<ServiceStatusBanner <ServiceStatusBanner
serviceName="Todo-Service" serviceName="Todo-Service"

View file

@ -5,6 +5,7 @@
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte'; import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
import { contactsFilterStore } from '$lib/stores/filter.svelte'; import { contactsFilterStore } from '$lib/stores/filter.svelte';
import { contactsSettings } from '$lib/stores/settings.svelte'; import { contactsSettings } from '$lib/stores/settings.svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import AlphabetNavContextMenu from '$lib/components/AlphabetNavContextMenu.svelte'; import AlphabetNavContextMenu from '$lib/components/AlphabetNavContextMenu.svelte';
interface Props { interface Props {
@ -100,6 +101,50 @@
// Available letters (letters that have contacts) // Available letters (letters that have contacts)
let availableLetters = $derived(Object.keys(groupedContacts).sort()); let availableLetters = $derived(Object.keys(groupedContacts).sort());
// Inline name editing
function handleNameBlur(contact: Contact, el: HTMLSpanElement) {
const trimmed = (el.textContent || '').trim();
const currentName = getDisplayName(contact);
if (trimmed && trimmed !== currentName) {
// Parse display name back into first/last name
const parts = trimmed.split(/\s+/);
const firstName = parts[0] || '';
const lastName = parts.slice(1).join(' ') || '';
contactsStore.updateContact(contact.id, {
firstName,
lastName,
displayName: trimmed,
});
} else {
el.textContent = currentName;
}
}
function handleNameKeydown(e: KeyboardEvent, contact: Contact) {
const target = e.target as HTMLSpanElement;
if (e.key === 'Enter') {
e.preventDefault();
target.blur();
} else if (e.key === 'Escape') {
target.textContent = getDisplayName(contact);
target.blur();
} else if (e.key === 'Tab' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const direction = e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey) ? -1 : 1;
e.preventDefault();
const allNames = Array.from(
document.querySelectorAll<HTMLElement>('.contact-name-editable[contenteditable]')
);
const currentIndex = allNames.indexOf(target);
const next = allNames[currentIndex + direction];
target.blur();
if (next) {
next.focus();
} else {
document.querySelector<HTMLInputElement>('.quick-input-bar input')?.focus();
}
}
}
function scrollToLetter(letter: string) { function scrollToLetter(letter: string) {
const element = document.getElementById(`section-${letter}`); const element = document.getElementById(`section-${letter}`);
if (element) { if (element) {
@ -162,10 +207,6 @@
<div class="section-contacts"> <div class="section-contacts">
{#each groupedContacts[letter] as contact (contact.id)} {#each groupedContacts[letter] as contact (contact.id)}
<div <div
role="button"
tabindex="0"
onclick={() => onContactClick(contact.id)}
onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)}
class="alphabet-contact-card {selectionMode && selectedIds.has(contact.id) class="alphabet-contact-card {selectionMode && selectedIds.has(contact.id)
? 'selected' ? 'selected'
: ''}" : ''}"
@ -188,8 +229,12 @@
</button> </button>
{/if} {/if}
<!-- Avatar --> <!-- Avatar — click opens detail -->
<div class="avatar-sm"> <button
class="avatar-sm avatar-btn"
onclick={() => onContactClick(contact.id)}
title="Details öffnen"
>
{#if contact.photoUrl} {#if contact.photoUrl}
<img <img
src={contact.photoUrl} src={contact.photoUrl}
@ -199,12 +244,23 @@
{:else} {:else}
{getInitials(contact)} {getInitials(contact)}
{/if} {/if}
</div> </button>
<!-- Contact Info --> <!-- Contact Info -->
<div class="contact-info"> <div class="contact-info">
<div class="contact-main-row"> <div class="contact-main-row">
<span class="contact-name">{getDisplayName(contact)}</span> <!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="contact-name contact-name-editable"
contenteditable="true"
role="textbox"
spellcheck="true"
onkeydown={(e) => handleNameKeydown(e, contact)}
onblur={(e) => handleNameBlur(contact, e.target as HTMLSpanElement)}
onclick={(e) => e.stopPropagation()}
>
{getDisplayName(contact)}
</span>
{#if contact.isFavorite} {#if contact.isFavorite}
<svg class="favorite-badge" fill="currentColor" viewBox="0 0 24 24"> <svg class="favorite-badge" fill="currentColor" viewBox="0 0 24 24">
<path <path
@ -238,7 +294,6 @@
{#if contact.phone || contact.mobile} {#if contact.phone || contact.mobile}
<a <a
href="tel:{contact.mobile || contact.phone}" href="tel:{contact.mobile || contact.phone}"
onclick={(e) => e.stopPropagation()}
class="action-chip" class="action-chip"
title={contact.mobile || contact.phone} title={contact.mobile || contact.phone}
> >
@ -253,12 +308,7 @@
</a> </a>
{/if} {/if}
{#if contact.email} {#if contact.email}
<a <a href="mailto:{contact.email}" class="action-chip" title={contact.email}>
href="mailto:{contact.email}"
onclick={(e) => e.stopPropagation()}
class="action-chip"
title={contact.email}
>
<svg class="action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
stroke-linecap="round" stroke-linecap="round"
@ -413,16 +463,9 @@
background-color: hsl(var(--card)); background-color: hsl(var(--card));
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
min-width: 0; min-width: 0;
} }
.alphabet-contact-card:hover {
border-color: hsl(var(--primary));
background-color: hsl(var(--accent));
}
.avatar-sm { .avatar-sm {
width: 36px; width: 36px;
height: 36px; height: 36px;
@ -460,6 +503,24 @@
white-space: nowrap; white-space: nowrap;
} }
.contact-name-editable {
cursor: text;
outline: none;
border-radius: 0.25rem;
padding: 0.0625rem 0.125rem;
margin: -0.0625rem -0.125rem;
}
.avatar-btn {
border: none;
cursor: pointer;
transition: opacity 0.15s;
}
.avatar-btn:hover {
opacity: 0.8;
}
.favorite-badge { .favorite-badge {
width: 0.8125rem; width: 0.8125rem;
height: 0.8125rem; height: 0.8125rem;

View file

@ -8,4 +8,20 @@
<title>{$_('contacts.title')} - Contacts</title> <title>{$_('contacts.title')} - Contacts</title>
</svelte:head> </svelte:head>
<svelte:window
onkeydown={(e) => {
const target = e.target as HTMLElement;
const isInQuickInput = target.closest('.quick-input-bar');
if (isInQuickInput && (e.key === 'ArrowUp' || (e.key === 'Tab' && !e.shiftKey))) {
const firstName = document.querySelector<HTMLElement>(
'.contact-name-editable[contenteditable]'
);
if (firstName) {
e.preventDefault();
firstName.focus();
}
}
}}
/>
<ContactList /> <ContactList />

View file

@ -0,0 +1,166 @@
---
title: 'Todo: Notepad-Design & Keyboard-Navigation'
description: 'Komplettes Redesign der Todo-Liste als Notizblock mit durchgängiger Tastaturnavigation zwischen Tasks und QuickInputBar.'
date: 2026-03-30
author: 'Till Schneider'
category: 'feature'
tags: ['todo', 'ux', 'keyboard-navigation', 'contenteditable', 'design', 'accessibility', 'notepad']
featured: false
commits: 42
readTime: 5
stats:
filesChanged: 834
linesAdded: 25713
linesRemoved: 10299
contributors:
- name: 'Till Schneider'
handle: 'Till-JS'
commits: 42
workingHours:
start: '2026-03-30T09:00'
end: '2026-03-30T18:00'
---
## Highlights
- Notizblock-Design mit liniertem Papier-Hintergrund und roter Margin-Linie
- Durchgängige Tastaturnavigation: Pfeiltasten, Tab, Shift+Tab zwischen allen Tasks
- contenteditable statt Input-Toggle: Cursor landet direkt an der Klick-Position
- Zirkuläre Navigation: QuickInputBar -> Tasks -> QuickInputBar
- Debug-Borders Modus (Ctrl+Shift+D) für UI-Debugging
## Notepad-Redesign
Die Todo-Homepage wurde von einer schmalen Card-Ansicht (560px) zu einem A4-breiten Notizblock (840px) umgebaut.
### Vorher
- Kleine weiße Card mit Box-Shadow auf grauem Hintergrund
- CollapsibleSection-Komponenten mit Toggle-Logik
- Task-Items mit Hover-Effekten (translateY, Hintergrundwechsel)
- Separate Input-Elemente für Titel-Bearbeitung (zwei Klicks nötig)
### Nachher
```
┌──────────────────────────────────────────────┐
│ ┃ Überfällig (2) │ ← Rote Margin-Linie
│ ┃ ○ Steuererklärung abgeben Gestern │
│ ┃ ○ Zahnarzt anrufen 25. Mär │
│ ┃─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ ← Gestrichelte Trenner
│ ┃ Heute (3) │
│ ┃ ○ Meeting vorbereiten Heute │
│ ┃ ○ Code Review PR #42 Heute │
│ ┃ ○ Einkaufen gehen Heute │
│ ┃─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ ┃ ▼ Erledigt (5) │ ← Standardmäßig eingeklappt
└──────────────────────────────────────────────┘
Linierter Hintergrund (Notizbuch-Linien)
```
**Design-Entscheidungen:**
| Aspekt | Entscheidung | Grund |
| ------------- | --------------------------------- | ---------------------------------------------- |
| Hintergrund | Liniert (#fffef5) nur auf Sheet | Papier-Metapher, Hauptbereich bleibt neutral |
| Breite | 840px (A4-ähnlich) | Mehr Platz für lange Titel, Labels, Kontakte |
| Sections | Einfache Header statt Collapsible | Weniger UI-Noise, Sections sind immer relevant |
| Erledigt | Eingeklappt mit Chevron-Toggle | Aktive Tasks im Fokus |
| Drag-Handle | Links außerhalb des Sheets | Stört nicht, erscheint nur bei Hover |
| Hover-Effekte | Komplett entfernt | Ruhigere UI, weniger visuelle Ablenkung |
## Keyboard-Navigation
### Navigationsmodell
```
┌─────────────────────────────┐
│ QuickInputBar (unten) │ ← ArrowUp / Tab
│ [Neue Aufgabe oder...] │
└──────────┬──────────────────┘
│ ↑ ArrowUp / Tab
▼ ↓ ArrowDown / Tab
┌─────────────────────────────┐
│ Task 1: Meeting vorber... │ ← contenteditable, Cursor an Klick-Position
│ Task 2: Code Review PR... │ ← ArrowDown / Tab → nächster Task
│ Task 3: Einkaufen gehen │ ← ArrowDown → zurück zur QuickInputBar
└─────────────────────────────┘
```
### Tastatur-Befehle im Task-Titel
| Taste | Aktion |
| --------------------------- | ------------------------ |
| `ArrowDown` / `Tab` | Nächster Task-Titel |
| `ArrowUp` / `Shift+Tab` | Vorheriger Task-Titel |
| `Enter` | Speichern und Blur |
| `Escape` | Änderungen verwerfen |
| Am letzten Task `ArrowDown` | Zurück zur QuickInputBar |
| Am ersten Task `ArrowUp` | Zurück zur QuickInputBar |
### Von QuickInputBar zu Tasks
| Taste | Aktion |
| --------- | ----------------------------- |
| `ArrowUp` | Erster Task-Titel fokussieren |
| `Tab` | Erster Task-Titel fokussieren |
### contenteditable statt Input-Toggle
**Vorher:** Klick auf Titel → `<span>` wird durch `<input>` ersetzt → zweiter Klick positioniert Cursor.
**Nachher:** Titel ist immer ein `<span contenteditable>` → ein Klick positioniert den Cursor sofort an der Maus-Position. Kein visueller Unterschied zwischen Lese- und Bearbeitungsmodus.
```svelte
<span
class="task-title"
contenteditable="true"
onkeydown={handleTitleKeydown}
onblur={handleTitleBlur}
>
{task.title}
</span>
```
## Debug-Borders
Neues Entwickler-Tool: `Ctrl+Shift+D` aktiviert farbkodierte Outlines für alle UI-Elemente.
| Element | Farbe |
| ---------------- | ------- |
| `div` | Rot |
| `section` | Blau |
| `nav` | Grün |
| `button` | Gelb |
| `input/textarea` | Violett |
| `a` | Pink |
| `img/svg` | Cyan |
State wird in localStorage persistiert. Aktuell nur in der Todo-App, geplant als globales Shared-Package.
## Weitere Verbesserungen
| Bereich | Vorher | Nachher |
| ---------------------- | ------------------------ | --------------------------------------- |
| Task-Titel | Einzeilig, abgeschnitten | Mehrzeilig mit word-break |
| Checkbox-Alignment | Vertikal zentriert | Am Anfang der ersten Zeile (flex-start) |
| Mobile Bottom-Padding | 150px | 100px |
| Erledigt-Datum Opacity | 0.5 (zu blass) | 0.7, Hover 1.0 |
| Task-Hover | translateY + Hintergrund | Kein Hover-Effekt |
| Titel-Input | Border + Hintergrund | Unsichtbar, gleiche Position |
## Zusammenfassung
| Bereich | Commits | Highlights |
| ------------------- | ------- | ----------------------------------------------- |
| Notepad-Design | ~15 | Liniertes Papier, A4-Breite, Section-Redesign |
| Keyboard-Navigation | ~10 | Arrow/Tab zwischen Tasks, zirkulär mit InputBar |
| contenteditable | ~5 | Direkte Cursor-Platzierung, kein Mode-Switch |
| Debug-Borders | ~3 | Ctrl+Shift+D, farbkodiert, localStorage |
| Cleanup | ~9 | Hover entfernt, Alignment, Opacity |
## Nächste Schritte
- Debug-Borders als globales Shared-Package für alle Apps
- Keyboard-Navigation Pattern in andere Apps übertragen (Zitare, Contacts)
- Tastatur-Shortcut-Hilfe (? oder Ctrl+/) mit Overlay

View file

@ -42,13 +42,7 @@
/* Task item transitions */ /* Task item transitions */
.task-item { .task-item {
transition: transition: none;
transform 0.15s ease,
box-shadow 0.15s ease;
}
.task-item:hover {
transform: translateY(-1px);
} }
/* Checkbox animations */ /* Checkbox animations */
@ -135,3 +129,63 @@
background-color: var(--color-secondary); background-color: var(--color-secondary);
border-left: 3px solid var(--color-primary); border-left: 3px solid var(--color-primary);
} }
/* Debug Mode - Toggle with Ctrl+Shift+D */
.debug-mode * {
outline: 1px solid rgba(255, 0, 0, 0.3) !important;
}
.debug-mode div {
outline-color: rgba(255, 0, 0, 0.4) !important;
}
.debug-mode section {
outline-color: rgba(0, 100, 255, 0.5) !important;
}
.debug-mode nav {
outline-color: rgba(0, 180, 0, 0.5) !important;
}
.debug-mode main {
outline-color: rgba(255, 140, 0, 0.5) !important;
}
.debug-mode header,
.debug-mode footer {
outline-color: rgba(128, 0, 128, 0.5) !important;
}
.debug-mode button {
outline-color: rgba(255, 220, 0, 0.6) !important;
}
.debug-mode input,
.debug-mode textarea,
.debug-mode select {
outline-color: rgba(139, 92, 246, 0.6) !important;
}
.debug-mode a {
outline-color: rgba(236, 72, 153, 0.5) !important;
}
.debug-mode img,
.debug-mode svg {
outline-color: rgba(0, 200, 200, 0.5) !important;
}
.debug-mode ul,
.debug-mode ol,
.debug-mode li {
outline-color: rgba(234, 179, 8, 0.4) !important;
}
.debug-mode p,
.debug-mode span,
.debug-mode h1,
.debug-mode h2,
.debug-mode h3,
.debug-mode h4 {
outline-color: rgba(107, 114, 128, 0.3) !important;
}

View file

@ -180,30 +180,42 @@
} }
// Inline title editing // Inline title editing
let isEditingTitle = $state(false); let titleRef = $state<HTMLSpanElement | null>(null);
let editingTitle = $state('');
let titleEditRef = $state<HTMLInputElement | null>(null);
function handleTitleClick(e: MouseEvent) {
e.stopPropagation();
isEditingTitle = true;
editingTitle = task.title;
setTimeout(() => titleEditRef?.focus(), 0);
}
function handleTitleKeydown(e: KeyboardEvent) { function handleTitleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
commitTitleEdit(); e.preventDefault();
titleRef?.blur();
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
isEditingTitle = false; if (titleRef) titleRef.textContent = task.title;
titleRef?.blur();
} else if (e.key === 'Tab' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const direction = e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey) ? -1 : 1;
e.preventDefault();
const allTitles = Array.from(
document.querySelectorAll<HTMLElement>('.task-title[contenteditable]')
);
const currentIndex = allTitles.indexOf(titleRef!);
const nextTitle = allTitles[currentIndex + direction];
titleRef?.blur();
if (nextTitle) {
nextTitle.focus();
} else {
// No next/prev todo — focus QuickInputBar
const input = document.querySelector<HTMLInputElement>('.quick-input-bar input');
input?.focus();
}
} }
} }
function commitTitleEdit() { function handleTitleBlur() {
isEditingTitle = false; if (!titleRef) return;
const trimmed = editingTitle.trim(); const trimmed = (titleRef.textContent || '').trim();
if (trimmed && trimmed !== task.title) { if (trimmed && trimmed !== task.title) {
onSave?.({ title: trimmed }); onSave?.({ title: trimmed });
} else {
// Revert if empty or unchanged
titleRef.textContent = task.title;
} }
} }
@ -409,22 +421,20 @@
<!-- Content --> <!-- Content -->
<div class="task-content"> <div class="task-content">
{#if isEditingTitle} <!-- svelte-ignore a11y_no_static_element_interactions -->
<input <span
bind:this={titleEditRef} bind:this={titleRef}
class="task-title-input" class="task-title"
type="text" class:line-through={task.isCompleted}
bind:value={editingTitle} contenteditable="true"
onkeydown={handleTitleKeydown} role="textbox"
onblur={commitTitleEdit} spellcheck="true"
/> onkeydown={handleTitleKeydown}
{:else} onblur={handleTitleBlur}
<!-- svelte-ignore a11y_click_events_have_key_events --> onclick={(e) => e.stopPropagation()}
<!-- svelte-ignore a11y_no_static_element_interactions --> >
<span class="task-title" class:line-through={task.isCompleted} onclick={handleTitleClick}> {task.title}
{task.title} </span>
</span>
{/if}
<!-- Labels and subtasks below title --> <!-- Labels and subtasks below title -->
{#if subtaskProgress() || (task.labels && task.labels.length > 0)} {#if subtaskProgress() || (task.labels && task.labels.length > 0)}
@ -721,13 +731,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.625rem; gap: 0.625rem;
padding: 0.5rem 0.75rem; min-height: 2.5rem; /* matches notepad --line-height */
padding: 0 1.5rem;
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
backdrop-filter: none; backdrop-filter: none;
-webkit-backdrop-filter: none; -webkit-backdrop-filter: none;
border: none; border: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.08); border-bottom: none;
box-shadow: none; box-shadow: none;
transition: all 0.2s; transition: all 0.2s;
} }
@ -748,21 +759,14 @@
:global(.dark) .task-item { :global(.dark) .task-item {
background: transparent; background: transparent;
border: none; border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
} }
.task-item:hover { .task-item:hover {
background: rgba(0, 0, 0, 0.02); background: transparent;
box-shadow: none;
} }
:global(.dark) .task-item:hover { :global(.dark) .task-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.task-item-wrapper.expanded .task-item:hover {
background: transparent; background: transparent;
box-shadow: none;
} }
.task-item.completed { .task-item.completed {
@ -780,23 +784,24 @@
border-bottom-color: rgba(34, 197, 94, 0.3); border-bottom-color: rgba(34, 197, 94, 0.3);
} }
/* Drag handle */ /* Drag handle — sticks out left beyond the content area */
.drag-handle { .drag-handle {
cursor: grab; cursor: grab;
opacity: 0.25; opacity: 0;
transition: opacity 0.15s; transition: opacity 0.15s;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.5rem 0.375rem; padding: 0.25rem 0.25rem;
margin-left: -0.25rem; margin-left: -2rem;
margin-right: -0.5rem;
margin-top: 0.125rem;
border-radius: 0.25rem; border-radius: 0.25rem;
min-height: 2rem;
} }
.task-item:hover .drag-handle { .task-item:hover .drag-handle {
opacity: 0.5; opacity: 0.4;
} }
.drag-handle:hover { .drag-handle:hover {
@ -828,6 +833,7 @@
/* Checkbox with priority color fill */ /* Checkbox with priority color fill */
.task-checkbox { .task-checkbox {
margin-top: 0.1875rem;
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
border-radius: 9999px; border-radius: 9999px;
@ -947,40 +953,21 @@
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: #374151; color: #374151;
white-space: nowrap; white-space: normal;
overflow: hidden; word-break: break-word;
text-overflow: ellipsis;
cursor: text; cursor: text;
border-radius: 0.25rem; border-radius: 0.25rem;
padding: 0.125rem 0.25rem; padding: 0.125rem 0.25rem;
margin: -0.125rem -0.25rem; margin: -0.125rem -0.25rem;
outline: none;
} }
.task-title:hover { .task-title:hover {
background: rgba(0, 0, 0, 0.04); background: transparent;
} }
:global(.dark) .task-title:hover { :global(.dark) .task-title:hover {
background: rgba(255, 255, 255, 0.06); background: transparent;
}
.task-title-input {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
background: rgba(0, 0, 0, 0.04);
border: 1px solid #8b5cf6;
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
margin: -0.125rem -0.25rem;
outline: none;
width: 100%;
}
:global(.dark) .task-title-input {
color: #f3f4f6;
background: rgba(255, 255, 255, 0.08);
border-color: #8b5cf6;
} }
:global(.dark) .task-title { :global(.dark) .task-title {
@ -1084,6 +1071,7 @@
color: #6b7280; color: #6b7280;
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
margin-top: 0.25rem;
} }
:global(.dark) .due-date { :global(.dark) .due-date {
@ -1109,12 +1097,12 @@
border: none; border: none;
cursor: pointer; cursor: pointer;
padding: 0.125rem 0; padding: 0.125rem 0;
opacity: 0.5; opacity: 0.7;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.completed-date-toggle:hover { .completed-date-toggle:hover {
opacity: 0.8; opacity: 1;
} }
.completed-date-toggle .date-label { .completed-date-toggle .date-label {

View file

@ -317,16 +317,12 @@
<style> <style>
.task-list { .task-list {
min-height: 40px; min-height: 2.5rem;
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
} }
.task-list :global(.task-item-wrapper:last-child .task-item) {
border-bottom: none;
}
.task-list.empty { .task-list.empty {
border: 2px dashed rgba(0, 0, 0, 0.15); border: 2px dashed rgba(0, 0, 0, 0.15);
display: flex; display: flex;
@ -362,7 +358,7 @@
} }
.dnd-shadow-placeholder { .dnd-shadow-placeholder {
min-height: 3rem; min-height: 2.5rem;
} }
/* Shadow placeholder (where dragged item will land) */ /* Shadow placeholder (where dragged item will land) */

View file

@ -0,0 +1,21 @@
const DEBUG_KEY = 'manacore:debug-mode';
function createDebugStore() {
let enabled = $state(
typeof localStorage !== 'undefined' ? localStorage.getItem(DEBUG_KEY) === 'true' : false
);
return {
get enabled() {
return enabled;
},
toggle() {
enabled = !enabled;
if (typeof localStorage !== 'undefined') {
localStorage.setItem(DEBUG_KEY, String(enabled));
}
},
};
}
export const debugStore = createDebugStore();

View file

@ -9,6 +9,17 @@ import type { TaskPriority } from '@todo/shared';
// Settings types // Settings types
export type TodoView = 'inbox' | 'today' | 'upcoming' | 'kanban' | 'completed'; export type TodoView = 'inbox' | 'today' | 'upcoming' | 'kanban' | 'completed';
export type KanbanCardSize = 'compact' | 'normal' | 'large'; export type KanbanCardSize = 'compact' | 'normal' | 'large';
export type PageMode = 'date' | 'priority' | 'custom';
export interface PageConfig {
id: string;
label: string;
filter: {
priorities?: ('low' | 'medium' | 'high' | 'urgent')[];
completed?: boolean;
dateRange?: 'overdue' | 'today' | 'tomorrow' | 'upcoming' | 'any';
};
}
export interface TodoAppSettings extends Record<string, unknown> { export interface TodoAppSettings extends Record<string, unknown> {
// Task Behavior // Task Behavior
@ -50,6 +61,10 @@ export interface TodoAppSettings extends Record<string, unknown> {
// Navigation UI // Navigation UI
pillNavCollapsed: boolean; pillNavCollapsed: boolean;
filterStripCollapsed: boolean; filterStripCollapsed: boolean;
// Page mode
pageMode: PageMode;
customPages: PageConfig[];
} }
const DEFAULT_SETTINGS: TodoAppSettings = { const DEFAULT_SETTINGS: TodoAppSettings = {
@ -92,6 +107,10 @@ const DEFAULT_SETTINGS: TodoAppSettings = {
// Navigation UI // Navigation UI
pillNavCollapsed: true, // PillNav hidden by default, shown via FAB pillNavCollapsed: true, // PillNav hidden by default, shown via FAB
filterStripCollapsed: false, // FilterStrip shown by default when PillNav is visible filterStripCollapsed: false, // FilterStrip shown by default when PillNav is visible
// Page mode
pageMode: 'priority' as PageMode,
customPages: [] as PageConfig[],
}; };
// Create base store using factory // Create base store using factory
@ -183,6 +202,12 @@ export const todoSettings = {
get filterStripCollapsed() { get filterStripCollapsed() {
return baseStore.settings.filterStripCollapsed; return baseStore.settings.filterStripCollapsed;
}, },
get pageMode() {
return baseStore.settings.pageMode;
},
get customPages() {
return baseStore.settings.customPages;
},
// Toggle methods // Toggle methods
togglePillNav() { togglePillNav() {

View file

@ -602,10 +602,10 @@
} }
.content-wrapper { .content-wrapper {
max-width: 900px; max-width: none;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
padding: 0.5rem; padding: 0;
} }
.content-wrapper.full-width { .content-wrapper.full-width {
@ -615,9 +615,6 @@
} }
@media (min-width: 640px) { @media (min-width: 640px) {
.content-wrapper {
padding: 1rem;
}
.content-wrapper.full-width { .content-wrapper.full-width {
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
@ -625,19 +622,16 @@
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.content-wrapper {
padding: 2rem;
}
.content-wrapper.full-width { .content-wrapper.full-width {
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
} }
} }
/* Mobile: More space for QuickInputBar + PillNav */ /* Mobile: Space for QuickInputBar + PillNav */
@media (max-width: 768px) { @media (max-width: 768px) {
.main-content { .main-content {
padding-bottom: calc(150px + env(safe-area-inset-bottom)); padding-bottom: calc(100px + env(safe-area-inset-bottom));
} }
} }

View file

@ -2,13 +2,19 @@
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
import { format, addDays, subDays, startOfDay } from 'date-fns'; import { format, addDays, subDays, startOfDay } from 'date-fns';
import { de } from 'date-fns/locale'; import { de } from 'date-fns/locale';
import { Sparkle, ArrowDown } from '@manacore/shared-icons'; import {
Sparkle,
ArrowDown,
Warning,
CalendarBlank,
CalendarDots,
CheckCircle,
} from '@manacore/shared-icons';
import { tasksStore } from '$lib/stores/tasks.svelte'; import { tasksStore } from '$lib/stores/tasks.svelte';
import { viewStore } from '$lib/stores/view.svelte'; import { viewStore } from '$lib/stores/view.svelte';
import { applyTaskFilters } from '$lib/utils/task-filters'; import { applyTaskFilters } from '$lib/utils/task-filters';
import { filterOverdue, filterToday, filterCompleted } from '$lib/data/task-queries'; import { filterOverdue, filterToday, filterCompleted } from '$lib/data/task-queries';
import TaskList from '$lib/components/TaskList.svelte'; import TaskList from '$lib/components/TaskList.svelte';
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
import { TaskListSkeleton } from '$lib/components/skeletons'; import { TaskListSkeleton } from '$lib/components/skeletons';
import type { Task } from '@todo/shared'; import type { Task } from '@todo/shared';
@ -17,6 +23,7 @@
getContext('tasks'); getContext('tasks');
let tipDismissed = $state(false); let tipDismissed = $state(false);
let completedOpen = $state(false);
// Stable date references (computed once, not on every re-render) // Stable date references (computed once, not on every re-render)
const today = startOfDay(new Date()); const today = startOfDay(new Date());
@ -136,186 +143,433 @@
}); });
} }
} }
// Build pages array from visible sections
let pages = $derived.by(() => {
const p: { id: string; label: string; icon: string }[] = [];
if (overdueTasks.length > 0) p.push({ id: 'overdue', label: 'Überfällig', icon: 'warning' });
if (showTodaySection) p.push({ id: 'today', label: 'Heute', icon: 'calendar' });
if (showTomorrowSection) p.push({ id: 'tomorrow', label: 'Morgen', icon: 'calendar-dots' });
if (showUpcomingSection) p.push({ id: 'upcoming', label: 'Demnächst', icon: 'calendar-dots' });
if (showCompletedSection) p.push({ id: 'completed', label: 'Erledigt', icon: 'check' });
return p;
});
let activePage = $state(0);
let scrollContainer: HTMLDivElement | undefined = $state();
function scrollToPage(index: number) {
if (!scrollContainer) return;
const sheets = scrollContainer.querySelectorAll('.notepad-sheet');
if (sheets[index]) {
sheets[index].scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}
}
function handleScroll() {
if (!scrollContainer) return;
const sheets = scrollContainer.querySelectorAll('.notepad-sheet');
const containerRect = scrollContainer.getBoundingClientRect();
const center = containerRect.left + containerRect.width / 2;
let closest = 0;
let closestDist = Infinity;
sheets.forEach((sheet, i) => {
const rect = sheet.getBoundingClientRect();
const sheetCenter = rect.left + rect.width / 2;
const dist = Math.abs(sheetCenter - center);
if (dist < closestDist) {
closestDist = dist;
closest = i;
}
});
activePage = closest;
}
</script> </script>
<svelte:head> <svelte:head>
<title>Todo</title> <title>Todo</title>
</svelte:head> </svelte:head>
<div class="unified-view"> <svelte:window
{#if allTasks.loading} onkeydown={(e) => {
<TaskListSkeleton sections={3} tasksPerSection={3} /> const target = e.target as HTMLElement;
{:else if allTasks.error} const isInQuickInput = target.closest('.quick-input-bar');
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg"> if (isInQuickInput && (e.key === 'ArrowUp' || (e.key === 'Tab' && !e.shiftKey))) {
{allTasks.error} const firstTitle = document.querySelector<HTMLElement>('.task-title[contenteditable]');
if (firstTitle) {
e.preventDefault();
firstTitle.focus();
}
}
}}
/>
{#if allTasks.loading}
<div class="notepad-page">
<div class="scroll-track">
<div class="notepad-sheet">
<TaskListSkeleton sections={3} tasksPerSection={3} />
</div>
</div> </div>
{:else if allEmpty} </div>
<!-- Enhanced empty state --> {:else if allTasks.error}
<div class="empty-state-container"> <div class="notepad-page">
<div class="empty-state-content"> <div class="scroll-track">
<div class="empty-state-icon"> <div class="notepad-sheet">
<Sparkle size={56} weight="duotone" /> <div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
{allTasks.error}
</div> </div>
</div>
<h2 class="empty-state-title">Bereit für einen produktiven Tag</h2> </div>
</div>
<div class="empty-state-cta"> {:else if allEmpty}
<p class="empty-state-cta-text">Tippe unten um loszulegen...</p> <div class="notepad-page">
<div class="empty-state-arrow"> <div class="scroll-track">
<ArrowDown size={20} weight="bold" /> <div class="notepad-sheet">
<div class="empty-state-container">
<div class="empty-state-content">
<div class="empty-state-icon">
<Sparkle size={56} weight="duotone" />
</div>
<h2 class="empty-state-title">Bereit für einen produktiven Tag</h2>
<div class="empty-state-cta">
<p class="empty-state-cta-text">Tippe unten um loszulegen...</p>
<div class="empty-state-arrow">
<ArrowDown size={20} weight="bold" />
</div>
</div>
<div class="empty-state-examples">
<p class="examples-label">Schnellstart-Tipps</p>
<div class="examples-grid">
{#each syntaxExamples as example}
<button
type="button"
class="example-chip"
onclick={() => handleExampleClick(example.text)}
title={example.description}
>
{example.text}
</button>
{/each}
</div>
</div>
</div> </div>
</div> </div>
</div>
</div>
</div>
{:else}
<!-- Page tabs -->
{#if pages.length > 1}
<div class="page-tabs">
{#each pages as page, i}
<button class="page-tab" class:active={activePage === i} onclick={() => scrollToPage(i)}>
{page.label}
</button>
{/each}
</div>
{/if}
<div class="empty-state-examples"> <div class="notepad-page">
<p class="examples-label">Schnellstart-Tipps</p> <div class="scroll-track" bind:this={scrollContainer} onscroll={handleScroll}>
<div class="examples-grid"> <!-- Overdue page -->
{#each syntaxExamples as example} {#if overdueTasks.length > 0}
<button <div class="notepad-sheet">
type="button" <div class="sheet-header sheet-header-warning">
class="example-chip" <Warning size={18} weight="bold" />
onclick={() => handleExampleClick(example.text)} <span>Überfällig</span>
title={example.description} <span class="section-count">{overdueTasks.length}</span>
> </div>
{example.text} <div class="sheet-content">
</button> <TaskList
tasks={overdueTasks}
enableDragDrop
dropTargetDate="overdue"
onTaskDrop={handleTaskDrop}
/>
</div>
</div>
{/if}
<!-- Today page -->
{#if showTodaySection}
<div class="notepad-sheet">
<div class="sheet-header">
<CalendarBlank size={18} weight="bold" />
<span>Heute</span>
{#if todayTasks.length > 0}
<span class="section-count">{todayTasks.length}</span>
{/if}
</div>
<div class="sheet-content">
<TaskList
tasks={todayTasks}
enableDragDrop
dropTargetDate={today}
onTaskDrop={handleTaskDrop}
/>
{#if showOnboardingTip && !tipDismissed}
<div class="onboarding-tip">
<span class="onboarding-tip-icon">💡</span>
<span class="onboarding-tip-text">
Tipp: Nutze <code>#tags</code> und <code>!priorität</code> für bessere Organisation
</span>
<button
class="onboarding-tip-close"
onclick={() => (tipDismissed = true)}
title="Tipp ausblenden"
aria-label="Tipp ausblenden"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/if}
</div>
</div>
{/if}
<!-- Tomorrow page -->
{#if showTomorrowSection}
<div class="notepad-sheet">
<div class="sheet-header">
<CalendarDots size={18} weight="bold" />
<span>Morgen</span>
<span class="section-count">{tomorrowTasks.length}</span>
</div>
<div class="sheet-content">
<TaskList
tasks={tomorrowTasks}
enableDragDrop
dropTargetDate={tomorrow}
onTaskDrop={handleTaskDrop}
/>
</div>
</div>
{/if}
<!-- Upcoming page -->
{#if showUpcomingSection}
<div class="notepad-sheet">
<div class="sheet-header">
<CalendarDots size={18} weight="bold" />
<span>Demnächst</span>
<span class="section-count">{upcomingCount}</span>
</div>
<div class="sheet-content">
{#each groupedUpcomingTasks as group}
<div class="subsection">
<h3 class="subsection-label">{group.label}</h3>
<TaskList
tasks={group.tasks}
enableDragDrop
dropTargetDate={group.date}
onTaskDrop={handleTaskDrop}
/>
</div>
{/each} {/each}
</div> </div>
</div> </div>
</div> {/if}
</div>
{:else}
<div class="notepad">
<div class="notepad-content">
<div class="space-y-2">
{#if overdueTasks.length > 0}
<CollapsibleSection
title="Überfällig"
count={overdueTasks.length}
icon="warning"
variant="warning"
defaultOpen={true}
>
<TaskList
tasks={overdueTasks}
enableDragDrop
dropTargetDate="overdue"
onTaskDrop={handleTaskDrop}
/>
</CollapsibleSection>
{/if}
{#if showTodaySection} <!-- Completed page -->
<CollapsibleSection {#if showCompletedSection}
title="Heute" <div class="notepad-sheet sheet-completed">
count={todayTasks.length} <div class="sheet-header">
icon="today" <CheckCircle size={18} weight="bold" />
variant="default" <span>Erledigt</span>
defaultOpen={true} <span class="section-count">{completedTasks.length}</span>
> </div>
<TaskList <div class="sheet-content">
tasks={todayTasks} <TaskList
enableDragDrop tasks={completedTasks}
dropTargetDate={today} enableDragDrop
onTaskDrop={handleTaskDrop} dropTargetDate="completed"
/> onTaskDrop={handleTaskDrop}
</CollapsibleSection> showCompleted
{/if} />
</div>
{#if showTomorrowSection}
<CollapsibleSection
title="Morgen"
count={tomorrowTasks.length}
icon="upcoming"
variant="default"
defaultOpen={true}
>
<TaskList
tasks={tomorrowTasks}
enableDragDrop
dropTargetDate={tomorrow}
onTaskDrop={handleTaskDrop}
/>
</CollapsibleSection>
{/if}
{#if showUpcomingSection}
<CollapsibleSection
title="Demnächst"
count={upcomingCount}
icon="upcoming"
variant="default"
defaultOpen={true}
>
<div class="space-y-4">
{#each groupedUpcomingTasks as group}
<div>
<h3 class="text-sm font-medium text-muted-foreground mb-2 pl-2">
{group.label}
</h3>
<TaskList
tasks={group.tasks}
enableDragDrop
dropTargetDate={group.date}
onTaskDrop={handleTaskDrop}
/>
</div>
{/each}
</div>
</CollapsibleSection>
{/if}
{#if showCompletedSection}
<CollapsibleSection
title="Erledigt"
count={completedTasks.length}
icon="completed"
variant="success"
defaultOpen={true}
>
<TaskList
tasks={completedTasks}
enableDragDrop
dropTargetDate="completed"
onTaskDrop={handleTaskDrop}
showCompleted
/>
</CollapsibleSection>
{/if}
{#if showOnboardingTip && !tipDismissed}
<div class="onboarding-tip">
<span class="onboarding-tip-icon">💡</span>
<span class="onboarding-tip-text">
Tipp: Nutze <code>#tags</code> und <code>!priorität</code> für bessere Organisation
</span>
<button
class="onboarding-tip-close"
onclick={() => (tipDismissed = true)}
title="Tipp ausblenden"
aria-label="Tipp ausblenden"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/if}
</div> </div>
</div> {/if}
</div> </div>
{/if} </div>
</div> {/if}
<style> <style>
.unified-view { /* ── Page tabs ── */
.page-tabs {
display: flex;
gap: 0.25rem;
padding: 0.5rem 1.5rem 0;
overflow-x: auto;
scrollbar-width: none;
}
.page-tabs::-webkit-scrollbar {
display: none;
}
.page-tab {
padding: 0.375rem 0.875rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #9ca3af;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.page-tab:hover {
color: #6b7280;
}
.page-tab.active {
color: hsl(var(--color-primary));
border-bottom-color: hsl(var(--color-primary));
}
:global(.dark) .page-tab {
color: #6b7280;
}
:global(.dark) .page-tab:hover {
color: #9ca3af;
}
:global(.dark) .page-tab.active {
color: hsl(var(--color-primary-light, var(--color-primary)));
border-bottom-color: hsl(var(--color-primary-light, var(--color-primary)));
}
/* ── Notepad page — horizontal scroll wrapper ── */
.notepad-page {
padding-bottom: 100px; padding-bottom: 100px;
} }
.scroll-track {
display: flex;
gap: 1.5rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-padding: 1.5rem;
padding: 1rem 1.5rem 2rem;
scrollbar-width: none;
}
.scroll-track::-webkit-scrollbar {
display: none;
}
/* ── Paper sheet ── */
.notepad-sheet {
flex: 0 0 auto;
width: min(840px, 85vw);
min-height: 60vh;
scroll-snap-align: center;
background: #fffef5;
border-radius: 0.375rem;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
overflow: hidden;
}
:global(.dark) .notepad-sheet {
background-color: #252220;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
.sheet-completed {
opacity: 0.75;
}
/* ── Sheet header ── */
.sheet-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 1.5rem;
font-size: 0.875rem;
font-weight: 600;
color: #374151;
text-transform: uppercase;
letter-spacing: 0.03em;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .sheet-header {
color: #d1d5db;
border-bottom-color: rgba(255, 255, 255, 0.06);
}
.sheet-header-warning {
color: #dc2626;
}
:global(.dark) .sheet-header-warning {
color: #f87171;
}
.section-count {
font-size: 0.6875rem;
font-weight: 500;
background: rgba(0, 0, 0, 0.06);
color: #6b7280;
padding: 0.0625rem 0.4375rem;
border-radius: 9999px;
line-height: 1.4;
}
:global(.dark) .section-count {
background: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
.sheet-header-warning .section-count {
background: rgba(220, 38, 38, 0.1);
color: #dc2626;
}
:global(.dark) .sheet-header-warning .section-count {
background: rgba(248, 113, 113, 0.15);
color: #f87171;
}
/* ── Sheet content ── */
.sheet-content {
flex: 1;
padding: 0.5rem 0;
}
/* ── Subsections (upcoming days) ── */
.subsection {
margin: 0;
padding: 0;
}
.subsection-label {
font-size: 0.75rem;
font-weight: 500;
color: #9ca3af;
min-height: 2.5rem;
display: flex;
align-items: center;
padding: 0 1.5rem;
margin: 0;
}
:global(.dark) .subsection-label {
color: #6b7280;
}
/* ── Empty state ── */
.empty-state-container { .empty-state-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -350,9 +604,12 @@
.empty-state-title { .empty-state-title {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
color: hsl(var(--color-foreground)); color: #374151;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
:global(.dark) .empty-state-title {
color: #f3f4f6;
}
.empty-state-cta { .empty-state-cta {
display: flex; display: flex;
@ -360,15 +617,21 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
background: hsl(var(--color-surface-hover)); background: rgba(0, 0, 0, 0.04);
border-radius: 0.75rem; border-radius: 0.75rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
:global(.dark) .empty-state-cta {
background: rgba(255, 255, 255, 0.06);
}
.empty-state-cta-text { .empty-state-cta-text {
color: hsl(var(--color-muted-foreground)); color: #6b7280;
font-size: 0.9375rem; font-size: 0.9375rem;
} }
:global(.dark) .empty-state-cta-text {
color: #9ca3af;
}
.empty-state-arrow { .empty-state-arrow {
color: hsl(var(--color-primary)); color: hsl(var(--color-primary));
@ -396,7 +659,7 @@
font-weight: 500; font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground)); color: #9ca3af;
} }
.examples-grid { .examples-grid {
@ -410,34 +673,35 @@
padding: 0.5rem 0.875rem; padding: 0.5rem 0.875rem;
font-size: 0.875rem; font-size: 0.875rem;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
background: hsl(var(--color-surface)); background: rgba(255, 255, 255, 0.6);
border: 1px solid hsl(var(--color-border)); border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 9999px; border-radius: 9999px;
color: hsl(var(--color-foreground)); color: #374151;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
:global(.dark) .example-chip {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.12);
color: #e5e7eb;
}
.example-chip:hover { .example-chip:hover {
background: hsl(var(--color-primary) / 0.1); background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary)); border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary)); color: hsl(var(--color-primary));
transform: translateY(-1px); transform: translateY(-1px);
} }
.example-chip:active { .example-chip:active {
transform: translateY(0); transform: translateY(0);
} }
/* ── Onboarding tip ── */
.onboarding-tip { .onboarding-tip {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.875rem 1rem; padding: 0.875rem 1.5rem;
margin-top: 1rem; margin: 0.5rem 0;
background: hsl(var(--color-primary) / 0.08);
border: 1px solid hsl(var(--color-primary) / 0.2);
border-radius: 0.75rem;
font-size: 0.875rem; font-size: 0.875rem;
} }
@ -446,7 +710,10 @@
} }
.onboarding-tip-text { .onboarding-tip-text {
color: hsl(var(--color-muted-foreground)); color: #6b7280;
}
:global(.dark) .onboarding-tip-text {
color: #9ca3af;
} }
.onboarding-tip-close { .onboarding-tip-close {
@ -454,17 +721,19 @@
margin-left: auto; margin-left: auto;
padding: 0.25rem; padding: 0.25rem;
border-radius: 0.375rem; border-radius: 0.375rem;
color: hsl(var(--color-muted-foreground)); color: #9ca3af;
background: transparent; background: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.onboarding-tip-close:hover { .onboarding-tip-close:hover {
color: hsl(var(--color-foreground)); color: #374151;
background: hsl(var(--color-primary) / 0.15); background: hsl(var(--color-primary) / 0.15);
} }
:global(.dark) .onboarding-tip-close:hover {
color: #f3f4f6;
}
.onboarding-tip-text code { .onboarding-tip-text code {
padding: 0.125rem 0.375rem; padding: 0.125rem 0.375rem;
@ -474,34 +743,4 @@
border-radius: 0.25rem; border-radius: 0.25rem;
color: hsl(var(--color-primary)); color: hsl(var(--color-primary));
} }
.notepad {
max-width: 560px;
margin: 0 auto;
background: #fffef5;
border-radius: 0.5rem;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08),
0 1px 2px rgba(0, 0, 0, 0.04);
position: relative;
}
: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);
}
.notepad-content {
padding: 0.75rem 1rem 1.5rem 1rem;
min-height: 200px;
}
@media (max-width: 640px) {
.notepad {
max-width: 100%;
border-radius: 0.5rem;
}
}
</style> </style>

View file

@ -5,6 +5,7 @@
import { isLoading as i18nLoading } from 'svelte-i18n'; import { isLoading as i18nLoading } from 'svelte-i18n';
import { theme } from '$lib/stores/theme'; import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { debugStore } from '$lib/stores/debug.svelte';
import { AppLoadingSkeleton } from '$lib/components/skeletons'; import { AppLoadingSkeleton } from '$lib/components/skeletons';
let { children } = $props(); let { children } = $props();
@ -13,14 +14,20 @@
let appReady = $derived(!loading && !$i18nLoading); let appReady = $derived(!loading && !$i18nLoading);
onMount(async () => { onMount(() => {
// Initialize theme
theme.initialize(); theme.initialize();
authStore.initialize().then(() => {
loading = false;
});
// Initialize auth const handleKey = (e: KeyboardEvent) => {
await authStore.initialize(); if (e.ctrlKey && e.shiftKey && e.key === 'D') {
e.preventDefault();
loading = false; debugStore.toggle();
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}); });
</script> </script>
@ -40,7 +47,16 @@
{#if !appReady} {#if !appReady}
<AppLoadingSkeleton layout="tasks" listItemCount={4} /> <AppLoadingSkeleton layout="tasks" listItemCount={4} />
{:else} {:else}
<div class="min-h-screen bg-background text-foreground"> <div class="min-h-screen bg-background text-foreground" class:debug-mode={debugStore.enabled}>
{@render children()} {@render children()}
</div> </div>
{#if debugStore.enabled}
<button
class="fixed top-2 right-2 z-[9999] rounded bg-red-600 px-2 py-0.5 font-mono text-xs text-white opacity-80 hover:opacity-100"
onclick={() => debugStore.toggle()}
>
DEBUG
</button>
{/if}
{/if} {/if}