docs: renewed README and light/dark diagonal screenshots; wider hashtag tiles
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m13s

- README: current features (journal, focus mode, push, themes, sharing
  into the inbox), outgoing connections, :latest instead of :stable,
  VAPID_* and LEGAL_CUSTOM_LINK_*, new journal and phone screenshots
- e2e/screenshots.sh: light and dark in the same state joined along a
  diagonal, own container/port per run
- hashtag tiles at least 18rem wide

chore(release): v0.15.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-31 00:41:20 +01:00
1 parent 6a855fc32c
commit 508717cc02
14 files changed
+189 -36

No files matched your search

+116 -3
View File
@@ -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();
};
+24 -8
View File
@@ -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'