Hintergründe: 15 kuratierte Bilder statt Testfotos

Elf universelle Hintergründe von grafisch bis fotografisch (Tinte, Goldstaub,
Horizont, Kohle, Leinen, Nebel, Dämmerung, Nachthimmel, Tusche, Papier,
Aquarell) plus vier v2-Fassungen zur Abstimmung, alle 2048² JPEG mit
320er Vorschauen (7 MB statt 48 MB). Auswahl, Reihenfolge und Helligkeit
stehen in src/config/backgrounds.js; der Picker zeigt Name und aktuelle
Wahl, das Logo folgt der Bildhelligkeit, Presets zeigen auf die neuen
Bilder, alte Pfade werden beim Laden auf den Nachfolger umgeschrieben.
Der Service Worker cacht nur die Vorschauen vor.

Werkzeuge zum Neu-Rendern (SVG-Builder, Gemini-Prompts) liegen in
dev/tools/backgrounds, die Sichtungs-Renders bleiben lokal (gitignore).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-25 09:31:09 +00:00
parent 10251e4f41
commit 522deffa1a
66 changed files with 556 additions and 38 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

View file

@ -176,9 +176,9 @@ export default defineConfig((ctx) => {
cfg.globPatterns = [
'**/*.{js,css,html,ico,svg,woff,woff2,json,webmanifest}',
'icons/*.png',
'images/bg-thumbs/*.jpg'
'images/backgrounds/thumbs/*.jpg'
]
cfg.globIgnores = [ ...(cfg.globIgnores || []), 'images/bg-image-*.jpg', 'images/gallery/**', 'videos/**', 'audio/**', 'demo/**', 'js/**' ]
cfg.globIgnores = [ ...(cfg.globIgnores || []), 'images/backgrounds/*.jpg', 'images/gallery/**', 'videos/**', 'audio/**', 'demo/**', 'js/**' ]
cfg.navigateFallbackDenylist = [ ...(cfg.navigateFallbackDenylist || []), /^\/api\//, /^\/event-media\// ]
cfg.runtimeCaching = [
{

View file

@ -14,6 +14,7 @@ import { computed } from 'vue'
import { useQuasar } from 'quasar'
import { useSettingsStore } from 'stores/settings'
import { logoVariantForBackground } from 'src/utils/brand'
import { findBackgroundImage } from 'src/config/backgrounds'
import logoPositive from 'assets/logo-thatsme-positiv.svg'
import logoNegative from 'assets/logo-thatsme-negativ.svg'
@ -41,6 +42,7 @@ const resolvedVariant = computed(() => {
bgEdge: fl.bgEdge,
bgCenter: fl.bgCenter,
backgroundImage: fl.backgroundImage,
imageTone: findBackgroundImage(fl.backgroundImage)?.tone ?? null,
isDark: $q.dark.isActive
})
})

View file

@ -92,7 +92,10 @@
<!-- Hintergrundbild -->
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
<span class="lw-settings__card-label">Hintergrundbild</span>
<div class="lw-settings__card-head">
<span class="lw-settings__card-label">Hintergrundbild</span>
<span class="lw-settings__card-value">{{ currentBackgroundLabel }}</span>
</div>
<div class="lw-settings__img-grid" role="listbox" aria-label="Hintergrundbild wählen">
<button
@ -522,6 +525,7 @@ import { useQuasar } from 'quasar'
import { useSettingsStore } from 'stores/settings'
import { usePanelDrag } from 'composables/usePanelDrag'
import { isBuiltInPresetId, presetPreviewStyle } from 'src/config/wavePresets'
import { BACKGROUND_IMAGES, findBackgroundImage } from 'src/config/backgrounds'
import ConfirmDialog from 'components/ConfirmDialog.vue'
const props = defineProps({ open: { type: Boolean, default: false } })
@ -575,15 +579,12 @@ const isCustomBackground = computed(() => {
return bg.startsWith('data:image/') || bg.startsWith('/settings/media/')
})
// Bundled backgrounds: the full image is only loaded once one is chosen; the
// picker shows the small copies from public/images/bg-thumbs.
const BACKGROUND_IMAGES = Array.from({ length: 10 }, (_, index) => {
const n = index + 1
return {
src: `/images/bg-image-${n}.jpg`,
thumb: `/images/bg-thumbs/bg-image-${n}.jpg`,
label: `Hintergrund ${n}`
}
// Bundled backgrounds (src/config/backgrounds.js): the picker shows the
// small copies, the full image loads once one is chosen.
const currentBackgroundLabel = computed(() => {
const bg = fl.value.backgroundImage ?? ''
if (!bg) return 'Keins'
return findBackgroundImage(bg)?.label ?? 'Eigenes Bild'
})
const HORIZON_MODES = [
@ -919,6 +920,18 @@ async function onBackgroundUpload(event) {
font-weight: 600;
}
.lw-settings__card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lw-settings__card-value {
font-size: 12px;
opacity: 0.6;
}
.lw-settings__img-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));

View file

@ -0,0 +1,63 @@
/**
* Bundled LifeWave backgrounds.
*
* All square 2048 px (see dev/backgrounds/README.md for how they were made),
* so `cover` crops them evenly in portrait and landscape. `tone` says what
* sits behind the header: it decides the logo variant, because with a photo
* the wave's own colours say nothing about the ground.
*
* Several motifs exist in two takes on purpose. The set is a selection to
* sort and vote on, not a final ten.
*/
const BASE = '/images/backgrounds'
export const BACKGROUND_IMAGES = [
{ id: 'tinte', label: 'Tinte', tone: 'dark' },
{ id: 'tinte-blatt', label: 'Tinte, Blatt', tone: 'dark' },
{ id: 'goldstaub', label: 'Goldstaub', tone: 'dark' },
{ id: 'horizont', label: 'Horizont', tone: 'dark' },
{ id: 'kohle', label: 'Kohle', tone: 'dark' },
{ id: 'leinen', label: 'Leinen', tone: 'dark' },
{ id: 'leinen-falten', label: 'Leinen, Falten', tone: 'dark' },
{ id: 'nebel', label: 'Nebel', tone: 'dark' },
{ id: 'daemmerung', label: 'Dämmerung', tone: 'dark' },
{ id: 'nachthimmel', label: 'Nachthimmel', tone: 'dark' },
{ id: 'tusche', label: 'Tusche', tone: 'dark' },
{ id: 'papier', label: 'Papier', tone: 'light' },
{ id: 'papier-buetten', label: 'Papier, Bütten', tone: 'light' },
{ id: 'aquarell', label: 'Aquarell', tone: 'light' },
{ id: 'aquarell-streifen', label: 'Aquarell, Streifen', tone: 'light' }
].map(image => ({
...image,
src: `${BASE}/${image.id}.jpg`,
thumb: `${BASE}/thumbs/${image.id}.jpg`
}))
export function findBackgroundImage(src) {
return BACKGROUND_IMAGES.find(image => image.src === src) ?? null
}
/**
* The test images that shipped before 2026-09-25, mapped to their closest
* successor so a stored setting keeps a background instead of a broken path.
*/
const LEGACY_BACKGROUNDS = {
'/images/bg-image-1.jpg': 'nachthimmel',
'/images/bg-image-2.jpg': 'leinen',
'/images/bg-image-3.jpg': 'nebel',
'/images/bg-image-4.jpg': 'tinte',
'/images/bg-image-5.jpg': 'daemmerung',
'/images/bg-image-6.jpg': 'horizont',
'/images/bg-image-7.jpg': 'goldstaub',
'/images/bg-image-8.jpg': 'kohle',
'/images/bg-image-9.jpg': 'tinte',
'/images/bg-image-10.jpg': 'kohle'
}
/** Rewrites a stored background path that no longer exists; others pass through. */
export function normalizeBackgroundImage(src) {
if (typeof src !== 'string') return ''
const legacy = LEGACY_BACKGROUNDS[src]
return legacy ? `${BASE}/${legacy}.jpg` : src
}

View file

@ -83,14 +83,14 @@ export const WAVE_PRESETS = [
{
id: 'builtin:dusk',
name: 'Abendrot',
description: 'Warmer Horizont unten, Nacht oben, Linien in Orange und Rosé.',
description: 'Dämmerung über dem Meer, Linien in Orange und Rosé.',
preview: { ground: ['#3a1a2b', '#120711'], lines: ['#ffb86b', '#ff5e7e'] },
floatingLines: {
lineMode: 'glow',
gradientStops: '#ffb86b\n#ff5e7e\n#2a0f1f',
bgCenter: '#3a1a2b',
bgEdge: '#120711',
backgroundImage: '',
backgroundImage: '/images/backgrounds/daemmerung.jpg',
speed: 0.8,
glowSize: 20,
glowStrength: 1.8,
@ -102,12 +102,12 @@ export const WAVE_PRESETS = [
{
id: 'builtin:milkyway',
name: 'Milchstraße',
description: 'Sternenhimmel mit feinen, kühlen Linien.',
description: 'Nachthimmel mit feinen, kühlen Linien.',
preview: { ground: ['#1b1a24', '#050408'], lines: ['#ffffff', '#9fb4ff'] },
floatingLines: {
lineMode: 'glow',
gradientStops: '#ffffff\n#9fb4ff\n#1a1f3a',
backgroundImage: '/images/bg-image-1.jpg',
backgroundImage: '/images/backgrounds/nachthimmel.jpg',
bgCenter: '#1b1a24',
bgEdge: '#050408',
lineBrightness: 0.75,
@ -121,7 +121,7 @@ export const WAVE_PRESETS = [
{
id: 'builtin:silk',
name: 'Seide',
description: 'Schwarzer Stoff, darauf klare goldene Linien.',
description: 'Dunkles Leinen, darauf klare goldene Linien.',
preview: { ground: ['#2a2a2a', '#0b0b0b'], lines: ['#f5c26b', '#f5c26b'] },
floatingLines: {
lineMode: 'static',
@ -129,7 +129,7 @@ export const WAVE_PRESETS = [
staticLineShadowStrength: 0.35,
lineThickness: 1.1,
lineCount: 8,
backgroundImage: '/images/bg-image-2.jpg',
backgroundImage: '/images/backgrounds/leinen.jpg',
bgCenter: '#2a2a2a',
bgEdge: '#0b0b0b',
glowSize: 14,
@ -141,12 +141,12 @@ export const WAVE_PRESETS = [
{
id: 'builtin:rain',
name: 'Regen',
description: 'Tropfen auf dunklem Glas, Linien in Türkis.',
description: 'Nebel über den Hügeln, Linien in Türkis.',
preview: { ground: ['#1e2224', '#0a0c0d'], lines: ['#7fe3ff', '#1fa2c8'] },
floatingLines: {
lineMode: 'glow',
gradientStops: '#7fe3ff\n#1fa2c8\n#0a1a22',
backgroundImage: '/images/bg-image-3.jpg',
backgroundImage: '/images/backgrounds/nebel.jpg',
bgCenter: '#1e2224',
bgEdge: '#0a0c0d',
speed: 1.2,
@ -159,12 +159,12 @@ export const WAVE_PRESETS = [
{
id: 'builtin:leaves',
name: 'Blattwerk',
description: 'Dunkles Grün mit Gold, wie Sonne durch Blätter.',
description: 'Grün und Gold auf tintenschwarzem Papier.',
preview: { ground: ['#12301a', '#05110a'], lines: ['#f5c26b', '#8bc34a'] },
floatingLines: {
lineMode: 'glow',
gradientStops: '#f5c26b\n#8bc34a\n#0d1a0c',
backgroundImage: '/images/bg-image-4.jpg',
backgroundImage: '/images/backgrounds/tinte.jpg',
bgCenter: '#12301a',
bgEdge: '#05110a',
speed: 0.7,
@ -178,7 +178,7 @@ export const WAVE_PRESETS = [
{
id: 'builtin:moon',
name: 'Mondsichel',
description: 'Fast schwarz, wenige dünne helle Linien. Reduziert.',
description: 'Schiefertafel, wenige dünne helle Linien. Reduziert.',
preview: { ground: ['#161616', '#000000'], lines: ['#e8e8e8', '#8a8a8a'] },
floatingLines: {
lineMode: 'static',
@ -188,7 +188,7 @@ export const WAVE_PRESETS = [
lineThickness: 0.6,
spread: 0.035,
speed: 0.5,
backgroundImage: '/images/bg-image-8.jpg',
backgroundImage: '/images/backgrounds/kohle.jpg',
bgCenter: '#161616',
bgEdge: '#000000',
glowSize: 10,
@ -201,12 +201,12 @@ export const WAVE_PRESETS = [
{
id: 'builtin:neon',
name: 'Farbrausch',
description: 'Magenta und Blau, Punkte mit weißem Rand. Laut und fröhlich.',
description: 'Magenta und Blau über dem Nachglühen, Punkte mit weißem Rand.',
preview: { ground: ['#ff4fd8', '#2b3cff'], lines: ['#ffffff', '#ff6ad5'] },
floatingLines: {
lineMode: 'glow',
gradientStops: '#ffffff\n#ff6ad5\n#2b0a3a',
backgroundImage: '/images/bg-image-6.jpg',
backgroundImage: '/images/backgrounds/horizont.jpg',
bgCenter: '#3a0a5a',
bgEdge: '#0d0620',
speed: 1.3,

View file

@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { apiJson } from 'src/services/apiClient'
import { WAVE_PRESETS, findWavePreset, isBuiltInPresetId } from 'src/config/wavePresets'
import { normalizeBackgroundImage } from 'src/config/backgrounds'
import { useAuthStore } from 'stores/auth'
const STORAGE_KEY_PREFIX = 'thatsme-settings'
@ -89,6 +90,14 @@ function loadFromStorage(userId) {
}
}
// Defaults plus stored values; a background path from the retired test set
// is rewritten to its successor so the setting does not point at a 404.
function normalizeFloatingLines(stored) {
const merged = { ...FLOATING_LINES_DEFAULTS, ...(stored ?? {}) }
merged.backgroundImage = normalizeBackgroundImage(merged.backgroundImage)
return merged
}
function clone(value) {
return JSON.parse(JSON.stringify(value))
}
@ -140,10 +149,7 @@ export const useSettingsStore = defineStore('settings', () => {
const lastError = ref(null)
const theme = ref(initialSettings?.theme ?? 'light')
const floatingLines = ref({
...FLOATING_LINES_DEFAULTS,
...(initialSettings?.floatingLines ?? {})
})
const floatingLines = ref(normalizeFloatingLines(initialSettings?.floatingLines))
// App preferences
const appearance = ref(initialSettings?.appearance ?? 'system') // 'system' | 'light' | 'dark'
@ -188,10 +194,7 @@ export const useSettingsStore = defineStore('settings', () => {
function applySnapshot(snapshot) {
theme.value = snapshot?.theme ?? 'light'
floatingLines.value = {
...FLOATING_LINES_DEFAULTS,
...(snapshot?.floatingLines ?? {})
}
floatingLines.value = normalizeFloatingLines(snapshot?.floatingLines)
appearance.value = snapshot?.appearance ?? 'system'
accentColor.value = normalizeAccentColor(snapshot?.accentColor ?? 'gold')
language.value = snapshot?.language ?? 'de'

View file

@ -37,11 +37,18 @@ export function relativeLuminance(hex) {
* header (in "split" horizon mode it is literally the top half), with the
* centre colour as a smaller influence.
*
* @param {{ bgEdge?: string, bgCenter?: string, backgroundImage?: string, isDark?: boolean }} input
* A bundled photo says which tone it has (`imageTone`); an unknown photo
* (the user's own upload) falls back to the UI theme.
*
* @param {{ bgEdge?: string, bgCenter?: string, backgroundImage?: string, imageTone?: 'dark'|'light'|null, isDark?: boolean }} input
* @returns {'positive' | 'negative'}
*/
export function logoVariantForBackground({ bgEdge, bgCenter, backgroundImage, isDark = true } = {}) {
if (backgroundImage) return isDark ? 'negative' : 'positive'
export function logoVariantForBackground({ bgEdge, bgCenter, backgroundImage, imageTone = null, isDark = true } = {}) {
if (backgroundImage) {
if (imageTone === 'light') return 'positive'
if (imageTone === 'dark') return 'negative'
return isDark ? 'negative' : 'positive'
}
const edge = relativeLuminance(bgEdge)
const centre = relativeLuminance(bgCenter)

View file

@ -38,3 +38,10 @@ describe('brand — logo variant follows the LifeWave background', () => {
expect(logoVariantForBackground({ bgEdge: 'transparent', isDark: true })).toBe('negative')
})
})
describe('brand — bundled photos carry their own tone', () => {
it('lets a light photo win over a dark theme and the other way round', () => {
expect(logoVariantForBackground({ backgroundImage: '/images/backgrounds/papier.jpg', imageTone: 'light', isDark: true })).toBe('positive')
expect(logoVariantForBackground({ backgroundImage: '/images/backgrounds/tinte.jpg', imageTone: 'dark', isDark: false })).toBe('negative')
})
})

View file

@ -233,7 +233,7 @@ describe('events store — media types and the voice note (remote user)', () =>
await flush(3)
await eventsStore.deleteGhostVoiceNote('v1')
await flush(2)
await waitFor(() => api.callsTo(MEDIA_DELETE).length === 1, { label: 'DELETE sent' })
const deletes = api.callsTo(MEDIA_DELETE)
expect(deletes).toHaveLength(1)

View file

@ -23,6 +23,7 @@ vi.mock('src/services/apiClient', () => ({
import { BRAND_PRESET_ID, WAVE_PRESETS, findWavePreset, isBuiltInPresetId, presetPreviewStyle } from 'src/config/wavePresets'
import { FLOATING_LINES_DEFAULTS, useSettingsStore } from 'stores/settings'
import { relativeLuminance } from 'src/utils/brand'
import { BACKGROUND_IMAGES, normalizeBackgroundImage } from 'src/config/backgrounds'
// Vitest runs from the frontend directory.
const publicDir = resolve(process.cwd(), 'public')
@ -186,3 +187,30 @@ describe('settings store — accent colour defaults', () => {
expect(useSettingsStore().accentColor).toBe('grey')
})
})
describe('backgrounds — bundled set and legacy paths', () => {
it('ships every configured background with its thumbnail', () => {
expect(BACKGROUND_IMAGES.length).toBeGreaterThanOrEqual(10)
expect(new Set(BACKGROUND_IMAGES.map(image => image.id)).size).toBe(BACKGROUND_IMAGES.length)
for (const image of BACKGROUND_IMAGES) {
expect(existsSync(`${publicDir}${image.src}`), `${image.id} missing`).toBe(true)
expect(existsSync(`${publicDir}${image.thumb}`), `${image.id} thumb missing`).toBe(true)
expect(['dark', 'light']).toContain(image.tone)
}
})
it('rewrites a stored path from the retired test set and keeps everything else', () => {
expect(normalizeBackgroundImage('/images/bg-image-1.jpg')).toBe('/images/backgrounds/nachthimmel.jpg')
expect(normalizeBackgroundImage('/images/backgrounds/kohle.jpg')).toBe('/images/backgrounds/kohle.jpg')
expect(normalizeBackgroundImage('/settings/media/background?v=1')).toBe('/settings/media/background?v=1')
expect(normalizeBackgroundImage('')).toBe('')
expect(normalizeBackgroundImage(undefined)).toBe('')
})
it('the settings store applies the rewrite when loading', () => {
setActivePinia(createPinia())
api = createApiRouter()
localStorage.setItem('thatsme-settings:guest', JSON.stringify({ floatingLines: { backgroundImage: '/images/bg-image-3.jpg' } }))
expect(useSettingsStore().floatingLines.backgroundImage).toBe('/images/backgrounds/nebel.jpg')
})
})