Merge branch 'ulsklyc:main' into main
This commit is contained in:
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.24.1] - 2026-04-25
|
||||
|
||||
### Fixed
|
||||
- Accessibility: skip-to-content link added to `index.html` — keyboard users can now bypass navigation and jump directly to main content
|
||||
- Accessibility: removed `role="presentation"` from modal overlay — restores screen reader access and resolves conflict with existing `aria-label`
|
||||
- Accessibility: search overlay now traps keyboard focus — tabbing can no longer escape the overlay into the hidden page behind it
|
||||
- Interaction: modal swipe-to-close — kept `dragging` flag active on upswing so the panel snaps back correctly instead of getting stuck
|
||||
- Rendering: SVG gradient IDs in the logo are now unique per render — prevents DOM ID collisions when the logo is mounted more than once
|
||||
- Touch targets: `.btn--icon-sm` minimum size raised from 36×36px to 44×44px (`--target-base`) — meets iOS minimum touch target guideline
|
||||
- Design tokens: added `--target-base: 44px` and documented `--target-sm: 32px` as visual-only (not a touch target)
|
||||
|
||||
## [0.24.0] - 2026-04-25
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Oikos Premium UI/UX Audit & Implementation Plan
|
||||
|
||||
Basierend auf den "Design Taste Frontend"-Richtlinien (Vercel-core meets Dribbble-clean, Anti-Slop, High-End) habe ich die Oikos-App auditiert. Hier sind die gefundenen Design-Anti-Patterns und der Plan zu deren Behebung.
|
||||
|
||||
> **Überprüft 2026-04-25:** Punkt 3 (Tactile Feedback) und Punkt 4 (Liquid Glass Refraction) sind bereits implementiert. Punkt 1 (Farbe) und Punkt 2 (Schrift) wurden nach Abwägung bewusst nicht umgesetzt — siehe Begründungen unten.
|
||||
|
||||
## 1. Audit-Ergebnisse (Identifizierte Anti-Patterns)
|
||||
|
||||
### 🟡 The "AI Purple/Blue" Ban (Color Calibration) — Bewusst nicht umgesetzt
|
||||
* **Ist-Zustand:** Die primäre Akzentfarbe (`--_color-accent`) ist auf `#4F46E5` (Indigo) gesetzt. Auch die sekundäre Akzentfarbe `#7C5CFC` geht stark in Richtung des typischen "AI Purple/Blue" (Lila-Bann).
|
||||
* **Soll-Zustand:** Lila/Neon-Blau ist laut Design-Guidelines strikt verboten. Wir benötigen eine absolut neutrale Basis mit einem starken, singulären Akzent.
|
||||
* **Maßnahme:** Umstellung der Akzentfarbe auf ein sattes "Deep Rose" (z.B. `#E11D48` / `#BE123C`) oder "Emerald", um einen erwachseneren, hochwertigeren Look zu erzeugen.
|
||||
* **Entscheidung (2026-04-25):** Nicht umgesetzt. Indigo ist bewusst gewählt, dokumentiert (tokens.css §2) und WCAG-konform (4.93:1 auf weiß). Ein Farbwechsel wäre eine Brand-Entscheidung, kein UX-Bug.
|
||||
|
||||
### 🟡 Deterministic Typography (Anti-Slop) — Bewusst nicht umgesetzt
|
||||
* **Ist-Zustand:** Die App nutzt den standardmäßigen System-Font-Stack (`-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto...`).
|
||||
* **Soll-Zustand:** Generische Schriften wie Inter oder Systemschriften wirken oft billig ("Startup Slop"). Für ein "Premium"-Dashboard-Gefühl sollte zwingend eine Schriftart mit Charakter wie `Geist`, `Satoshi` oder `Cabinet Grotesk` erzwungen werden.
|
||||
* **Maßnahme:** Einbindung von `Geist` (via Fontsource oder lokal) und Aktualisierung der `--font-sans` Variable.
|
||||
* **Entscheidung (2026-04-25):** Nicht umgesetzt. CLAUDE.md verbietet CDN-Links zur Laufzeit. Self-Hosting wäre möglich, aber System-Fonts sind schneller, privatsphäre-freundlicher und für eine selbstgehostete Family-App besser geeignet.
|
||||
|
||||
### ✅ Tactile Feedback & Motion Intensity — Bereits implementiert
|
||||
* **Ist-Zustand:** Buttons haben Hover-Zustände, aber der "physische Klick" (Tactile Feedback) auf den Active-State fehlt oftmals oder ist nicht konsistent durch die Bank weg definiert.
|
||||
* **Soll-Zustand:** Auf `:active` muss ein `-translate-y-[1px]` oder `scale(0.98)` angewandt werden, um einen physischen Druckwiderstand zu simulieren.
|
||||
* **Maßnahme:** Anpassung der `.btn:active` und `.more-item:active` Selektoren in `layout.css`.
|
||||
* **Status (2026-04-25):** Bereits vorhanden — `.btn:active { transform: scale(0.98); }` in `layout.css`.
|
||||
|
||||
### ✅ Liquid Glass Refraction — Bereits implementiert
|
||||
* **Ist-Zustand:** Der Glass-Effekt nutzt `backdrop-filter` und teilweise Ränder, aber die physikalisch korrekte Kantenbrechung (Refraktion) fehlt.
|
||||
* **Soll-Zustand:** Glassmorphismus benötigt einen 1px "Inner Border" (als `inset` Box-Shadow) aus weiß/transparent, um die Brechung von echtem Glas an der oberen Kante zu simulieren (`shadow-[inset_0_1px_0_rgba(255,255,255,0.1)]`).
|
||||
* **Maßnahme:** Update der `--_glass-shadow-sm` und ähnlichen Variablen in `tokens.css` und `glass.css`.
|
||||
* **Status (2026-04-25):** Bereits vorhanden — `--glass-inset-base/medium/strong/elevated` in `tokens.css §16d`, angewandt in `glass.css` auf Buttons, FAB und Toasts.
|
||||
|
||||
### 🔴 Dashboard Hardening & "Anti-Card Overuse"
|
||||
* **Ist-Zustand:** Dashboard-Widgets nutzen kompakte 12px Paddings und Standard-Schatten.
|
||||
* **Soll-Zustand:** Ein "Vercel-core" Aesthetic verlangt großzügigeres Whitespace (z.B. 24px+ Padding), pure weiße Karten auf leicht grauem Grund (`#f9fafb`) mit 1px Rändern (`border-slate-200/50`) und sehr weichen, diffusen Schatten anstelle von harten Dropshadows.
|
||||
* **Maßnahme:** Erhöhung des Widget-Paddings und Anpassung der Border- und Shadow-Tokens.
|
||||
|
||||
---
|
||||
|
||||
## 2. Implementierungsplan (Ausführung)
|
||||
|
||||
Ich werde nun folgende Dateien anpassen, um die oben genannten Mängel zu beheben:
|
||||
|
||||
1. **`index.html`**: Import der Schriftart `Geist` hinzufügen.
|
||||
2. **`public/styles/tokens.css`**:
|
||||
* `--font-sans` auf `"Geist", -apple-system...` ändern.
|
||||
* Akzentfarben (Indigo -> Deep Rose) umbauen.
|
||||
* Schatten für "Liquid Glass" anpassen (Inner Shadow für Refraktion hinzufügen).
|
||||
3. **`public/styles/layout.css`**:
|
||||
* Taktiles Feedback (`transform: scale(0.98) translateY(1px)`) auf alle Buttons im `:active`-Zustand anwenden.
|
||||
4. **`public/styles/dashboard.css`**:
|
||||
* Padding der `.widget__body` und `.widget-greeting` erhöhen, um mehr "Art Gallery/Vercel-core" Whitespace zu schaffen.
|
||||
|
||||
*(Die Ausführung der Änderungen erfolgt im Hintergrund im nächsten Schritt.)*
|
||||
@@ -0,0 +1,51 @@
|
||||
# Oikos UX/UI Audit & Implementation Plan
|
||||
|
||||
## 1. Design-System & Styling
|
||||
|
||||
### Issues Identified
|
||||
1. **Touch Targets Too Small**: `--target-sm: 32px` and `.btn--icon-sm` (36x36px) violate minimum touch target guidelines (44x44px or 48x48px). This causes usability issues on mobile devices (fat-finger syndrome).
|
||||
2. **Duplicated Dark Mode Tokens**: Dark mode CSS variables in `tokens.css` are duplicated across `@media (prefers-color-scheme: dark)` and `[data-theme="dark"]`, creating a maintenance nightmare.
|
||||
|
||||
### Implementation Steps
|
||||
- [x] **Fix Touch Targets**: `--target-base: 44px` Token ergänzt; `.btn--icon-sm` auf `min-height/min-width: var(--target-base)` korrigiert; `--target-sm` bleibt 32px als visuelle Größe (kein Touch-Target).
|
||||
- [ ] **Refactor Theme Tokens**: Bewusst übersprungen — der CSS-native `@media (prefers-color-scheme: dark)`-Block ist eine Stärke (Dark Mode ohne JS). Entfernen würde Nutzer ohne JS ohne Dark Mode lassen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Components & Interaction
|
||||
|
||||
### Issues Identified
|
||||
1. **Mobile Modal Swipe-to-Close Bug**: If a user drags the modal down (`dy > 0`) and then back up (`dy < 0`) without lifting their finger, `dragging` is set to `false`. The `touchend` event is then ignored, leaving the modal stuck out of position.
|
||||
2. **Accessibility (A11y) Violation**: The `.modal-overlay` element uses `role="presentation"` alongside an `aria-label`. `role="presentation"` hides the element from screen readers, conflicting with the label and its function as a clickable close area.
|
||||
3. **Misplaced Utility Functions**: Generic UI helpers (`wireBlurValidation`, `validateAll`, `btnSuccess`, `btnLoading`, `btnError`) are hardcoded in `pages/dashboard.js` instead of a shared utility file.
|
||||
|
||||
### Implementation Steps
|
||||
- [x] **Fix Swipe Bug (`modal.js`)**: `touchmove`-Handler korrigiert — bei `dy < 0` wird Panel auf `translateY(0)` zurückgesetzt, `dragging` bleibt `true`.
|
||||
- [x] **Fix Modal A11y (`modal.js`)**: `role="presentation"` aus `.modal-overlay` entfernt.
|
||||
- [x] **Relocate Utilities**: Bereits erledigt — `wireBlurValidation`, `validateAll`, `btnSuccess`, `btnLoading`, `btnError` sind in `utils/ux.js` (Zeilen 538–620).
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout, Navigation & Routing
|
||||
|
||||
### Issues Identified
|
||||
1. **FOUC (Flash of Unstyled Content) on Navigation**: In `router.js`, `loadPageStyle` removes the old stylesheet before the new page transition animation (`page-transition--in-right`) completes, causing layout jumps.
|
||||
2. **Missing Focus Trap in Global Search**: The `#search-overlay` does not use the focus trap logic from `modal.js`. Users can tab out of the search overlay into the hidden page below.
|
||||
3. **SVG ID Collision Risk**: The logo generated in `router.js` uses a hardcoded ID (`id="oikos-logo-bg"`) for its gradient. If reused, this will break rendering.
|
||||
|
||||
### Implementation Steps
|
||||
- [ ] **Fix Routing FOUC (`router.js`)**: Kein echter Bug — `style.cleanup()` wird vor `module.render()` aufgerufen, aber die neue Seite startet `opacity: 0`. Kein sichtbares FOUC in der aktuellen Implementierung.
|
||||
- [x] **Add Search Focus Trap (`router.js`)**: Eigenständiger Focus Trap in `openSearch`/`closeSearch` implementiert (ohne modal.js-Kopplung).
|
||||
- [x] **Fix SVG IDs (`router.js`)**: Gradient-ID wird nun mit `Math.random().toString(36)`-Suffix generiert.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dashboard
|
||||
|
||||
### Issues Identified
|
||||
1. **Wasted Space from Large Empty States**: Empty widgets (e.g., no tasks) render a large "Empty State" UI. On mobile, this pushes populated widgets below the fold.
|
||||
2. **Lack of Visual Feedback in Customization**: Reordering widgets in the customize modal (`rebuildList()`) happens instantly without transition, feeling jarring.
|
||||
|
||||
### Implementation Steps
|
||||
- [ ] **Compact Empty States (`dashboard.js`)**: Offen — `.widget__empty` hat bereits reduziertes Padding (`space-5`), aber kein echtes Row-Layout. Niedrige Priorität.
|
||||
- [ ] **Animate Widget Reordering (`dashboard.js`)**: Offen — View Transition API wäre sinnvoll, aber kein Bug. Niedrige Priorität.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "oikos",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "oikos",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oikos",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.1",
|
||||
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
||||
"main": "server/index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -131,7 +131,7 @@ function _wireSheetSwipe(panel) {
|
||||
panel.addEventListener('touchmove', (e) => {
|
||||
if (!dragging) return;
|
||||
const dy = e.touches[0].clientY - startY;
|
||||
if (dy < 0) { dragging = false; return; } // Aufwärts-Scroll: Swipe abbrechen
|
||||
if (dy < 0) { panel.style.transform = 'translateY(0)'; return; } // Aufwärts: Panel zurücksetzen, dragging bleibt aktiv
|
||||
// Erst ab 10px Bewegung animieren: Verhindert winzige Transforms durch
|
||||
// normale Taps, die danach zurückgesetzt werden müssten.
|
||||
if (dy > 10) panel.style.transform = `translateY(${(dy - 10) * 0.6}px)`;
|
||||
@@ -218,7 +218,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
|
||||
const sizeClass = size !== 'md' ? ` modal-panel--${size}` : '';
|
||||
|
||||
const html = `
|
||||
<div class="modal-overlay" id="shared-modal-overlay" aria-label="${t('modal.overlayLabel')}" role="presentation">
|
||||
<div class="modal-overlay" id="shared-modal-overlay" aria-label="${t('modal.overlayLabel')}">
|
||||
<div class="modal-panel${sizeClass}" role="dialog" aria-modal="true"
|
||||
aria-labelledby="shared-modal-title">
|
||||
<div class="modal-panel__header">
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
<script src="/lucide.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Skip-Link: sichtbar bei Keyboard-Fokus, verknüpft mit <main id="main-content"> -->
|
||||
<a href="#main-content" class="sr-only">Zum Hauptinhalt springen</a>
|
||||
|
||||
<!-- App-Shell - wird durch JavaScript gefüllt -->
|
||||
<div id="app" class="app-shell">
|
||||
<!-- Skeleton-Loading während Initialisierung -->
|
||||
|
||||
+27
-2
@@ -313,7 +313,8 @@ function renderAppShell(container) {
|
||||
logoSvg.setAttribute('fill', 'none');
|
||||
const defs = document.createElementNS(SVG_NS, 'defs');
|
||||
const grad = document.createElementNS(SVG_NS, 'linearGradient');
|
||||
grad.setAttribute('id', 'oikos-logo-bg');
|
||||
const gradId = `oikos-logo-bg-${Math.random().toString(36).slice(2, 7)}`;
|
||||
grad.setAttribute('id', gradId);
|
||||
grad.setAttribute('x1', '0'); grad.setAttribute('y1', '0');
|
||||
grad.setAttribute('x2', '160'); grad.setAttribute('y2', '160');
|
||||
grad.setAttribute('gradientUnits', 'userSpaceOnUse');
|
||||
@@ -328,7 +329,7 @@ function renderAppShell(container) {
|
||||
logoSvg.appendChild(defs);
|
||||
const bgRect = document.createElementNS(SVG_NS, 'rect');
|
||||
bgRect.setAttribute('width', '160'); bgRect.setAttribute('height', '160');
|
||||
bgRect.setAttribute('rx', '36'); bgRect.setAttribute('fill', 'url(#oikos-logo-bg)');
|
||||
bgRect.setAttribute('rx', '36'); bgRect.setAttribute('fill', `url(#${gradId})`);
|
||||
logoSvg.appendChild(bgRect);
|
||||
const housePath = document.createElementNS(SVG_NS, 'path');
|
||||
housePath.setAttribute('d', 'M80 36L36 72V120C36 122.2 37.8 124 40 124H68V96H92V124H120C122.2 124 124 122.2 124 120V72L80 36Z');
|
||||
@@ -528,17 +529,41 @@ function initSearch(container) {
|
||||
const results = container.querySelector('#search-results');
|
||||
if (!searchBtn || !overlay || !input || !results) return;
|
||||
|
||||
// Leichtgewichtiger Focus Trap für das Search Overlay.
|
||||
// Eigenständig (kein modal.js), da modul-globale Variablen in modal.js
|
||||
// bei gleichzeitig offenem Modal überschrieben würden.
|
||||
let _searchTrapHandler = null;
|
||||
|
||||
function openSearch() {
|
||||
if (window._closeMoreSheet) window._closeMoreSheet();
|
||||
overlay.setAttribute('aria-hidden', 'false');
|
||||
overlay.classList.add('search-overlay--visible');
|
||||
setTimeout(() => input.focus(), 50);
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
|
||||
const FOCUSABLE = 'a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex="-1"])';
|
||||
_searchTrapHandler = (e) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
const focusable = Array.from(overlay.querySelectorAll(FOCUSABLE));
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault(); last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault(); first.focus();
|
||||
}
|
||||
};
|
||||
overlay.addEventListener('keydown', _searchTrapHandler);
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
overlay.classList.remove('search-overlay--visible');
|
||||
if (_searchTrapHandler) {
|
||||
overlay.removeEventListener('keydown', _searchTrapHandler);
|
||||
_searchTrapHandler = null;
|
||||
}
|
||||
input.value = '';
|
||||
results.replaceChildren();
|
||||
}
|
||||
|
||||
@@ -1990,13 +1990,11 @@
|
||||
button i[data-lucide],
|
||||
button svg { pointer-events: none; }
|
||||
|
||||
/* Kompakter Icon-Button (36×36) für Icons in engen Listenkontexten */
|
||||
/* Kompakter Icon-Button: 44px Klickfläche, optisch kompakt durch geringes Padding */
|
||||
.btn--icon-sm {
|
||||
padding: var(--space-1);
|
||||
min-height: unset;
|
||||
min-width: unset;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: var(--target-base);
|
||||
min-width: var(--target-base);
|
||||
}
|
||||
|
||||
/* Textarea: vertikale Größenänderung ist nutzbar */
|
||||
|
||||
@@ -307,9 +307,10 @@
|
||||
/* --------------------------------------------------------
|
||||
* 11b. Touch-Target Sizes
|
||||
* -------------------------------------------------------- */
|
||||
--target-sm: 32px;
|
||||
--target-md: 40px;
|
||||
--target-lg: 48px;
|
||||
--target-sm: 32px; /* Visuelle Größe (z.B. Logos) — kein Touch-Target */
|
||||
--target-md: 40px; /* Desktop Touch-Target (Maus) */
|
||||
--target-lg: 48px; /* Mobile Touch-Target (Finger) */
|
||||
--target-base: 44px; /* iOS-Minimum Touch-Target (44pt) */
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* 12. Layout
|
||||
|
||||
Reference in New Issue
Block a user