diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6598461..6f5fa0b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,12 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
+## [0.15.0] - 2026-10-31
+
+### Changed
+- Hashtag-Übersicht: Kacheln sind breiter (so viele Spalten wie mit mindestens 18rem passen), Tag-Namen und Notiztitel haben mehr Platz.
+- README überarbeitet und Screenshots erneuert: jedes Bild zeigt links das helle und rechts das dunkle Theme (diagonal geteilt), neu sind Journal und Notiz am Handy. `e2e/screenshots.sh` erzeugt die Paare selbst (`SHOT_LIGHT`/`SHOT_DARK` für andere Farbfamilien) und läuft wie die Tests mit eigenem Container und Port.
+
## [0.14.3] - 2026-10-31
### Changed
@@ -352,7 +358,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
-[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.3...dev
+[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.15.0...dev
+[0.15.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.3...v0.15.0
[0.14.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.2...v0.14.3
[0.14.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.1...v0.14.2
[0.14.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.0...v0.14.1
diff --git a/README.md b/README.md
index 24d4bf7..f83ce25 100644
--- a/README.md
+++ b/README.md
@@ -6,14 +6,16 @@
[](#)
[](#-schnellstart)
[](#-deine-daten-gehören-dir)
-[](#-schnellstart)
+[](CHANGELOG.md)
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
-
+
+Alle Screenshots zeigen links das helle und rechts das dunkle Theme.
+
[Philosophie](#-die-philosophie) • [Funktionen](#-funktionen) • [Schnellstart](#-schnellstart) • [Betrieb](#-betrieb--konfiguration) • [Entwicklung](#-entwicklung) • [Dokumentation](#-dokumentation)
@@ -36,11 +38,14 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe: über den Browser oder datenschutzfreundlich über einen eigenen Whisper-Container (optional in `docker-compose.yml`, siehe [Architektur](Doku/architecture.md#spracherkennung)); auf Smartphones auch per Gedrückthalten des `+`-Knopfs (Hold-to-Record mit Pegel-Wellen).
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
-* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
+* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste oder über große Knöpfe unter der Karte; die Warteschlange klappt bei Bedarf auf.
+* **Teilen aus anderen Apps:** Als installierte App erscheint MindSift im Teilen-Menü des Smartphones; geteilte Texte und Links landen im Posteingang.
* Papierkorb mit Wiederherstellen.
-
+
+
+
### ✅ Aufgaben & Kanban
@@ -53,7 +58,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
* **Wiederholungen:** Wiederkehrende Aufgaben (täglich, wöchentlich, monatlich, jährlich) mit automatischer Folgeaufgabe beim Abhaken.
* **Tageszusammenfassung:** Tägliche Übersicht fälliger Aufgaben zur selbst gewählten Uhrzeit per E-Mail, Discord, eigenem Webhook oder Push.
* **Push-Benachrichtigungen** auf Handy und Desktop (auch bei geschlossener App; iPhone/iPad als Home-Bildschirm-App), als eigener Kanal je Ereignis.
-* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort per E-Mail, Discord oder eigenem Webhook; granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben sowie tägliche Zusammenfassungen zur gewählten Uhrzeit.
+* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort, alle übrigen Ereignisse (Freigaben, erledigte geteilte Aufgaben, beobachtete Aufgaben) gebündelt; pro Ereignis wählbar über E-Mail, Discord, eigenen Webhook und Push.
@@ -62,28 +67,32 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
### 📝 Workspace: Outliner-Notizen
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
-* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
+* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren; **Fokus-Modus** (`Strg+Umschalt+F`) zeigt nur die Notiz in Lesebreite, Ordner gleiten bei Bedarf von links herein.
* **Journal:** eine Notiz pro Tag, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach), mit Kalender zum Springen; einzelne Tage lassen sich teilen. Pro Gerät abschaltbar.
* Blocktypen und Slash-Befehle: `/todo`, `/number` (nummerierte Liste: 1., a., i.), `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`, `/extract`, `/image`, `/audio`, `/diktat`.
* **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription).
-* **Link-Vorschau:** Automatische Vorschaukarten über Punkten mit reiner Web-Adresse.
+* **Link-Vorschau:** Vorschaukarte (Titel, Beschreibung, Bild) für Punkte, die nur aus einer Web-Adresse bestehen, z. B. über `/link`; die Seite ruft der Server ab, nicht dein Browser. Pro Gerät abschaltbar.
* **Ast auslagern & Notizen zusammenführen:** Bulletpoints samt Unterbaum in eigenständige Notizen auslagern (`/extract`) oder Notizen per Drag & Drop bzw. Menü zusammenführen (jeweils mit vollständiger Rückgängig-Funktion).
* Wiki-Links `[[Notiz]]`, auch auf **einzelne Punkte** (`[[Notiz#^id|Text]]`, kopierbar an jedem Punkt), Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
-* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt; `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her.
+* Eingefügtes Markdown wird zur Gliederung: Überschriften werden zu Abschnitten mit eingerücktem Inhalt, Listen (auch nummeriert) behalten ihre Ebenen, Absätze werden eigene Punkte. Kürzel beim Tippen: `- `, `1. `, `[] `, `# `, `Strg+B`/`I`/`K`. `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her.
* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen als **Bearbeiter** oder **Leser** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein.
#### 🤝 Gemeinsam bearbeiten
-* **Live:** Mehrere Personen schreiben gleichzeitig in derselben Notiz; Änderungen erscheinen bei den anderen in Bruchteilen einer Sekunde. Der Kopf der Notiz zeigt, wer gerade mitliest.
+* **Live:** Mehrere Personen schreiben gleichzeitig in derselben Notiz; Änderungen erscheinen bei den anderen in Bruchteilen einer Sekunde. Die Leiste über der Notiz zeigt, wer gerade mitliest.
* **Kein Überschreiben:** Wer in einem Bullet schreibt, hält ihn kurz für sich; andere sehen ein Schloss mit Namen und arbeiten in den übrigen Bullets weiter. Kommt es doch zu einem Konflikt (z. B. nach Offline-Arbeit), bleibt der zweite Text als markierte Kopie mit „Übernehmen“/„Verwerfen“ erhalten.
* **Offline-fest:** Jede Eingabe wird zuerst im Browser gesichert und später übertragen, auch nach Neuladen oder Tagen ohne Netz.
-* **Hinter strengen Proxys:** WebSocket → SSE → Long-Polling → Abfrage alle 3 s; MindSift wählt automatisch, was durchkommt. Der aktive Weg steht im Editor („Live“, „Abgleich alle 3 s“).
+* **Hinter strengen Proxys:** WebSocket → SSE → Long-Polling → Abfrage alle 3 s; MindSift wählt automatisch, was durchkommt. Der aktive Weg steht in der Leiste über der Notiz („Live“, „Abgleich alle 3 s“).
Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md).
-
+
+
+
+
+
### 🛒 Geteilte Listen
@@ -99,13 +108,18 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
* Gruppen mit Gruppen-Admins für Freigaben.
-* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
-* Fünf Farbfamilien mit je einer hellen und dunklen Variante (Klassik, Fjord, Terrakotta, Polarized, Suri), Hell/Dunkel auf Wunsch wie das System, eigene Akzentfarbe, Ecken rund oder eckig, Knöpfe mit oder ohne Rahmen plus Theme-Manager für eigene CSS-Themes, installierbar als PWA. Die App startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
+* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus, Sicherungen, Spracherkennung.
+
+### 🎨 Darstellung & App
+
+* **Fünf Farbfamilien** mit je einer hellen und dunklen Variante: Klassik, Fjord, Terrakotta, Polarized, Suri. Hell/Dunkel auf Wunsch „Wie System“, dazu eine eigene Akzentfarbe, Ecken rund oder eckig und Knöpfe mit oder ohne Rahmen (pro Gerät). Für eigene Themes gibt es einen Theme-Manager (eine CSS-Datei).
+* **Installierbar als App (PWA)** auf Smartphone und Desktop. Sie startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
### 🔌 Integrationen
-* **Webhook / API-Key:** `POST /api/v1/webhooks/ingest` legt Aufgaben aus Smarthome, Skripten oder Automationen an.
-* Persönliche API-Keys, Datenexport als JSON.
+* **Eingehend (API-Key):** `POST /api/v1/webhooks/ingest` legt Gedanken, Aufgaben oder Listeneinträge aus Smarthome, Skripten oder Automationen an.
+* **Ausgehend:** eigener Webhook als Benachrichtigungskanal (JSON per POST), Discord, E-Mail, Push.
+* Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel).
---
@@ -138,7 +152,7 @@ Minimal:
```yaml
services:
mindsift:
- image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.8.0); :latest = main, :dev = Entwicklungsstand
+ image: git.dsit.me/sandrodach/mindsift:latest # :latest = Stand von main (jede Version), :dev = Entwicklungsstand
restart: unless-stopped
ports: ["8080:8080"]
volumes: ["./data:/data"]
@@ -172,17 +186,19 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders bei E-Mails |
| `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten |
| `NOTIFY_DIGEST_MINUTES` | `5` | Bündelungsintervall für Benachrichtigungen in Minuten |
+| `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` | automatisch | Schlüssel für Push-Benachrichtigungen; ohne Angabe einmal erzeugt und in der Datenbank gespeichert. `VAPID_SUBJECT` (Kontakt für die Push-Dienste, `mailto:` oder `https://`) sonst aus `BASE_URL` |
| `MEDIA_*` | diverse | Grenzen für Bilder und Audio in Notizen (`MEDIA_MAX_IMAGE_MB=15`, `MEDIA_MAX_AUDIO_MB=50`, `MEDIA_QUOTA_MB=0`) |
| `STT_*` | diverse | Optionale eigene Spracherkennung per Whisper (`STT_URL`, `STT_MODEL`, `STT_LANGUAGE` …; siehe [Doku/architecture.md](Doku/architecture.md#spracherkennung)) |
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
+| `LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL` | leer | Zusätzlicher Link im Footer (z. B. Statusseite), nur `https` |
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** |
| `DEMO_RESET_MINUTES` | `20` | Nur im Demo-Modus: setzt die Datenbank in diesem Abstand auf die Beispieldaten zurück (`0` = nie). Angemeldete bleiben angemeldet, die App zeigt einen Countdown und lädt danach neu. |
* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
* **Datensicherung:** In den Admin-Einstellungen lassen sich automatische tägliche Backups einschalten (ganze Datenbank plus ein Backup je Benutzer unter `data/backups/`, Standard 3 Tage aufbewahrt). Benutzer laden ihre Server-Backups herunter oder spielen sie ganz oder für einzelne Bereiche wieder ein. Der Ordner liegt neben der Datenbank: zusätzlich woanders sichern. Manuell geht weiterhin `sqlite3 mindsift.db ".backup 'backup.db'"`.
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen.
-* **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main`, `:stable` = zuletzt veröffentlichte Version. Ein Git-Tag wie `v1.2.0` erzeugt `:v1.2.0` und verschiebt `:stable` darauf; Vorabversionen wie `v1.0.0-rc.1` verschieben `:stable` nicht.
+* **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main` (jede neue Version landet dort). Ein Git-Tag wie `v1.2.0` erzeugt zusätzlich `:v1.2.0` und `:stable`; Vorabversionen wie `v1.0.0-rc.1` verschieben `:stable` nicht.
* **Versionen & Änderungen:** [Semantic Versioning](https://semver.org/spec/v2.0.0.html), was sich geändert hat, steht in der [CHANGELOG.md](CHANGELOG.md).
---
@@ -204,12 +220,12 @@ npm run test:run # Komponententests (Vitest, jsdom)
npm run build # Produktions-Build
cd ..
e2e/run.sh # Browser-Tests (Playwright, zwei Benutzer) gegen ein frisch gebautes Image
-APP_VERSION=v0.4 e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten)
+e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten, hell/dunkel diagonal)
```
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus.
-**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite) · React 18, TypeScript, Vite, Tailwind.
+**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite, webpush-go) · React 18, TypeScript, Vite, Tailwind.
---
@@ -221,7 +237,7 @@ Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank,
## 🔒 Deine Daten gehören dir
-* Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
+* Selbst gehostet, keine Telemetrie, keine Fremddienste, Schrift und Icons werden mitgeliefert. Ausgehende Verbindungen baut nur der Server auf, und nur für das, was du nutzt: Benachrichtigungen (SMTP, Discord, eigener Webhook, die Push-Dienste der Browser), Link-Vorschauen (die verlinkte Seite wird einmal abgerufen, pro Gerät abschaltbar) und optional dein eigener Whisper-Server.
* Alle Inhalte liegen in einer einzigen SQLite-Datei; Bilder und Sprachnotizen liegen daneben in `data/media/`. Der ganze Workspace lässt sich als Obsidian-kompatibles Markdown-Archiv (ZIP, mit `attachments/`) exportieren, alle Daten eines Benutzers als Backup-ZIP (JSON plus Mediendateien).
---
diff --git a/e2e/screenshots.mjs b/e2e/screenshots.mjs
index d8da61d..e5dc4c5 100644
--- a/e2e/screenshots.mjs
+++ b/e2e/screenshots.mjs
@@ -1,9 +1,15 @@
// Takes the README screenshots from a fresh server in demo mode (sample data of admin/admin).
+// Every picture is taken twice in the same state – light and dark theme – and the two halves are
+// joined along a slanted diagonal (light left, dark right).
// Usage: e2e/screenshots.sh (builds the image) or MINDSIFT_URL=… SHOTS_DIR=… node screenshots.mjs
+// Theme pair: SHOT_LIGHT / SHOT_DARK (default: light / dark, the "Klassik" family)
+import fs from 'node:fs';
import { chromium } from 'playwright';
const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080';
const OUT = process.env.SHOTS_DIR || '../screenshots';
+const LIGHT = process.env.SHOT_LIGHT || 'light';
+const DARK = process.env.SHOT_DARK || 'dark';
const DESKTOP = { viewport: { width: 1518, height: 886 }, deviceScaleFactor: 2 };
const MOBILE = { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -20,7 +26,13 @@ async function login(user) {
async function open(browser, token, options) {
const ctx = await browser.newContext({ ...options, colorScheme: 'dark', locale: 'de-DE' });
- await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token);
+ await ctx.addInitScript(
+ ([t, theme]) => {
+ localStorage.setItem('mindsift_jwt_token', t);
+ localStorage.setItem('mindsift_active_theme', theme);
+ },
+ [token, DARK]
+ );
const page = await ctx.newPage();
page.on('pageerror', (e) => console.log('PAGEERROR', e.message));
await page.goto(BASE);
@@ -28,12 +40,76 @@ async function open(browser, token, options) {
return page;
}
-const tab = (page, name) => page.getByRole('button', { name: new RegExp(name) }).first().click();
+// Anchored, so "Workspace" does not hit the triage button "Als Notiz – Im Workspace"
+const tab = (page, name) => page.getByRole('button', { name: new RegExp(`^${name}`) }).locator('visible=true').first().click();
+
+// Switches the theme in place (no reload), so both halves show exactly the same state
+async function setTheme(page, id, dark) {
+ await page.evaluate(
+ ([themeId, isDark]) => {
+ const root = document.documentElement;
+ root.setAttribute('data-theme', themeId);
+ root.classList.toggle('dark', isDark);
+ },
+ [id, dark]
+ );
+ await sleep(250);
+}
+
+let composer;
+// Light picture on the left of the diagonal, dark on the right, a thin line between them
+async function compose(lightPng, darkPng, file) {
+ const dataUrl = await composer.evaluate(
+ async ([light, dark]) => {
+ const load = (b64) =>
+ new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.src = 'data:image/png;base64,' + b64;
+ });
+ const [a, b] = await Promise.all([load(light), load(dark)]);
+ const w = a.naturalWidth;
+ const h = a.naturalHeight;
+ const canvas = document.createElement('canvas');
+ canvas.width = w;
+ canvas.height = h;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(b, 0, 0);
+ const top = w * 0.58;
+ const bottom = w * 0.42;
+ ctx.save();
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.lineTo(top, 0);
+ ctx.lineTo(bottom, h);
+ ctx.lineTo(0, h);
+ ctx.closePath();
+ ctx.clip();
+ ctx.drawImage(a, 0, 0);
+ ctx.restore();
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
+ ctx.lineWidth = Math.max(2, w / 600);
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.35)';
+ ctx.shadowBlur = w / 300;
+ ctx.beginPath();
+ ctx.moveTo(top, 0);
+ ctx.lineTo(bottom, h);
+ ctx.stroke();
+ return canvas.toDataURL('image/png');
+ },
+ [lightPng.toString('base64'), darkPng.toString('base64')]
+ );
+ fs.writeFileSync(`${OUT}/${file}`, Buffer.from(dataUrl.split(',')[1], 'base64'));
+}
async function shot(page, file) {
await sleep(800); // animations
await page.mouse.move(0, 0);
- await page.screenshot({ path: `${OUT}/${file}` });
+ await setTheme(page, LIGHT, false);
+ const light = await page.screenshot();
+ await setTheme(page, DARK, true);
+ const dark = await page.screenshot();
+ await compose(light, dark, file);
console.log(`OK ${file}`);
}
@@ -41,6 +117,7 @@ const main = async () => {
const admin = await login('admin');
const user = await login('user');
const browser = await chromium.launch({ args: ['--lang=de-DE'] });
+ composer = await (await browser.newContext()).newPage();
const page = await open(browser, admin, DESKTOP);
await tab(page, 'Triage');
@@ -67,6 +144,36 @@ const main = async () => {
await sleep(2000);
await shot(page, 'workspace_desktop.png');
+ // Journal: a few days with entries, the calendar in the sidebar
+ const H = { Authorization: `Bearer ${admin}`, 'Content-Type': 'application/json' };
+ const today = new Date();
+ const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+ const day = (offset) => iso(new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset));
+ await fetch(`${BASE}/api/v1/journal/${day(0)}`, { method: 'POST', headers: H });
+ await page.getByRole('button', { name: 'Journal', exact: true }).click();
+ await sleep(1500);
+ const write = async (date, lines) => {
+ const section = page.locator(`[data-journal-date="${date}"]`);
+ if ((await section.count()) === 0) return; // day outside the visible month
+ await section.locator('.cursor-text').first().click();
+ for (let i = 0; i < lines.length; i++) {
+ if (i > 0) await page.keyboard.press('Enter');
+ await page.keyboard.type(lines[i], { delay: 5 });
+ }
+ await sleep(600);
+ };
+ await write(day(0), ['Standup: Umzug liegt im Plan #homelab', 'Nachmittags Backup-Test mit restic', 'Idee: Wochenrückblick als Vorlage anlegen']);
+ for (const offset of [2, 5]) {
+ await page.getByTitle(new RegExp(new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset).toLocaleDateString('de-DE', { day: 'numeric', month: 'long' }))).first().click().catch(() => {});
+ await sleep(1200);
+ await write(day(offset), offset === 2 ? ['Rasen gemäht, Hecke wartet noch #garten'] : ['Erste Gedanken zur Hausrenovierung #renovierung', 'Angebote einholen bis Monatsende']);
+ }
+ await page.getByRole('button', { name: 'Heute' }).click().catch(() => {});
+ await page.mouse.click(1000, 860);
+ await sleep(2500);
+ await shot(page, 'journal_desktop.png');
+ await page.getByRole('button', { name: 'Journal', exact: true }).click(); // close it again
+
await tab(page, 'Listen');
await page.getByText('Wocheneinkauf', { exact: true }).first().click().catch(() => {});
await shot(page, 'Listen_desktop.png');
@@ -75,6 +182,12 @@ const main = async () => {
await mobile.getByText('Später im Browser weiter nutzen').click({ timeout: 5000 }).catch(() => {}); // install prompt
await shot(mobile, 'Mobile_Triage.png');
+ await tab(mobile, 'Workspace');
+ await sleep(600);
+ await mobile.getByText(/^Homelab 2\.0/).first().click();
+ await sleep(1500);
+ await shot(mobile, 'Mobile_Workspace.png');
+
await browser.close();
};
diff --git a/e2e/screenshots.sh b/e2e/screenshots.sh
index 040553e..195b913 100755
--- a/e2e/screenshots.sh
+++ b/e2e/screenshots.sh
@@ -1,20 +1,36 @@
#!/usr/bin/env bash
# Builds MindSift, starts it in demo mode with a fresh database and renews the README screenshots
-# in screenshots/. Requires podman (or docker via ENGINE=docker). Usage: e2e/screenshots.sh
+# in screenshots/ (light and dark theme joined along a diagonal). Requires podman (or docker via
+# ENGINE=docker). Usage: e2e/screenshots.sh, other theme pair: SHOT_LIGHT=fjord SHOT_DARK=fjord-night
set -euo pipefail
ENGINE="${ENGINE:-podman}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$(mktemp -d)"
-trap '"$ENGINE" rm -f mindsift-shots >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT
+RUN_ID="$$-$RANDOM"
+NAME="mindsift-shots-$RUN_ID"
+IMAGE="mindsift-shots-image:$RUN_ID"
+PORT="${SHOTS_PORT:-}"
+if [ -z "$PORT" ]; then
+ for p in $(shuf -i 18100-18999 -n 50); do
+ if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then PORT="$p"; break; fi
+ done
+fi
+cleanup() {
+ "$ENGINE" rm -f "$NAME" >/dev/null 2>&1 || true
+ "$ENGINE" rmi "$IMAGE" >/dev/null 2>&1 || true
+ rm -rf "$WORK"
+}
+trap cleanup EXIT
echo "Baue Image …"
# Version in the header, e.g. APP_VERSION=v0.2 e2e/screenshots.sh
-"$ENGINE" build -q --build-arg APP_VERSION="${APP_VERSION:-}" -t mindsift-e2e-image "$ROOT" >/dev/null
-"$ENGINE" run -d --name mindsift-shots --network host -e PORT=18081 -e MINDA_DEMO=true \
- -e JWT_SECRET=screenshots-only-secret -v "$WORK":/data:Z mindsift-e2e-image >/dev/null
-for _ in $(seq 1 60); do curl -s -m 1 http://localhost:18081/api/v1/health >/dev/null && break; sleep 0.5; done
+"$ENGINE" build -q --build-arg APP_VERSION="${APP_VERSION:-}" -t "$IMAGE" "$ROOT" >/dev/null
+"$ENGINE" run -d --name "$NAME" --network host -e PORT="$PORT" -e MINDA_DEMO=true -e DEMO_RESET_MINUTES=0 \
+ -e JWT_SECRET=screenshots-only-secret -v "$WORK":/data:Z "$IMAGE" >/dev/null
+for _ in $(seq 1 60); do curl -s -m 1 "http://localhost:$PORT/api/v1/health" >/dev/null && break; sleep 0.5; done
echo "Erstelle Screenshots …"
-"$ENGINE" run --rm --network host -e MINDSIFT_URL=http://localhost:18081 -e SHOTS_DIR=/shots -e LANG=de_DE.UTF-8 \
+"$ENGINE" run --rm --network host -e MINDSIFT_URL="http://localhost:$PORT" -e SHOTS_DIR=/shots \
+ -e SHOT_LIGHT="${SHOT_LIGHT:-light}" -e SHOT_DARK="${SHOT_DARK:-dark}" -e LANG=de_DE.UTF-8 -e TZ=Europe/Berlin \
-v "$ROOT/e2e":/e2e:Z -v "$ROOT/screenshots":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
- sh -c 'apt-get update -qq >/dev/null && apt-get install -y -qq fonts-inter >/dev/null 2>&1; npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs'
+ sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs'
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 7e82406..d7d4581 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
- "version": "0.14.3",
+ "version": "0.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
- "version": "0.14.3",
+ "version": "0.15.0",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
diff --git a/frontend/package.json b/frontend/package.json
index 8940d7a..3977dc4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
- "version": "0.14.3",
+ "version": "0.15.0",
"license": "Unlicense",
"type": "module",
"scripts": {
diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx
index 474ab4e..4b5d317 100644
--- a/frontend/src/features/workspace/WorkspaceView.tsx
+++ b/frontend/src/features/workspace/WorkspaceView.tsx
@@ -2102,7 +2102,8 @@ export const WorkspaceView: React.FC = ({
)}
) : (
-
+
+ {/* As many columns as fit with at least 18rem each, so tag names and note titles have room */}
{filteredTags.map((t) => {
const meta = tagsMetadataMap.get(t.tag.toLowerCase());
return (
diff --git a/screenshots/Listen_desktop.png b/screenshots/Listen_desktop.png
index f78d50d..28bfcd4 100644
Binary files a/screenshots/Listen_desktop.png and b/screenshots/Listen_desktop.png differ
diff --git a/screenshots/Mobile_Triage.png b/screenshots/Mobile_Triage.png
index ff50fc3..2277c1e 100644
Binary files a/screenshots/Mobile_Triage.png and b/screenshots/Mobile_Triage.png differ
diff --git a/screenshots/Mobile_Workspace.png b/screenshots/Mobile_Workspace.png
new file mode 100644
index 0000000..02f0640
Binary files /dev/null and b/screenshots/Mobile_Workspace.png differ
diff --git a/screenshots/Triage_desktop.png b/screenshots/Triage_desktop.png
index ddc1b1e..fadce1b 100644
Binary files a/screenshots/Triage_desktop.png and b/screenshots/Triage_desktop.png differ
diff --git a/screenshots/aufgaben_desktop.png b/screenshots/aufgaben_desktop.png
index 58e2112..7459b86 100644
Binary files a/screenshots/aufgaben_desktop.png and b/screenshots/aufgaben_desktop.png differ
diff --git a/screenshots/journal_desktop.png b/screenshots/journal_desktop.png
new file mode 100644
index 0000000..a71b549
Binary files /dev/null and b/screenshots/journal_desktop.png differ
diff --git a/screenshots/workspace_desktop.png b/screenshots/workspace_desktop.png
index 42947f6..30eb193 100644
Binary files a/screenshots/workspace_desktop.png and b/screenshots/workspace_desktop.png differ