feat(articles): bulk-import schema + plan (Phase 1)

Three new sync-tracked Dexie tables under the articles appId:

  articleImportJobs     — job header (counters, status, lease metadata).
  articleImportItems    — one row per URL in a job, state-machine driven.
  articleExtractPickup  — short-lived server→client handoff inbox.

URL stays plaintext on items by necessity — the server-worker reads it
without master-key access, same rationale as articles.originalUrl. The
extracted article eventually lands encrypted in the existing `articles`
table; bulk-import rows hold only pointers.

Plan: docs/plans/articles-bulk-import.md (full architecture, 7 phases,
test matrix, edge-cases). Phase 2 already shipped in 5535f2da4 (worker);
this commit lays the schema underneath it.

Originally committed as b2f4e8314, lost during a parallel reset, here
restored via cherry-pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 22:11:51 +02:00
parent 5535f2da48
commit 7bca16dfa7
59 changed files with 785 additions and 4249 deletions

View file

@ -1,199 +0,0 @@
<script lang="ts">
import { SCENE, LAKES, RIVERS } from './data/layout';
import { createMockEcosystem } from './data/mockData';
import type { AppData } from './data/types';
import Background from './terrain/Background.svelte';
import Terrain from './terrain/Terrain.svelte';
import WaterBody from './terrain/WaterBody.svelte';
import RiverFlow from './water/RiverFlow.svelte';
import PlantFactory from './plants/PlantFactory.svelte';
import Ambient from './atmosphere/Ambient.svelte';
import PlantTooltip from './ui/PlantTooltip.svelte';
import DetailPanel from './ui/DetailPanel.svelte';
import { onMount } from 'svelte';
let apps = $state(createMockEcosystem());
let hour = $state(new Date().getHours() + new Date().getMinutes() / 60);
onMount(() => {
// Update time every minute
const interval = setInterval(() => {
hour = new Date().getHours() + new Date().getMinutes() / 60;
}, 60000);
return () => clearInterval(interval);
});
let selectedApp = $state<AppData | null>(null);
let hoveredApp = $state<AppData | null>(null);
let tooltipPos = $state({ x: 0, y: 0 });
// Pan & zoom state
let svgEl: SVGSVGElement | undefined = $state();
let viewBox = $state({ x: 0, y: 0, w: SCENE.width as number, h: SCENE.height as number });
let isPanning = $state(false);
let hasPanned = $state(false);
let panStart = $state({ x: 0, y: 0 });
let panViewBoxStart = $state({ x: 0, y: 0 });
function handleWheel(e: WheelEvent) {
e.preventDefault();
const scaleFactor = e.deltaY > 0 ? 1.08 : 0.92;
const rect = svgEl?.getBoundingClientRect();
if (!rect) return;
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
const px = viewBox.x + mx * viewBox.w;
const py = viewBox.y + my * viewBox.h;
const nw = Math.max(400, Math.min(SCENE.width * 2, viewBox.w * scaleFactor));
const nh = Math.max(225, Math.min(SCENE.height * 2, viewBox.h * scaleFactor));
viewBox = { x: px - mx * nw, y: py - my * nh, w: nw, h: nh };
}
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return;
isPanning = true;
hasPanned = false;
panStart = { x: e.clientX, y: e.clientY };
panViewBoxStart = { x: viewBox.x, y: viewBox.y };
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent) {
if (!isPanning || !svgEl) return;
const rect = svgEl.getBoundingClientRect();
const dx = ((e.clientX - panStart.x) / rect.width) * viewBox.w;
const dy = ((e.clientY - panStart.y) / rect.height) * viewBox.h;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) hasPanned = true;
viewBox = { ...viewBox, x: panViewBoxStart.x - dx, y: panViewBoxStart.y - dy };
}
function handlePointerUp() {
isPanning = false;
}
function handleAppClick(app: AppData) {
if (hasPanned) return;
selectedApp = app;
}
function handleAppHover(app: AppData, e: MouseEvent) {
hoveredApp = app;
tooltipPos = { x: e.clientX, y: e.clientY };
}
function handleAppHoverMove(e: MouseEvent) {
tooltipPos = { x: e.clientX, y: e.clientY };
}
function handleAppLeave() {
hoveredApp = null;
}
function closePanel() {
selectedApp = null;
}
function resetView() {
viewBox = { x: 0, y: 0, w: SCENE.width, h: SCENE.height };
}
let viewBoxStr = $derived(`${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`);
</script>
<div
class="relative overflow-hidden rounded-xl"
style="width: 100%; height: 100%; min-height: 500px; background-color: #e0f2fe;"
>
<!-- Reset zoom button -->
<button
type="button"
onclick={resetView}
class="absolute right-3 top-3 z-10 rounded-lg bg-white/80 px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm backdrop-blur-sm transition-colors hover:bg-white"
>
Reset View
</button>
<!-- Title overlay -->
<div class="absolute left-4 top-3 z-10">
<h2 class="text-lg font-semibold text-slate-700/80" style="font-family: serif;">
Mana Seenplatte
</h2>
<p class="text-xs text-slate-500/70">Ecosystem Observatory</p>
</div>
<!-- Legend -->
<div
class="absolute bottom-3 left-3 z-10 flex gap-3 rounded-lg bg-white/70 px-3 py-2 text-[10px] text-slate-600 backdrop-blur-sm"
>
<span class="flex items-center gap-1">
<svg width="12" height="12"><circle cx="6" cy="8" r="3" fill="#2D6B30" /></svg>
Mature
</span>
<span class="flex items-center gap-1">
<svg width="12" height="12"><circle cx="6" cy="8" r="3" fill="#3E8B42" /></svg>
Production
</span>
<span class="flex items-center gap-1">
<svg width="12" height="12"><circle cx="6" cy="8" r="3" fill="#7DB86A" /></svg>
Beta
</span>
<span class="flex items-center gap-1">
<svg width="12" height="12"><circle cx="6" cy="8" r="3" fill="#C4B848" /></svg>
Alpha
</span>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<svg
bind:this={svgEl}
viewBox={viewBoxStr}
preserveAspectRatio="xMidYMid meet"
xmlns="http://www.w3.org/2000/svg"
style="width: 100%; height: 100%; display: block; cursor: {isPanning ? 'grabbing' : 'grab'};"
onwheel={handleWheel}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointerleave={handlePointerUp}
>
<!-- Layer 1: Sky + Mountains -->
<Background {hour} />
<!-- Layer 2: Terrain (meadows, shores) -->
<Terrain />
<!-- Layer 3: Rivers (behind lakes) -->
{#each RIVERS as river}
<RiverFlow {river} />
{/each}
<!-- Layer 4: Lakes -->
{#each LAKES as lake}
<WaterBody {lake} />
{/each}
<!-- Layer 5: Ambient creatures -->
<Ambient {hour} />
<!-- Layer 6: Plants (apps) sorted by y-position for depth -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#each apps.toSorted((a, b) => a.position.y - b.position.y) as app (app.id)}
<g
onmouseenter={(e) => handleAppHover(app, e)}
onmousemove={handleAppHoverMove}
onmouseleave={handleAppLeave}
>
<PlantFactory {app} onclick={() => handleAppClick(app)} />
</g>
{/each}
</svg>
<!-- Tooltip (HTML overlay, positioned via mouse coordinates) -->
{#if hoveredApp && !selectedApp}
<PlantTooltip app={hoveredApp} x={tooltipPos.x} y={tooltipPos.y} />
{/if}
</div>
<!-- Detail Panel (outside the SVG container for proper positioning) -->
<DetailPanel app={selectedApp} onclose={closePanel} />

View file

@ -1,126 +0,0 @@
<script lang="ts">
import { SCENE } from '../data/layout';
let { hour = 12 }: { hour?: number } = $props();
let isNight = $derived(hour < 6 || hour > 20.5);
// Bird flight paths (simple arcs)
const birds = [
{ startX: -50, startY: 180, endX: SCENE.width + 50, endY: 150, dur: 25, delay: 0 },
{ startX: -30, startY: 220, endX: SCENE.width + 30, endY: 200, dur: 30, delay: 8 },
{ startX: SCENE.width + 40, startY: 160, endX: -40, endY: 190, dur: 28, delay: 15 },
];
// Firefly positions for night mode
const fireflies = Array.from({ length: 15 }, (_, i) => ({
cx: ((i * 137 + 80) % (SCENE.width - 200)) + 100,
cy: 450 + ((i * 89) % 350),
dur: 3 + (i % 4),
delay: (i % 6) * 0.8,
}));
// Dragonfly paths over lakes
const dragonflies = [
{ cx: 790, cy: 470, rx: 40, ry: 20, dur: 6 },
{ cx: 340, cy: 410, rx: 25, ry: 15, dur: 5 },
{ cx: 760, cy: 650, rx: 35, ry: 18, dur: 7 },
];
</script>
<!-- Birds (daytime only) -->
{#if !isNight}
{#each birds as bird}
<g opacity="0.5">
<!-- Simple V-shape bird -->
<g>
<animateMotion
dur="{bird.dur}s"
begin="{bird.delay}s"
repeatCount="indefinite"
path="M{bird.startX},{bird.startY} Q{SCENE.width / 2},{bird.startY -
40} {bird.endX},{bird.endY}"
/>
<path
d="M-5,0 L0,-2 L5,0"
fill="none"
stroke="#3A4A5A"
stroke-width="1"
stroke-linecap="round"
/>
</g>
</g>
{/each}
<!-- Dragonflies over lakes -->
{#each dragonflies as df}
<g opacity="0.4">
<circle r="1.5" fill="#60A0C0">
<animateMotion
dur="{df.dur}s"
repeatCount="indefinite"
path="M{df.cx},{df.cy} Q{df.cx + df.rx},{df.cy - df.ry} {df.cx},{df.cy -
df.ry * 2} Q{df.cx - df.rx},{df.cy - df.ry} {df.cx},{df.cy}"
/>
<animate
attributeName="opacity"
values="0.3;0.7;0.3"
dur="{df.dur / 2}s"
repeatCount="indefinite"
/>
</circle>
</g>
{/each}
{/if}
<!-- Fireflies (night only) -->
{#if isNight}
{#each fireflies as ff}
<circle cx={ff.cx} cy={ff.cy} r="2" fill="#E8E060">
<animate
attributeName="opacity"
values="0;0.8;0.8;0"
dur="{ff.dur}s"
begin="{ff.delay}s"
repeatCount="indefinite"
/>
<animate
attributeName="cx"
values="{ff.cx};{ff.cx + 8};{ff.cx - 5};{ff.cx}"
dur="{ff.dur * 2}s"
begin="{ff.delay}s"
repeatCount="indefinite"
/>
<animate
attributeName="cy"
values="{ff.cy};{ff.cy - 10};{ff.cy - 5};{ff.cy}"
dur="{ff.dur * 1.5}s"
begin="{ff.delay}s"
repeatCount="indefinite"
/>
</circle>
{/each}
{/if}
<!-- Butterflies near plants (daytime, subtle) -->
{#if !isNight}
<g opacity="0.35">
<g>
<animateMotion
dur="12s"
repeatCount="indefinite"
path="M730,400 Q750,380 780,395 Q800,385 790,405 Q770,410 730,400"
/>
<path d="M0,0 L-3,-3 L0,-1 L3,-3 Z" fill="#D080C0" />
</g>
<g>
<animateMotion
dur="10s"
begin="4s"
repeatCount="indefinite"
path="M350,540 Q370,520 390,535 Q380,550 360,555 Q340,550 350,540"
/>
<path d="M0,0 L-3,-3 L0,-1 L3,-3 Z" fill="#E0A040" />
</g>
</g>
{/if}

View file

@ -1,83 +0,0 @@
<script lang="ts">
import { SCENE } from '../data/layout';
let { hour = new Date().getHours() + new Date().getMinutes() / 60 }: { hour?: number } = $props();
// Interpolate between time-of-day color presets
const presets = [
{ h: 0, top: '#0B1026', bottom: '#1A2444' }, // midnight
{ h: 5, top: '#1A1B3A', bottom: '#2D3458' }, // pre-dawn
{ h: 6.5, top: '#4A3060', bottom: '#E8956A' }, // sunrise
{ h: 8, top: '#6B9FD4', bottom: '#E0E8F0' }, // morning
{ h: 12, top: '#87CEEB', bottom: '#E0F0FF' }, // noon
{ h: 17, top: '#87CEEB', bottom: '#E0F0FF' }, // afternoon
{ h: 19, top: '#C45030', bottom: '#F0B860' }, // sunset
{ h: 20.5, top: '#2C1654', bottom: '#4A3060' }, // dusk
{ h: 22, top: '#0B1026', bottom: '#1A2444' }, // night
{ h: 24, top: '#0B1026', bottom: '#1A2444' }, // midnight wrap
];
function lerp(a: string, b: string, t: number): string {
const ah = parseInt(a.slice(1), 16);
const bh = parseInt(b.slice(1), 16);
const r = Math.round(((ah >> 16) & 0xff) * (1 - t) + ((bh >> 16) & 0xff) * t);
const g = Math.round(((ah >> 8) & 0xff) * (1 - t) + ((bh >> 8) & 0xff) * t);
const bl = Math.round((ah & 0xff) * (1 - t) + (bh & 0xff) * t);
return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, '0')}`;
}
function getSkyColors(h: number) {
for (let i = 0; i < presets.length - 1; i++) {
if (h >= presets[i].h && h < presets[i + 1].h) {
const t = (h - presets[i].h) / (presets[i + 1].h - presets[i].h);
return {
top: lerp(presets[i].top, presets[i + 1].top, t),
bottom: lerp(presets[i].bottom, presets[i + 1].bottom, t),
};
}
}
return { top: presets[0].top, bottom: presets[0].bottom };
}
let colors = $derived(getSkyColors(hour));
let isNight = $derived(hour < 6 || hour > 20.5);
let isDusk = $derived(hour > 18.5 && hour < 20.5);
let gradientId = 'sky-gradient-dynamic';
</script>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color={colors.top} />
<stop offset="100%" stop-color={colors.bottom} />
</linearGradient>
</defs>
<rect x="0" y="0" width={SCENE.width} height="360" fill="url(#{gradientId})" />
<!-- Stars (visible at night) -->
{#if isNight}
<g opacity={hour < 5 ? 0.7 : 0.4}>
{#each Array(35) as _, i}
{@const sx = (i * 137 + 42) % SCENE.width}
{@const sy = (i * 89 + 17) % 250}
{@const sr = 0.5 + (i % 3) * 0.4}
<circle cx={sx} cy={sy} r={sr} fill="white">
<animate
attributeName="opacity"
values="0.3;0.9;0.3"
dur="{2 + (i % 4)}s"
begin="{(i % 7) * 0.5}s"
repeatCount="indefinite"
/>
</circle>
{/each}
</g>
{/if}
<!-- Sun glow (during golden hours) -->
{#if isDusk}
<circle cx="1400" cy="260" r="80" fill="#F0A040" opacity="0.15">
<animate attributeName="opacity" values="0.1;0.2;0.1" dur="4s" repeatCount="indefinite" />
</circle>
<circle cx="1400" cy="260" r="30" fill="#F0C860" opacity="0.3" />
{/if}

View file

@ -1,84 +0,0 @@
// Natural color palette for the Seenplatte ecosystem
export const sky = {
dayTop: '#87CEEB',
dayBottom: '#E0F0FF',
duskTop: '#2C1654',
duskBottom: '#E8956A',
nightTop: '#0B1026',
nightBottom: '#1A2444',
} as const;
export const mountains = {
far: '#8BA4B8',
mid: '#6B8A9E',
near: '#4A7085',
snow: '#E8F0F4',
} as const;
export const water = {
shallow: '#7EC8D9',
mid: '#4BA3B5',
deep: '#2A7A8C',
veryDeep: '#1A5666',
river: '#5BB5C5',
highlight: '#A8E4F0',
foam: '#E8F6FA',
} as const;
export const terrain = {
meadow: '#7DB86A',
meadowLight: '#96CC86',
meadowDark: '#5E9A4D',
shore: '#C4B48A',
shoreDark: '#A89870',
path: '#D4C4A0',
rock: '#8C8C80',
rockLight: '#A8A89C',
} as const;
export const vegetation = {
// Tree health colors based on ManaScore
healthyDark: '#2D6B30',
healthy: '#3E8B42',
healthyLight: '#5AAB5E',
moderate: '#7DB86A',
warning: '#C4B848',
stressed: '#D4944A',
critical: '#B85C4A',
// Specific plant colors
trunk: '#6B4E3D',
trunkLight: '#8B6E5D',
reed: '#7DA868',
reedDark: '#5A8548',
lilyPad: '#4A8B50',
lilyFlower: '#E8B0D0',
lilyFlowerCenter: '#F0D060',
moss: '#5A8B4A',
mossLight: '#78A868',
sprout: '#90C880',
sproutStake: '#A89070',
} as const;
/**
* Get tree crown color based on ManaScore (0-100)
*/
export function getHealthColor(score: number): string {
if (score >= 85) return vegetation.healthyDark;
if (score >= 70) return vegetation.healthy;
if (score >= 55) return vegetation.moderate;
if (score >= 40) return vegetation.warning;
if (score >= 25) return vegetation.stressed;
return vegetation.critical;
}
/**
* Get water clarity based on error rate (0 = clear, 1 = murky)
*/
export function getWaterColor(clarity: number): string {
if (clarity > 0.8) return water.shallow;
if (clarity > 0.5) return water.mid;
if (clarity > 0.2) return water.deep;
return water.veryDeep;
}

View file

@ -1,171 +0,0 @@
import type { LakeData, RiverData } from './types';
// Scene dimensions (SVG viewBox)
export const SCENE = {
width: 1600,
height: 900,
viewBox: '0 0 1600 900',
} as const;
// Mountain layer paths (3 layers, back to front)
export const MOUNTAIN_PATHS = {
far: 'M0,280 Q200,180 400,240 Q500,210 600,250 Q750,160 900,220 Q1050,180 1200,230 Q1350,170 1500,210 L1600,240 L1600,350 L0,350 Z',
mid: 'M0,310 Q150,250 300,290 Q450,240 550,280 Q700,220 850,270 Q1000,230 1150,275 Q1300,240 1450,265 L1600,280 L1600,380 L0,380 Z',
near: 'M0,340 Q100,300 250,330 Q400,290 500,320 Q650,280 800,315 Q950,285 1100,320 Q1250,295 1400,310 L1600,320 L1600,400 L0,400 Z',
} as const;
// Lake definitions with organic SVG paths
export const LAKES: LakeData[] = [
{
id: 'auth',
name: 'auth',
label: 'Zentralsee',
path: 'M680,440 Q720,410 790,415 Q860,420 890,445 Q910,470 900,500 Q885,535 850,550 Q800,565 750,558 Q700,550 680,525 Q660,500 665,470 Q668,450 680,440 Z',
color: '#4BA3B5',
colorDeep: '#2A7A8C',
clarity: 1,
level: 0.8,
position: { x: 790, y: 485 },
},
{
id: 'redis',
name: 'redis',
label: 'Bergsee',
path: 'M280,390 Q310,370 350,375 Q390,380 405,400 Q415,420 405,440 Q390,455 355,460 Q320,462 295,450 Q275,435 270,415 Q268,398 280,390 Z',
color: '#7EC8D9',
colorDeep: '#4BA3B5',
clarity: 1,
level: 0.9,
position: { x: 340, y: 420 },
},
{
id: 'minio',
name: 'minio',
label: 'Stausee',
path: 'M1180,400 Q1220,375 1280,380 Q1340,385 1370,410 Q1390,435 1380,465 Q1360,490 1320,500 Q1270,510 1220,502 Q1185,492 1170,468 Q1158,445 1165,420 Q1170,405 1180,400 Z',
color: '#5BB5C5',
colorDeep: '#3A8A9A',
clarity: 0.9,
level: 0.7,
position: { x: 1275, y: 440 },
},
{
id: 'db-left',
name: 'postgres-left',
label: 'Waldsee West',
path: 'M180,580 Q220,555 280,560 Q340,565 365,590 Q380,615 370,640 Q355,665 310,675 Q260,682 215,670 Q180,658 165,635 Q155,612 165,595 Q170,582 180,580 Z',
color: '#3A8A9A',
colorDeep: '#1A5666',
clarity: 0.85,
level: 0.75,
position: { x: 270, y: 620 },
},
{
id: 'db-center',
name: 'postgres-center',
label: 'Waldsee Mitte',
path: 'M650,620 Q700,595 770,600 Q840,605 870,635 Q890,660 878,690 Q862,718 810,730 Q760,738 710,730 Q660,720 640,695 Q625,670 632,645 Q638,628 650,620 Z',
color: '#3A8A9A',
colorDeep: '#1A5666',
clarity: 0.9,
level: 0.8,
position: { x: 760, y: 665 },
},
{
id: 'db-right',
name: 'postgres-right',
label: 'Waldsee Ost',
path: 'M1120,590 Q1160,568 1220,572 Q1280,578 1305,600 Q1322,625 1315,652 Q1300,678 1255,690 Q1210,698 1165,688 Q1130,678 1115,655 Q1102,632 1108,610 Q1112,595 1120,590 Z',
color: '#3A8A9A',
colorDeep: '#1A5666',
clarity: 0.85,
level: 0.75,
position: { x: 1215, y: 630 },
},
];
// Rivers connecting the lakes
export const RIVERS: RiverData[] = [
{
id: 'redis-to-auth',
from: 'redis',
to: 'auth',
path: 'M405,430 Q480,435 540,440 Q600,445 665,460',
flowSpeed: 0.8,
width: 8,
},
{
id: 'minio-to-auth',
from: 'minio',
to: 'auth',
path: 'M1170,450 Q1100,455 1020,460 Q940,465 900,475',
flowSpeed: 0.6,
width: 8,
},
{
id: 'auth-to-db-left',
from: 'auth',
to: 'db-left',
path: 'M710,555 Q620,570 520,580 Q420,590 365,595',
flowSpeed: 0.7,
width: 10,
},
{
id: 'auth-to-db-center',
from: 'auth',
to: 'db-center',
path: 'M790,560 Q785,580 778,600 Q770,610 765,620',
flowSpeed: 0.7,
width: 10,
},
{
id: 'auth-to-db-right',
from: 'auth',
to: 'db-right',
path: 'M870,548 Q960,565 1040,575 Q1080,582 1115,592',
flowSpeed: 0.7,
width: 10,
},
{
id: 'inlet',
from: 'source',
to: 'auth',
path: 'M790,350 Q792,370 790,390 Q788,410 790,420',
flowSpeed: 0.9,
width: 6,
},
];
// App positions around the lakes
export const APP_POSITIONS: Record<string, { x: number; y: number; lakeId: string }> = {
// Around Zentralsee (auth) - core/mature apps
mana: { x: 730, y: 410, lakeId: 'auth' },
chat: { x: 860, y: 420, lakeId: 'auth' },
picture: { x: 910, y: 480, lakeId: 'auth' },
presi: { x: 660, y: 490, lakeId: 'auth' },
// Around Waldsee West (db-left)
calendar: { x: 170, y: 560, lakeId: 'db-left' },
todo: { x: 330, y: 555, lakeId: 'db-left' },
contacts: { x: 370, y: 630, lakeId: 'db-left' },
storage: { x: 160, y: 650, lakeId: 'db-left' },
// Around Waldsee Mitte (db-center)
quotes: { x: 640, y: 600, lakeId: 'db-center' },
music: { x: 850, y: 610, lakeId: 'db-center' },
clock: { x: 880, y: 680, lakeId: 'db-center' },
food: { x: 650, y: 720, lakeId: 'db-center' },
// Around Waldsee Ost (db-right)
photos: { x: 1110, y: 575, lakeId: 'db-right' },
skilltree: { x: 1310, y: 590, lakeId: 'db-right' },
context: { x: 1320, y: 660, lakeId: 'db-right' },
plants: { x: 1115, y: 675, lakeId: 'db-right' },
// Around Bergsee (redis) - lightweight/cache
traces: { x: 400, y: 385, lakeId: 'redis' },
// Around Stausee (minio) - storage-heavy
cards: { x: 1180, y: 385, lakeId: 'minio' },
questions: { x: 1370, y: 400, lakeId: 'minio' },
};

View file

@ -1,352 +0,0 @@
import type { AppData, AppStatus, PlantType, CategoryScores } from './types';
import { APP_POSITIONS } from './layout';
interface AppDefinition {
id: string;
displayName: string;
score: number;
status: AppStatus;
categories: CategoryScores;
previousScore?: number;
}
function getPlantType(status: AppStatus, score: number): PlantType {
if (score >= 85) return 'oak';
if (score >= 70) return 'birch';
if (status === 'beta' && score >= 55) return 'youngTree';
if (status === 'beta') return 'reed';
if (status === 'alpha') return 'sprout';
if (status === 'prototype') return 'sprout';
return 'youngTree';
}
// Real ManaScore data from 2026-03-24 audits
const APP_DEFINITIONS: AppDefinition[] = [
{
id: 'calendar',
displayName: 'Calendar',
score: 97,
previousScore: 82,
status: 'mature',
categories: {
backend: 95,
frontend: 96,
database: 92,
testing: 90,
deployment: 92,
documentation: 98,
security: 92,
ux: 95,
},
},
{
id: 'todo',
displayName: 'Todo',
score: 96,
previousScore: 80,
status: 'mature',
categories: {
backend: 94,
frontend: 95,
database: 88,
testing: 90,
deployment: 92,
documentation: 95,
security: 90,
ux: 94,
},
},
{
id: 'contacts',
displayName: 'Contacts',
score: 94,
status: 'production',
categories: {
backend: 92,
frontend: 90,
database: 88,
testing: 88,
deployment: 90,
documentation: 92,
security: 85,
ux: 85,
},
},
{
id: 'mana',
displayName: 'Mana',
score: 88,
status: 'production',
categories: {
backend: 55,
frontend: 90,
database: 70,
testing: 72,
deployment: 90,
documentation: 88,
security: 80,
ux: 92,
},
},
{
id: 'presi',
displayName: 'Presi',
score: 86,
status: 'mature',
categories: {
backend: 90,
frontend: 82,
database: 85,
testing: 82,
deployment: 75,
documentation: 90,
security: 85,
ux: 82,
},
},
{
id: 'storage',
displayName: 'Storage',
score: 84,
previousScore: 55,
status: 'production',
categories: {
backend: 88,
frontend: 84,
database: 82,
testing: 78,
deployment: 65,
documentation: 78,
security: 78,
ux: 75,
},
},
{
id: 'chat',
displayName: 'Chat',
score: 82,
status: 'production',
categories: {
backend: 90,
frontend: 82,
database: 95,
testing: 60,
deployment: 92,
documentation: 85,
security: 82,
ux: 80,
},
},
{
id: 'picture',
displayName: 'Picture',
score: 81,
status: 'production',
categories: {
backend: 90,
frontend: 80,
database: 92,
testing: 55,
deployment: 75,
documentation: 78,
security: 80,
ux: 78,
},
},
{
id: 'music',
displayName: 'Music',
score: 80,
status: 'beta',
categories: {
backend: 90,
frontend: 78,
database: 90,
testing: 65,
deployment: 85,
documentation: 80,
security: 78,
ux: 60,
},
},
{
id: 'food',
displayName: 'Food',
score: 63,
status: 'beta',
categories: {
backend: 78,
frontend: 62,
database: 80,
testing: 58,
deployment: 40,
documentation: 85,
security: 68,
ux: 55,
},
},
{
id: 'photos',
displayName: 'Photos',
score: 62,
status: 'beta',
categories: {
backend: 82,
frontend: 65,
database: 72,
testing: 0,
deployment: 85,
documentation: 78,
security: 65,
ux: 55,
},
},
{
id: 'quotes',
displayName: 'Quotes',
score: 62,
status: 'beta',
categories: {
backend: 72,
frontend: 78,
database: 75,
testing: 0,
deployment: 92,
documentation: 20,
security: 70,
ux: 75,
},
},
{
id: 'context',
displayName: 'Context',
score: 60,
status: 'beta',
categories: {
backend: 75,
frontend: 75,
database: 82,
testing: 55,
deployment: 25,
documentation: 85,
security: 68,
ux: 65,
},
},
{
id: 'clock',
displayName: 'Clock',
score: 58,
status: 'beta',
categories: {
backend: 75,
frontend: 70,
database: 72,
testing: 0,
deployment: 88,
documentation: 10,
security: 60,
ux: 55,
},
},
{
id: 'skilltree',
displayName: 'SkillTree',
score: 58,
status: 'beta',
categories: {
backend: 65,
frontend: 68,
database: 72,
testing: 28,
deployment: 55,
documentation: 62,
security: 65,
ux: 72,
},
},
{
id: 'plants',
displayName: 'Plants',
score: 50,
status: 'alpha',
categories: {
backend: 68,
frontend: 58,
database: 70,
testing: 0,
deployment: 45,
documentation: 62,
security: 55,
ux: 50,
},
},
{
id: 'cards',
displayName: 'Cards',
score: 48,
status: 'alpha',
categories: {
backend: 50,
frontend: 65,
database: 30,
testing: 18,
deployment: 80,
documentation: 25,
security: 55,
ux: 68,
},
},
{
id: 'questions',
displayName: 'Questions',
score: 48,
status: 'alpha',
categories: {
backend: 88,
frontend: 62,
database: 78,
testing: 0,
deployment: 10,
documentation: 72,
security: 55,
ux: 55,
},
},
{
id: 'traces',
displayName: 'Traces',
score: 35,
status: 'alpha',
categories: {
backend: 72,
frontend: 10,
database: 70,
testing: 0,
deployment: 8,
documentation: 45,
security: 55,
ux: 35,
},
},
];
export function createMockEcosystem(): AppData[] {
return APP_DEFINITIONS.map((def) => {
const pos = APP_POSITIONS[def.id] || { x: 800, y: 500, lakeId: 'auth' };
const trend = def.previousScore ? def.score - def.previousScore : 0;
return {
id: def.id,
name: def.id,
displayName: def.displayName,
score: def.score,
status: def.status,
health: 'up' as const,
plantType: getPlantType(def.status, def.score),
categories: def.categories,
trend,
lakeId: pos.lakeId,
position: { x: pos.x, y: pos.y },
};
});
}

View file

@ -1,69 +0,0 @@
export type PlantType =
| 'oak'
| 'birch'
| 'youngTree'
| 'reed'
| 'waterLily'
| 'moss'
| 'shrub'
| 'sprout'
| 'stump'
| 'swampCluster';
export type AppStatus = 'prototype' | 'alpha' | 'beta' | 'production' | 'mature';
export type HealthStatus = 'up' | 'degraded' | 'down' | 'unknown';
export interface CategoryScores {
backend: number;
frontend: number;
database: number;
testing: number;
deployment: number;
documentation: number;
security: number;
ux: number;
}
export interface AppData {
id: string;
name: string;
displayName: string;
score: number;
status: AppStatus;
health: HealthStatus;
plantType: PlantType;
categories: CategoryScores;
trend: number;
lakeId: string;
position: { x: number; y: number };
}
export interface LakeData {
id: string;
name: string;
label: string;
path: string;
color: string;
colorDeep: string;
clarity: number; // 0-1, 1 = crystal clear
level: number; // 0-1, normalized fill level
position: { x: number; y: number };
}
export interface RiverData {
id: string;
from: string;
to: string;
path: string;
flowSpeed: number; // 0-1
width: number;
}
export interface EcosystemState {
apps: AppData[];
lakes: LakeData[];
rivers: RiverData[];
timeOfDay: number; // 0-24
systemHealth: 'sunny' | 'cloudy' | 'stormy';
}

View file

@ -1,63 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let spread = $derived(10 + (app.score / 100) * 15);
function mossPatches(seed: string) {
const patches = [];
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
}
const count = 4 + (Math.abs(hash) % 5);
for (let i = 0; i < count; i++) {
hash = ((hash << 5) - hash + i * 17) | 0;
patches.push({
cx: ((hash & 0xff) / 255 - 0.5) * spread * 2,
cy: (((hash >> 8) & 0xff) / 255 - 0.5) * spread * 0.8,
rx: 3 + ((hash >> 16) & 0x7),
ry: 2 + ((hash >> 20) & 0x5),
light: i % 3 === 0,
});
}
return patches;
}
let patches = $derived(mossPatches(app.id));
</script>
<g
class="plant-moss"
transform="translate({app.position.x}, {app.position.y})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
{#each patches as patch}
<ellipse
cx={patch.cx}
cy={patch.cy}
rx={patch.rx}
ry={patch.ry}
fill={patch.light ? vegetation.mossLight : vegetation.moss}
opacity="0.6"
/>
{/each}
<!-- Label -->
<text
y={spread * 0.5 + 12}
text-anchor="middle"
font-size="8"
font-family="system-ui, sans-serif"
fill="#5A7060"
opacity="0.5"
>
{app.displayName}
</text>
</g>

View file

@ -1,25 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import TreePlant from './TreePlant.svelte';
import ReedPlant from './ReedPlant.svelte';
import WaterLily from './WaterLily.svelte';
import Sprout from './Sprout.svelte';
import MossCluster from './MossCluster.svelte';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
</script>
{#if app.plantType === 'oak' || app.plantType === 'birch' || app.plantType === 'youngTree'}
<TreePlant {app} {onclick} />
{:else if app.plantType === 'reed'}
<ReedPlant {app} {onclick} />
{:else if app.plantType === 'waterLily'}
<WaterLily {app} {onclick} />
{:else if app.plantType === 'sprout'}
<Sprout {app} {onclick} />
{:else if app.plantType === 'moss'}
<MossCluster {app} {onclick} />
{:else}
<!-- Fallback: render as sprout -->
<Sprout {app} {onclick} />
{/if}

View file

@ -1,98 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let height = $derived(25 + (app.score / 100) * 30); // 25-55px tall
let stalkCount = $derived(3 + Math.floor(app.score / 20)); // 3-8 stalks
let animDelay = $derived(`${(app.id.charCodeAt(0) % 8) * 0.4}s`);
function stalks(count: number) {
const result = [];
for (let i = 0; i < count; i++) {
const offset = (i - (count - 1) / 2) * 5;
const h = height + (i % 3) * 5 - 5;
const curve = offset * 0.5;
result.push({ offset, h, curve, hasBulrush: i % 2 === 0 });
}
return result;
}
let stalkData = $derived(stalks(stalkCount));
</script>
<g
class="plant-reed"
transform="translate({app.position.x}, {app.position.y})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
<g>
<animateTransform
attributeName="transform"
type="rotate"
values="-2,0,0; 2,0,0; -2,0,0"
dur="4s"
begin={animDelay}
repeatCount="indefinite"
/>
{#each stalkData as stalk}
<!-- Stalk -->
<path
d="M{stalk.offset},0 Q{stalk.offset + stalk.curve},{-stalk.h * 0.5} {stalk.offset +
stalk.curve * 1.2},{-stalk.h}"
fill="none"
stroke={vegetation.reed}
stroke-width="2"
stroke-linecap="round"
/>
<!-- Bulrush head (on some stalks) -->
{#if stalk.hasBulrush}
<ellipse
cx={stalk.offset + stalk.curve * 1.2}
cy={-stalk.h - 5}
rx="2.5"
ry="6"
fill={vegetation.reedDark}
/>
{/if}
{/each}
<!-- Leaf blades at base -->
<path
d="M-4,0 Q-10,-15 -8,-25"
fill="none"
stroke={vegetation.reed}
stroke-width="2.5"
stroke-linecap="round"
opacity="0.7"
/>
<path
d="M4,0 Q12,-12 10,-22"
fill="none"
stroke={vegetation.reed}
stroke-width="2.5"
stroke-linecap="round"
opacity="0.7"
/>
</g>
<!-- Label -->
<text
y="15"
text-anchor="middle"
font-size="9"
font-family="system-ui, sans-serif"
font-weight="500"
fill="#3A5040"
opacity="0.7"
>
{app.displayName}
</text>
</g>

View file

@ -1,81 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let height = $derived(12 + (app.score / 100) * 18); // 12-30px
let animDelay = $derived(`${(app.id.charCodeAt(0) % 5) * 0.6}s`);
</script>
<g
class="plant-sprout"
transform="translate({app.position.x}, {app.position.y})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
<g>
<animateTransform
attributeName="transform"
type="rotate"
values="-3,0,0; 3,0,0; -3,0,0"
dur="3.5s"
begin={animDelay}
repeatCount="indefinite"
/>
<!-- Support stake -->
<line
x1="3"
y1="2"
x2="3"
y2={-height - 5}
stroke={vegetation.sproutStake}
stroke-width="1.5"
stroke-linecap="round"
/>
<!-- Stem -->
<path
d="M0,0 Q-1,{-height * 0.5} 0,{-height}"
fill="none"
stroke={vegetation.sprout}
stroke-width="2"
stroke-linecap="round"
/>
<!-- Two small leaves -->
<path
d="M0,{-height * 0.6} Q-8,{-height * 0.7} -6,{-height * 0.85}"
fill={vegetation.sprout}
stroke={vegetation.sprout}
stroke-width="1"
opacity="0.8"
/>
<path
d="M0,{-height * 0.75} Q6,{-height * 0.8} 5,{-height * 0.95}"
fill={vegetation.sprout}
stroke={vegetation.sprout}
stroke-width="1"
opacity="0.8"
/>
<!-- Tiny leaf at top -->
<ellipse cx="0" cy={-height - 3} rx="3" ry="4" fill={vegetation.sprout} opacity="0.9" />
</g>
<!-- Label -->
<text
y="14"
text-anchor="middle"
font-size="8"
font-family="system-ui, sans-serif"
fill="#5A7060"
opacity="0.6"
>
{app.displayName}
</text>
</g>

View file

@ -1,117 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation, getHealthColor } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let crownColor = $derived(getHealthColor(app.score));
let size = $derived(0.6 + (app.score / 100) * 0.6); // 0.6 - 1.2 scale
let isOak = $derived(app.plantType === 'oak');
// Generate pseudo-random but deterministic crown blobs
function crownBlobs(seed: string, count: number) {
const blobs = [];
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
}
for (let i = 0; i < count; i++) {
hash = ((hash << 5) - hash + i * 37) | 0;
const angle = ((hash & 0xff) / 255) * Math.PI * 2;
const dist = ((hash >> 8) & 0xff) / 255;
blobs.push({
cx: Math.cos(angle) * dist * (isOak ? 22 : 16),
cy: -30 - Math.sin(angle) * dist * (isOak ? 18 : 14) - i * 3,
rx: (isOak ? 18 : 14) + ((hash >> 16) & 0xf) - 8,
ry: (isOak ? 14 : 11) + ((hash >> 20) & 0xf) - 8,
});
}
return blobs;
}
let blobs = $derived(crownBlobs(app.id, isOak ? 7 : 5));
let animDelay = $derived(`${(app.id.charCodeAt(0) % 10) * 0.3}s`);
</script>
<g
class="plant-tree"
transform="translate({app.position.x}, {app.position.y}) scale({size})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
<!-- Wind sway animation wrapper -->
<g>
<animateTransform
attributeName="transform"
type="rotate"
values="-1,0,0; 1,0,0; -1,0,0"
dur="6s"
begin={animDelay}
repeatCount="indefinite"
/>
<!-- Shadow -->
<ellipse cx="8" cy="2" rx={isOak ? 20 : 14} ry="5" fill="black" opacity="0.1" />
<!-- Trunk -->
<path d="M-3,0 Q-4,-15 -2,-25 L2,-25 Q4,-15 3,0 Z" fill={vegetation.trunk} />
{#if isOak}
<!-- Branch left -->
<path d="M-2,-18 Q-12,-22 -16,-28 L-14,-30 Q-10,-24 -1,-20 Z" fill={vegetation.trunkLight} />
<!-- Branch right -->
<path d="M2,-20 Q10,-24 15,-30 L17,-28 Q12,-22 3,-18 Z" fill={vegetation.trunkLight} />
{/if}
<!-- Crown (cluster of ellipses) -->
<g>
{#each blobs as blob, i}
{@const lightness = i % 3 === 0 ? 15 : i % 3 === 1 ? 0 : -10}
<ellipse
cx={blob.cx}
cy={blob.cy}
rx={blob.rx}
ry={blob.ry}
fill={crownColor}
opacity={0.75 + (i % 3) * 0.08}
style="filter: brightness({100 + lightness}%)"
/>
{/each}
<!-- Highlight on top -->
<ellipse
cx={-4}
cy={-42}
rx={isOak ? 10 : 8}
ry={isOak ? 7 : 5}
fill="white"
opacity="0.08"
/>
</g>
</g>
<!-- Label -->
<text
y="18"
text-anchor="middle"
font-size="10"
font-family="system-ui, sans-serif"
font-weight="500"
fill="#3A5040"
opacity="0.8"
>
{app.displayName}
</text>
<text
y="30"
text-anchor="middle"
font-size="9"
font-family="system-ui, sans-serif"
fill="#5A7060"
opacity="0.6"
>
{app.score}
</text>
</g>

View file

@ -1,118 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { vegetation } from '../data/colors';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
let padSize = $derived(8 + (app.score / 100) * 10); // 8-18px radius
let petalCount = $derived(app.score >= 70 ? 8 : app.score >= 40 ? 6 : 4);
let bloomAmount = $derived(app.score / 100); // 0-1 how open the flower is
let animDelay = $derived(`${(app.id.charCodeAt(0) % 6) * 0.5}s`);
function petals(count: number) {
const result = [];
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2 - Math.PI / 2;
result.push({ angle, i });
}
return result;
}
let petalData = $derived(petals(petalCount));
</script>
<g
class="plant-lily"
transform="translate({app.position.x}, {app.position.y})"
style="cursor: pointer;"
role="button"
tabindex="0"
{onclick}
onkeydown={(e) => e.key === 'Enter' && onclick?.()}
>
<!-- Gentle bob animation -->
<g>
<animateTransform
attributeName="transform"
type="translate"
values="0,0; 0,-1.5; 0,0; 0,1; 0,0"
dur="5s"
begin={animDelay}
repeatCount="indefinite"
/>
<!-- Shadow under pad -->
<ellipse cx="1" cy="2" rx={padSize + 1} ry={padSize * 0.5 + 1} fill="black" opacity="0.1" />
<!-- Lily pad (circle with notch) -->
<path
d="M{padSize},0
A{padSize},{padSize * 0.55} 0 1,0 {-padSize},0
A{padSize},{padSize * 0.55} 0 1,0 {padSize},0
L0,0 L{padSize * 0.3},{-padSize * 0.15} Z"
fill={vegetation.lilyPad}
opacity="0.85"
/>
<!-- Pad vein -->
<line
x1="0"
y1="0"
x2={-padSize * 0.6}
y2={padSize * 0.15}
stroke="#3A7040"
stroke-width="0.5"
opacity="0.4"
/>
<line
x1="0"
y1="0"
x2={-padSize * 0.5}
y2={-padSize * 0.2}
stroke="#3A7040"
stroke-width="0.5"
opacity="0.4"
/>
<!-- Flower (if score > 20) -->
{#if app.score > 20}
<g transform="translate({-padSize * 0.2}, {-padSize * 0.1})">
<!-- Petals -->
{#each petalData as petal}
{@const px = Math.cos(petal.angle) * 5 * bloomAmount}
{@const py = Math.sin(petal.angle) * 3 * bloomAmount}
<ellipse
cx={px}
cy={py - 2}
rx={3.5 * bloomAmount + 1}
ry={2 * bloomAmount + 0.5}
fill={vegetation.lilyFlower}
opacity={0.7 + bloomAmount * 0.3}
transform="rotate({(petal.angle * 180) / Math.PI}, {px}, {py - 2})"
/>
{/each}
<!-- Center -->
<circle
cx="0"
cy="-2"
r={2 * bloomAmount + 0.5}
fill={vegetation.lilyFlowerCenter}
opacity="0.9"
/>
</g>
{/if}
</g>
<!-- Label -->
<text
y={padSize * 0.55 + 14}
text-anchor="middle"
font-size="9"
font-family="system-ui, sans-serif"
font-weight="500"
fill="#3A5040"
opacity="0.7"
>
{app.displayName}
</text>
</g>

View file

@ -1,67 +0,0 @@
<script lang="ts">
import { mountains } from '../data/colors';
import { SCENE, MOUNTAIN_PATHS } from '../data/layout';
import Sky from '../atmosphere/Sky.svelte';
let { hour = new Date().getHours() + new Date().getMinutes() / 60 }: { hour?: number } = $props();
</script>
<defs>
<!-- Subtle haze filter -->
<filter id="mountain-haze">
<feGaussianBlur stdDeviation="1.5" />
</filter>
<filter id="mountain-haze-light">
<feGaussianBlur stdDeviation="0.8" />
</filter>
</defs>
<!-- Dynamic sky -->
<Sky {hour} />
<!-- Clouds (subtle, decorative) -->
<g opacity="0.35">
<ellipse cx="300" cy="120" rx="120" ry="30" fill="white" />
<ellipse cx="340" cy="115" rx="80" ry="25" fill="white" />
<ellipse cx="260" cy="118" rx="60" ry="20" fill="white" />
<ellipse cx="1100" cy="90" rx="100" ry="25" fill="white" />
<ellipse cx="1140" cy="85" rx="70" ry="22" fill="white" />
<ellipse cx="700" cy="150" rx="90" ry="22" fill="white" />
<ellipse cx="740" cy="145" rx="65" ry="18" fill="white" />
</g>
<!-- Mountain layers (back to front) -->
<g>
<!-- Far mountains (lightest, most hazy) -->
<path d={MOUNTAIN_PATHS.far} fill={mountains.far} opacity="0.6" filter="url(#mountain-haze)" />
<!-- Snow caps on far mountains -->
<path
d="M740,165 Q755,155 770,168 Q782,158 795,170 L790,180 Q775,172 760,180 Q748,172 740,178 Z"
fill={mountains.snow}
opacity="0.5"
filter="url(#mountain-haze)"
/>
<!-- Mid mountains -->
<path
d={MOUNTAIN_PATHS.mid}
fill={mountains.mid}
opacity="0.75"
filter="url(#mountain-haze-light)"
/>
<!-- Near mountains (darkest, sharpest) -->
<path d={MOUNTAIN_PATHS.near} fill={mountains.near} opacity="0.85" />
</g>
<!-- Tree line on near mountains (tiny triangles) -->
<g opacity="0.4" fill="#3A6050">
{#each Array(60) as _, i}
{@const x = i * 28 + 10}
{@const baseY = 335 + Math.sin(i * 0.8) * 15}
{@const h = 12 + Math.sin(i * 1.3) * 5}
<polygon points="{x},{baseY} {x + 4},{baseY - h} {x + 8},{baseY}" />
{/each}
</g>

View file

@ -1,59 +0,0 @@
<script lang="ts">
import { terrain } from '../data/colors';
import { SCENE } from '../data/layout';
</script>
<defs>
<!-- Meadow gradient (top = darker, bottom = lighter) -->
<linearGradient id="meadow-gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color={terrain.meadowDark} />
<stop offset="40%" stop-color={terrain.meadow} />
<stop offset="100%" stop-color={terrain.meadowLight} />
</linearGradient>
<!-- Shore texture pattern -->
<pattern id="shore-dots" width="8" height="8" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="0.8" fill={terrain.shoreDark} opacity="0.3" />
<circle cx="6" cy="6" r="0.6" fill={terrain.shoreDark} opacity="0.2" />
</pattern>
</defs>
<!-- Main terrain fill (everything below mountains) -->
<rect x="0" y="350" width={SCENE.width} height={SCENE.height - 350} fill="url(#meadow-gradient)" />
<!-- Rolling hills / terrain variation -->
<g>
<!-- Gentle hills in the meadow -->
<path
d="M0,380 Q200,360 400,375 Q600,365 800,378 Q1000,362 1200,372 Q1400,365 1600,380 L1600,420 Q1400,405 1200,410 Q1000,400 800,412 Q600,402 400,408 Q200,398 0,410 Z"
fill={terrain.meadow}
opacity="0.5"
/>
<!-- Shoreline areas around the lakes (sandy patches) -->
<ellipse cx="790" cy="485" rx="160" ry="95" fill={terrain.shore} opacity="0.3" />
<ellipse cx="340" cy="420" rx="90" ry="55" fill={terrain.shore} opacity="0.25" />
<ellipse cx="1275" cy="445" rx="130" ry="75" fill={terrain.shore} opacity="0.25" />
<ellipse cx="270" cy="625" rx="130" ry="75" fill={terrain.shore} opacity="0.3" />
<ellipse cx="760" cy="670" rx="140" ry="80" fill={terrain.shore} opacity="0.3" />
<ellipse cx="1215" cy="635" rx="125" ry="70" fill={terrain.shore} opacity="0.3" />
</g>
<!-- Small rocks scattered around -->
<g fill={terrain.rock} opacity="0.5">
<ellipse cx="520" cy="450" rx="8" ry="5" />
<ellipse cx="525" cy="448" rx="5" ry="3" fill={terrain.rockLight} />
<ellipse cx="1050" cy="520" rx="6" ry="4" />
<ellipse cx="450" cy="650" rx="7" ry="4" />
<ellipse cx="950" cy="710" rx="9" ry="5" />
<ellipse cx="1400" cy="550" rx="5" ry="3" />
</g>
<!-- Grass tufts along the bottom -->
<g opacity="0.4" fill={terrain.meadowDark}>
{#each Array(40) as _, i}
{@const x = i * 42 + 15}
{@const y = 820 + Math.sin(i * 2.1) * 20}
<path d="M{x},{y} Q{x + 2},{y - 12} {x + 5},{y} Q{x + 7},{y - 10} {x + 10},{y}" />
{/each}
</g>

View file

@ -1,70 +0,0 @@
<script lang="ts">
import type { LakeData } from '../data/types';
let { lake }: { lake: LakeData } = $props();
// Unique IDs for gradients
let gradientId = $derived(`lake-gradient-${lake.id}`);
let reflectionId = $derived(`lake-reflection-${lake.id}`);
</script>
<defs>
<!-- Radial gradient for depth effect -->
<radialGradient id={gradientId} cx="50%" cy="40%" r="60%">
<stop offset="0%" stop-color={lake.color} stop-opacity={lake.clarity} />
<stop offset="100%" stop-color={lake.colorDeep} stop-opacity={lake.clarity * 0.9} />
</radialGradient>
<!-- Subtle shimmer/reflection -->
<radialGradient id={reflectionId} cx="40%" cy="30%" r="30%">
<stop offset="0%" stop-color="white" stop-opacity="0.15" />
<stop offset="100%" stop-color="white" stop-opacity="0" />
</radialGradient>
</defs>
<g class="lake" data-lake={lake.id}>
<!-- Lake body -->
<path
d={lake.path}
fill="url(#{gradientId})"
stroke="#3A8A9A"
stroke-width="0.5"
stroke-opacity="0.3"
/>
<!-- Light reflection on surface -->
<path d={lake.path} fill="url(#{reflectionId})" />
<!-- Animated wave lines on the surface -->
<g opacity="0.15" stroke="white" stroke-width="0.8" fill="none">
<path d={lake.path} stroke-dasharray="4 12" stroke-dashoffset="0">
<animate attributeName="stroke-dashoffset" values="0;16" dur="4s" repeatCount="indefinite" />
</path>
</g>
<!-- Second wave layer (offset timing) -->
<g opacity="0.1" stroke="white" stroke-width="0.5" fill="none">
<path d={lake.path} stroke-dasharray="3 15" stroke-dashoffset="8">
<animate
attributeName="stroke-dashoffset"
values="8;26"
dur="5.5s"
repeatCount="indefinite"
/>
</path>
</g>
<!-- Lake label -->
<text
x={lake.position.x}
y={lake.position.y}
text-anchor="middle"
font-size="11"
font-family="serif"
font-style="italic"
fill="#1A5666"
opacity="0.6"
>
{lake.label}
</text>
</g>

View file

@ -1,359 +0,0 @@
<script lang="ts">
import type { AppData, CategoryScores } from '../data/types';
import RadarChart from './RadarChart.svelte';
let { apps }: { apps: AppData[] } = $props();
let selected = $state<string[]>([]);
const colors = ['#3b82f6', '#f59e0b', '#10b981', '#ef4444'];
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
};
function toggle(id: string) {
if (selected.includes(id)) {
selected = selected.filter((s) => s !== id);
} else if (selected.length < 4) {
selected = [...selected, id];
}
}
let selectedApps = $derived(selected.map((id) => apps.find((a) => a.id === id)!).filter(Boolean));
const categoryKeys: (keyof CategoryScores)[] = [
'backend',
'frontend',
'database',
'testing',
'deployment',
'documentation',
'security',
'ux',
];
const labels = [
{ key: 'backend', label: 'Backend' },
{ key: 'frontend', label: 'Frontend' },
{ key: 'database', label: 'Database' },
{ key: 'testing', label: 'Testing' },
{ key: 'deployment', label: 'Deploy' },
{ key: 'documentation', label: 'Docs' },
{ key: 'security', label: 'Security' },
{ key: 'ux', label: 'UX' },
] as const;
const size = 280;
let center = $derived(size / 2);
let maxR = $derived(size / 2 - 30);
function polarToXY(angle: number, radius: number) {
const rad = ((angle - 90) * Math.PI) / 180;
return { x: center + Math.cos(rad) * radius, y: center + Math.sin(rad) * radius };
}
let rings = $derived(
[0.25, 0.5, 0.75, 1].map((pct) => {
const r = maxR * pct;
return labels
.map((_, i) => {
const angle = (360 / labels.length) * i;
return polarToXY(angle, r);
})
.map((p) => `${p.x},${p.y}`)
.join(' ');
})
);
let axes = $derived(labels.map((_, i) => polarToXY((360 / labels.length) * i, maxR)));
let labelPositions = $derived(
labels.map((l, i) => {
const pos = polarToXY((360 / labels.length) * i, maxR + 20);
return { ...pos, label: l.label };
})
);
function appPolygon(app: AppData): string {
return labels
.map((l, i) => {
const angle = (360 / labels.length) * i;
const value = app.categories[l.key] / 100;
const p = polarToXY(angle, maxR * value);
return `${p.x},${p.y}`;
})
.join(' ');
}
</script>
<div class="compare-view">
<!-- App selector -->
<div class="selector">
<p class="selector-label">Apps auswahlen (max. 4):</p>
<div class="selector-chips">
{#each apps as app (app.id)}
<button
type="button"
class="chip"
class:active={selected.includes(app.id)}
style={selected.includes(app.id)
? `border-color: ${colors[selected.indexOf(app.id)]}; color: ${colors[selected.indexOf(app.id)]}`
: ''}
onclick={() => toggle(app.id)}
>
<span class="chip-dot" style="background: {statusColors[app.status]}"></span>
{app.displayName}
<span class="chip-score">{app.score}</span>
</button>
{/each}
</div>
</div>
{#if selectedApps.length >= 2}
<div class="compare-content">
<!-- Overlaid radar chart -->
<div class="radar-section">
<svg width={size} height={size} viewBox="0 0 {size} {size}">
{#each rings as ring}
<polygon
points={ring}
fill="none"
stroke="rgba(148, 163, 184, 0.12)"
stroke-width="0.5"
/>
{/each}
{#each axes as axis}
<line
x1={center}
y1={center}
x2={axis.x}
y2={axis.y}
stroke="rgba(148, 163, 184, 0.08)"
stroke-width="0.5"
/>
{/each}
{#each selectedApps as app, i (app.id)}
<polygon
points={appPolygon(app)}
fill="{colors[i]}20"
stroke={colors[i]}
stroke-width="2"
opacity="0.8"
/>
{/each}
{#each labelPositions as lp}
<text
x={lp.x}
y={lp.y}
text-anchor="middle"
dominant-baseline="central"
font-size="9"
font-family="system-ui, sans-serif"
fill="#94a3b8"
>
{lp.label}
</text>
{/each}
</svg>
<!-- Legend -->
<div class="radar-legend">
{#each selectedApps as app, i (app.id)}
<span class="legend-item">
<span class="legend-color" style="background: {colors[i]}"></span>
{app.displayName} ({app.score})
</span>
{/each}
</div>
</div>
<!-- Category comparison bars -->
<div class="bars-section">
{#each categoryKeys as cat}
<div class="bar-group">
<span class="bar-label">{cat}</span>
<div class="bar-rows">
{#each selectedApps as app, i (app.id)}
<div class="bar-row">
<div class="bar-track">
<div
class="bar-fill"
style="width: {app.categories[cat]}%; background: {colors[i]}"
></div>
</div>
<span class="bar-value">{app.categories[cat]}</span>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="compare-placeholder">
<p>Wahle mindestens 2 Apps zum Vergleichen</p>
</div>
{/if}
</div>
<style>
.compare-view {
display: flex;
flex-direction: column;
gap: 24px;
}
.selector-label {
font-size: 12px;
color: #94a3b8;
margin: 0 0 10px 0;
}
.selector-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.15);
background: rgba(15, 23, 42, 0.4);
color: #94a3b8;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.chip:hover {
border-color: rgba(148, 163, 184, 0.3);
color: #cbd5e1;
}
.chip.active {
background: rgba(59, 130, 246, 0.1);
}
.chip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.chip-score {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.compare-content {
display: grid;
grid-template-columns: auto 1fr;
gap: 32px;
align-items: start;
}
@media (max-width: 768px) {
.compare-content {
grid-template-columns: 1fr;
}
}
.radar-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.radar-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: #cbd5e1;
}
.legend-color {
width: 10px;
height: 10px;
border-radius: 3px;
}
.bars-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.bar-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.bar-label {
font-size: 10px;
color: #64748b;
text-transform: capitalize;
font-weight: 600;
}
.bar-rows {
display: flex;
flex-direction: column;
gap: 3px;
}
.bar-row {
display: flex;
align-items: center;
gap: 8px;
}
.bar-track {
flex: 1;
height: 6px;
background: rgba(148, 163, 184, 0.08);
border-radius: 3px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.bar-value {
font-size: 10px;
color: #94a3b8;
width: 22px;
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.compare-placeholder {
text-align: center;
padding: 48px 0;
color: #64748b;
font-size: 14px;
}
</style>

View file

@ -1,331 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import RadarChart from './RadarChart.svelte';
let {
app,
onclose,
}: {
app: AppData | null;
onclose: () => void;
} = $props();
const statusLabels: Record<string, string> = {
mature: 'Mature',
production: 'Production',
beta: 'Beta',
alpha: 'Alpha',
prototype: 'Prototype',
};
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
prototype: '#94a3b8',
};
const appUrls: Record<string, string> = {
calendar: 'https://calendar.mana.how',
todo: 'https://todo.mana.how',
contacts: 'https://contacts.mana.how',
chat: 'https://chat.mana.how',
storage: 'https://storage.mana.how',
picture: 'https://picture.mana.how',
presi: 'https://presi.mana.how',
music: 'https://music.mana.how',
clock: 'https://clock.mana.how',
food: 'https://food.mana.how',
photos: 'https://photos.mana.how',
quotes: 'https://quotes.mana.how',
mana: 'https://mana.how',
cards: 'https://cards.mana.how',
plants: 'https://plants.mana.how',
playground: 'https://playground.mana.how',
};
function scoreColor(score: number): string {
if (score >= 85) return '#34d399';
if (score >= 70) return '#60a5fa';
if (score >= 55) return '#fbbf24';
if (score >= 40) return '#f97316';
return '#ef4444';
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if app}
<!-- Backdrop -->
<button
type="button"
class="panel-backdrop"
onclick={onclose}
tabindex="-1"
aria-label="Close panel"
></button>
<div class="panel" class:open={!!app}>
<!-- Header -->
<div class="panel-header">
<div>
<h3 class="panel-title">{app.displayName}</h3>
<span class="panel-status" style="color: {statusColors[app.status]}">
{statusLabels[app.status]}
</span>
</div>
<div class="panel-score" style="color: {scoreColor(app.score)}">
{app.score}
</div>
</div>
<!-- Score bar -->
<div class="score-bar-container">
<div class="score-bar" style="width: {app.score}%; background: {scoreColor(app.score)}"></div>
</div>
<!-- Radar Chart -->
<div class="panel-section">
<h4 class="section-label">Kategorien</h4>
<div class="radar-container">
<RadarChart categories={app.categories} size={200} />
</div>
</div>
<!-- Category scores list -->
<div class="panel-section">
<div class="category-list">
{#each Object.entries(app.categories) as [key, value]}
<div class="category-row">
<span class="category-label">{key}</span>
<div class="category-bar-wrap">
<div
class="category-bar-fill"
style="width: {value}%; background: {scoreColor(value)}"
></div>
</div>
<span class="category-value">{value}</span>
</div>
{/each}
</div>
</div>
<!-- Links -->
<div class="panel-section">
<div class="panel-links">
{#if appUrls[app.id]}
<a href={appUrls[app.id]} target="_blank" rel="noopener" class="panel-link">
App offnen
</a>
{/if}
<a href="/manascore/{app.id}" class="panel-link secondary"> ManaScore Details </a>
</div>
</div>
<!-- Close button -->
<button type="button" class="panel-close" onclick={onclose} aria-label="Close">
&times;
</button>
</div>
{/if}
<style>
.panel-backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(0, 0, 0, 0.2);
border: none;
cursor: default;
}
.panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 45;
width: 340px;
max-width: 90vw;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(16px);
border-left: 1px solid rgba(148, 163, 184, 0.1);
overflow-y: auto;
padding: 24px;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
animation: slideIn 0.25s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.panel-title {
font-size: 20px;
font-weight: 700;
color: #f1f5f9;
margin: 0;
}
.panel-status {
font-size: 12px;
font-weight: 500;
}
.panel-score {
font-size: 36px;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.score-bar-container {
height: 4px;
background: rgba(148, 163, 184, 0.1);
border-radius: 2px;
margin-bottom: 24px;
overflow: hidden;
}
.score-bar {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
.panel-section {
margin-bottom: 20px;
}
.section-label {
font-size: 10px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 10px 0;
}
.radar-container {
display: flex;
justify-content: center;
}
.category-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.category-row {
display: flex;
align-items: center;
gap: 8px;
}
.category-label {
font-size: 11px;
color: #94a3b8;
width: 90px;
text-transform: capitalize;
flex-shrink: 0;
}
.category-bar-wrap {
flex: 1;
height: 5px;
background: rgba(148, 163, 184, 0.1);
border-radius: 3px;
overflow: hidden;
}
.category-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.category-value {
font-size: 11px;
color: #cbd5e1;
width: 24px;
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.panel-links {
display: flex;
flex-direction: column;
gap: 8px;
}
.panel-link {
display: block;
text-align: center;
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
text-decoration: none;
background: #3b82f6;
color: white;
transition: background 0.2s;
}
.panel-link:hover {
background: #2563eb;
}
.panel-link.secondary {
background: rgba(148, 163, 184, 0.1);
color: #94a3b8;
}
.panel-link.secondary:hover {
background: rgba(148, 163, 184, 0.2);
color: #cbd5e1;
}
.panel-close {
position: absolute;
top: 16px;
right: 16px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: rgba(148, 163, 184, 0.1);
color: #94a3b8;
border-radius: 6px;
font-size: 18px;
cursor: pointer;
transition:
background 0.2s,
color 0.2s;
}
.panel-close:hover {
background: rgba(148, 163, 184, 0.2);
color: #f1f5f9;
}
</style>

View file

@ -1,177 +0,0 @@
<script lang="ts">
import type { LakeData } from '../data/types';
let { lake }: { lake: LakeData } = $props();
// Normalize the lake path to fit inside the card SVG
// We'll render it at its original scale but crop via viewBox
let viewBox = $derived(() => {
// Parse path to get rough bounds
const nums = lake.path.match(/[\d.]+/g)?.map(Number) || [];
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (let i = 0; i < nums.length; i += 2) {
if (nums[i] < minX) minX = nums[i];
if (nums[i] > maxX) maxX = nums[i];
}
for (let i = 1; i < nums.length; i += 2) {
if (nums[i] < minY) minY = nums[i];
if (nums[i] > maxY) maxY = nums[i];
}
const pad = 20;
return `${minX - pad} ${minY - pad} ${maxX - minX + pad * 2} ${maxY - minY + pad * 2}`;
});
const lakeDescriptions: Record<string, string> = {
auth: 'Zentraler Authentifizierungs-Hub. Alle Services fliessen durch diesen See.',
redis: 'Schneller Cache-Speicher. Klein, kristallklar, sofort verfugbar.',
minio: 'Objekt-Speicher fur Dateien, Bilder und Medien aller Apps.',
'db-left': 'PostgreSQL-Datenbanken fur Calendar, Todo, Contacts, Storage.',
'db-center': 'PostgreSQL-Datenbanken fur Quotes, Music, Clock, Food.',
'db-right': 'PostgreSQL-Datenbanken fur Photos, SkillTree, Context, Plants.',
};
const lakeIcons: Record<string, string> = {
auth: 'Mana Core Auth',
redis: 'Redis Cache',
minio: 'MinIO Storage',
'db-left': 'PostgreSQL West',
'db-center': 'PostgreSQL Mitte',
'db-right': 'PostgreSQL Ost',
};
</script>
<div class="lake-card">
<!-- Lake SVG preview -->
<div class="lake-preview">
<svg
width="220"
height="120"
viewBox={viewBox()}
preserveAspectRatio="xMidYMid meet"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<radialGradient id="lc-{lake.id}" cx="50%" cy="40%" r="60%">
<stop offset="0%" stop-color={lake.color} />
<stop offset="100%" stop-color={lake.colorDeep} />
</radialGradient>
</defs>
<path d={lake.path} fill="url(#lc-{lake.id})" />
<!-- Shimmer -->
<path d={lake.path} fill="white" opacity="0.08" />
<!-- Waves -->
<path
d={lake.path}
fill="none"
stroke="white"
stroke-width="0.8"
stroke-dasharray="4 12"
opacity="0.2"
>
<animate
attributeName="stroke-dashoffset"
values="0;16"
dur="4s"
repeatCount="indefinite"
/>
</path>
</svg>
</div>
<!-- Info -->
<div class="lake-info">
<h3 class="lake-name">{lake.label}</h3>
<span class="lake-service">{lakeIcons[lake.id] || lake.name}</span>
<p class="lake-desc">{lakeDescriptions[lake.id] || ''}</p>
<div class="lake-stats">
<span class="lake-stat">
<span class="stat-label">Klarheit</span>
<span class="stat-value">{Math.round(lake.clarity * 100)}%</span>
</span>
<span class="lake-stat">
<span class="stat-label">Fullstand</span>
<span class="stat-value">{Math.round(lake.level * 100)}%</span>
</span>
</div>
</div>
</div>
<style>
.lake-card {
flex-shrink: 0;
width: 260px;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(8px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
overflow: hidden;
transition:
transform 0.2s,
border-color 0.2s;
}
.lake-card:hover {
transform: translateY(-3px);
border-color: rgba(59, 130, 246, 0.2);
}
.lake-preview {
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
padding: 12px;
}
.lake-info {
padding: 12px 14px;
}
.lake-name {
font-size: 15px;
font-weight: 600;
color: #f1f5f9;
margin: 0 0 2px 0;
}
.lake-service {
font-size: 11px;
color: #60a5fa;
font-weight: 500;
}
.lake-desc {
font-size: 11px;
color: #94a3b8;
margin: 8px 0;
line-height: 1.4;
}
.lake-stats {
display: flex;
gap: 16px;
}
.lake-stat {
display: flex;
flex-direction: column;
gap: 1px;
}
.stat-label {
font-size: 9px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 13px;
font-weight: 600;
color: #cbd5e1;
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -1,295 +0,0 @@
<script lang="ts">
import type { AppData, CategoryScores } from '../data/types';
let { apps, onselect }: { apps: AppData[]; onselect: (app: AppData) => void } = $props();
type SortKey = 'score' | 'name' | 'trend' | keyof CategoryScores;
let sortKey = $state<SortKey>('score');
let sortAsc = $state(false);
const categoryKeys: (keyof CategoryScores)[] = [
'backend',
'frontend',
'database',
'testing',
'deployment',
'documentation',
'security',
'ux',
];
const shortLabels: Record<string, string> = {
backend: 'BE',
frontend: 'FE',
database: 'DB',
testing: 'Test',
deployment: 'Deploy',
documentation: 'Docs',
security: 'Sec',
ux: 'UX',
};
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
prototype: '#94a3b8',
};
function scoreColor(score: number): string {
if (score >= 85) return '#34d399';
if (score >= 70) return '#60a5fa';
if (score >= 50) return '#fbbf24';
if (score >= 30) return '#f97316';
return '#ef4444';
}
function toggleSort(key: SortKey) {
if (sortKey === key) {
sortAsc = !sortAsc;
} else {
sortKey = key;
sortAsc = false;
}
}
let sorted = $derived(() => {
const arr = [...apps];
arr.sort((a, b) => {
let va: number | string, vb: number | string;
if (sortKey === 'score') {
va = a.score;
vb = b.score;
} else if (sortKey === 'name') {
va = a.displayName.toLowerCase();
vb = b.displayName.toLowerCase();
} else if (sortKey === 'trend') {
va = a.trend;
vb = b.trend;
} else {
va = a.categories[sortKey];
vb = b.categories[sortKey];
}
if (va < vb) return sortAsc ? -1 : 1;
if (va > vb) return sortAsc ? 1 : -1;
return 0;
});
return arr;
});
function sortIndicator(key: SortKey): string {
if (sortKey !== key) return '';
return sortAsc ? ' ↑' : ' ↓';
}
</script>
<div class="leaderboard">
<div class="table-scroll">
<table>
<thead>
<tr>
<th class="rank-col">#</th>
<th class="name-col sortable" onclick={() => toggleSort('name')}>
App{sortIndicator('name')}
</th>
<th class="score-col sortable" onclick={() => toggleSort('score')}>
Score{sortIndicator('score')}
</th>
<th class="trend-col sortable" onclick={() => toggleSort('trend')}>
Trend{sortIndicator('trend')}
</th>
{#each categoryKeys as cat}
<th class="cat-col sortable" onclick={() => toggleSort(cat)}>
{shortLabels[cat]}{sortIndicator(cat)}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sorted() as app, i (app.id)}
<tr class="app-row" onclick={() => onselect(app)}>
<td class="rank">{i + 1}</td>
<td class="name-cell">
<span class="status-dot" style="background: {statusColors[app.status]}"></span>
{app.displayName}
</td>
<td class="score-cell" style="color: {scoreColor(app.score)}">
{app.score}
</td>
<td class="trend-cell">
{#if app.trend > 0}
<span class="trend-up">+{app.trend}</span>
{:else if app.trend < 0}
<span class="trend-down">{app.trend}</span>
{:else}
<span class="trend-neutral">-</span>
{/if}
</td>
{#each categoryKeys as cat}
<td class="cat-cell">
<div class="cat-bar-wrap">
<div
class="cat-bar-fill"
style="width: {app.categories[cat]}%; background: {scoreColor(
app.categories[cat]
)}"
></div>
</div>
<span class="cat-value">{app.categories[cat]}</span>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<style>
.leaderboard {
width: 100%;
}
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 800px;
}
thead tr {
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
th {
font-size: 10px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 8px 6px;
text-align: left;
white-space: nowrap;
user-select: none;
}
th.sortable {
cursor: pointer;
}
th.sortable:hover {
color: #94a3b8;
}
.rank-col {
width: 32px;
text-align: center;
}
.name-col {
width: 120px;
}
.score-col {
width: 56px;
text-align: right;
}
.trend-col {
width: 48px;
text-align: right;
}
.cat-col {
width: 70px;
text-align: right;
}
.app-row {
border-bottom: 1px solid rgba(148, 163, 184, 0.05);
cursor: pointer;
transition: background 0.15s;
}
.app-row:hover {
background: rgba(59, 130, 246, 0.06);
}
td {
padding: 8px 6px;
font-size: 12px;
color: #cbd5e1;
}
.rank {
text-align: center;
color: #64748b;
font-weight: 600;
font-size: 11px;
}
.name-cell {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
color: #f1f5f9;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.score-cell {
text-align: right;
font-weight: 700;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.trend-cell {
text-align: right;
font-size: 11px;
font-weight: 600;
}
.trend-up {
color: #34d399;
}
.trend-down {
color: #ef4444;
}
.trend-neutral {
color: #475569;
}
.cat-cell {
text-align: right;
}
.cat-bar-wrap {
display: inline-block;
width: 32px;
height: 4px;
background: rgba(148, 163, 184, 0.1);
border-radius: 2px;
overflow: hidden;
vertical-align: middle;
margin-right: 4px;
}
.cat-bar-fill {
height: 100%;
border-radius: 2px;
}
.cat-value {
font-size: 10px;
font-variant-numeric: tabular-nums;
color: #94a3b8;
}
</style>

View file

@ -1,185 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
import { getHealthColor } from '../data/colors';
import PlantFactory from '../plants/PlantFactory.svelte';
let { app, onclick }: { app: AppData; onclick?: () => void } = $props();
const statusLabels: Record<string, string> = {
mature: 'Mature',
production: 'Production',
beta: 'Beta',
alpha: 'Alpha',
prototype: 'Prototype',
};
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
prototype: '#94a3b8',
};
const plantTypeLabels: Record<string, string> = {
oak: 'Eiche',
birch: 'Birke',
youngTree: 'Junger Baum',
reed: 'Schilf',
waterLily: 'Seerose',
moss: 'Moos',
shrub: 'Busch',
sprout: 'Sprossling',
stump: 'Baumstumpf',
swampCluster: 'Sumpfpflanze',
};
// Create a centered version of the app for the card SVG
let cardApp = $derived({
...app,
position: { x: 80, y: 120 },
});
function scoreColor(score: number): string {
if (score >= 85) return '#34d399';
if (score >= 70) return '#60a5fa';
if (score >= 55) return '#fbbf24';
if (score >= 40) return '#f97316';
return '#ef4444';
}
</script>
<button type="button" class="plant-card" {onclick}>
<!-- Plant SVG preview -->
<div class="plant-preview">
<svg width="160" height="160" viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg">
<!-- Ground line -->
<ellipse cx="80" cy="125" rx="50" ry="6" fill="#7DB86A" opacity="0.3" />
<PlantFactory app={cardApp} />
</svg>
</div>
<!-- Info -->
<div class="plant-info">
<div class="plant-header">
<span class="plant-name">{app.displayName}</span>
<span class="plant-score" style="color: {scoreColor(app.score)}">{app.score}</span>
</div>
<div class="plant-meta">
<span class="plant-type">{plantTypeLabels[app.plantType] || app.plantType}</span>
<span class="plant-status" style="color: {statusColors[app.status]}">
{statusLabels[app.status]}
</span>
</div>
<!-- Mini score bar -->
<div class="plant-bar">
<div
class="plant-bar-fill"
style="width: {app.score}%; background: {scoreColor(app.score)}"
></div>
</div>
{#if app.trend !== 0}
<div class="plant-trend" class:positive={app.trend > 0} class:negative={app.trend < 0}>
{app.trend > 0 ? '+' : ''}{app.trend} seit letztem Audit
</div>
{/if}
</div>
</button>
<style>
.plant-card {
flex-shrink: 0;
width: 200px;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(8px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition:
transform 0.2s,
border-color 0.2s,
box-shadow 0.2s;
text-align: left;
color: inherit;
padding: 0;
}
.plant-card:hover {
transform: translateY(-4px);
border-color: rgba(59, 130, 246, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.plant-preview {
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(180deg, #e0f2fe 0%, #bae6fd 40%, #7db86a 100%);
padding: 8px 0 0;
}
.plant-info {
padding: 12px;
}
.plant-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.plant-name {
font-size: 14px;
font-weight: 600;
color: #f1f5f9;
}
.plant-score {
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.plant-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
margin-bottom: 8px;
}
.plant-type {
color: #64748b;
}
.plant-status {
font-weight: 500;
}
.plant-bar {
height: 3px;
background: rgba(148, 163, 184, 0.15);
border-radius: 2px;
overflow: hidden;
}
.plant-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease;
}
.plant-trend {
font-size: 10px;
margin-top: 6px;
}
.plant-trend.positive {
color: #34d399;
}
.plant-trend.negative {
color: #ef4444;
}
</style>

View file

@ -1,176 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
let { app, x, y }: { app: AppData; x: number; y: number } = $props();
const statusLabels: Record<string, string> = {
mature: 'Mature',
production: 'Production',
beta: 'Beta',
alpha: 'Alpha',
prototype: 'Prototype',
};
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
prototype: '#94a3b8',
};
const healthIcons: Record<string, string> = {
up: '●',
degraded: '◐',
down: '○',
unknown: '?',
};
const healthColors: Record<string, string> = {
up: '#34d399',
degraded: '#fbbf24',
down: '#ef4444',
unknown: '#94a3b8',
};
</script>
<div class="plant-tooltip" style="left: {x}px; top: {y}px;">
<div class="tooltip-header">
<span class="tooltip-name">{app.displayName}</span>
<span class="tooltip-score">{app.score}</span>
</div>
<div class="tooltip-meta">
<span class="tooltip-status" style="color: {statusColors[app.status]}">
{statusLabels[app.status]}
</span>
<span class="tooltip-health" style="color: {healthColors[app.health]}">
{healthIcons[app.health]}
</span>
{#if app.trend !== 0}
<span class="tooltip-trend" class:positive={app.trend > 0} class:negative={app.trend < 0}>
{app.trend > 0 ? '+' : ''}{app.trend}
</span>
{/if}
</div>
<div class="tooltip-categories">
{#each Object.entries(app.categories) as [key, value]}
<div class="tooltip-cat-row">
<span class="tooltip-cat-label">{key}</span>
<div class="tooltip-cat-bar">
<div class="tooltip-cat-fill" style="width: {value}%"></div>
</div>
<span class="tooltip-cat-value">{value}</span>
</div>
{/each}
</div>
</div>
<style>
.plant-tooltip {
position: fixed;
z-index: 50;
pointer-events: none;
transform: translate(-50%, -100%) translateY(-12px);
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(8px);
border: 1px solid rgba(148, 163, 184, 0.15);
border-radius: 10px;
padding: 10px 14px;
min-width: 180px;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.05);
}
.tooltip-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.tooltip-name {
font-size: 13px;
font-weight: 600;
color: #f1f5f9;
}
.tooltip-score {
font-size: 18px;
font-weight: 700;
color: #f1f5f9;
font-variant-numeric: tabular-nums;
}
.tooltip-meta {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
font-size: 11px;
}
.tooltip-status {
font-weight: 500;
}
.tooltip-health {
font-size: 8px;
}
.tooltip-trend {
font-size: 11px;
font-weight: 600;
}
.tooltip-trend.positive {
color: #34d399;
}
.tooltip-trend.negative {
color: #ef4444;
}
.tooltip-categories {
display: flex;
flex-direction: column;
gap: 2px;
}
.tooltip-cat-row {
display: flex;
align-items: center;
gap: 6px;
}
.tooltip-cat-label {
font-size: 9px;
color: #94a3b8;
width: 72px;
text-transform: capitalize;
flex-shrink: 0;
}
.tooltip-cat-bar {
flex: 1;
height: 3px;
background: rgba(148, 163, 184, 0.15);
border-radius: 2px;
overflow: hidden;
}
.tooltip-cat-fill {
height: 100%;
border-radius: 2px;
background: linear-gradient(90deg, #3b82f6, #60a5fa);
transition: width 0.3s ease;
}
.tooltip-cat-value {
font-size: 9px;
color: #94a3b8;
width: 18px;
text-align: right;
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -1,120 +0,0 @@
<script lang="ts">
import type { CategoryScores } from '../data/types';
let {
categories,
size = 140,
}: {
categories: CategoryScores;
size?: number;
} = $props();
const labels = [
{ key: 'backend', label: 'Backend' },
{ key: 'frontend', label: 'Frontend' },
{ key: 'database', label: 'Database' },
{ key: 'testing', label: 'Testing' },
{ key: 'deployment', label: 'Deploy' },
{ key: 'documentation', label: 'Docs' },
{ key: 'security', label: 'Security' },
{ key: 'ux', label: 'UX' },
] as const;
let center = $derived(size / 2);
let maxR = $derived(size / 2 - 24);
function polarToXY(angle: number, radius: number) {
const rad = ((angle - 90) * Math.PI) / 180;
return {
x: center + Math.cos(rad) * radius,
y: center + Math.sin(rad) * radius,
};
}
// Grid rings
let rings = $derived(
[0.25, 0.5, 0.75, 1].map((pct) => {
const r = maxR * pct;
const points = labels.map((_, i) => {
const angle = (360 / labels.length) * i;
return polarToXY(angle, r);
});
return points.map((p) => `${p.x},${p.y}`).join(' ');
})
);
// Data polygon
let dataPoints = $derived(
labels.map((l, i) => {
const angle = (360 / labels.length) * i;
const value = categories[l.key] / 100;
return polarToXY(angle, maxR * value);
})
);
let dataPolygon = $derived(dataPoints.map((p) => `${p.x},${p.y}`).join(' '));
// Axis lines
let axes = $derived(
labels.map((_, i) => {
const angle = (360 / labels.length) * i;
return polarToXY(angle, maxR);
})
);
// Label positions (slightly outside)
let labelPositions = $derived(
labels.map((l, i) => {
const angle = (360 / labels.length) * i;
const pos = polarToXY(angle, maxR + 16);
return { ...pos, label: l.label, value: categories[l.key] };
})
);
</script>
<svg width={size} height={size} viewBox="0 0 {size} {size}">
<!-- Grid rings -->
{#each rings as ring}
<polygon points={ring} fill="none" stroke="rgba(148, 163, 184, 0.15)" stroke-width="0.5" />
{/each}
<!-- Axis lines -->
{#each axes as axis}
<line
x1={center}
y1={center}
x2={axis.x}
y2={axis.y}
stroke="rgba(148, 163, 184, 0.1)"
stroke-width="0.5"
/>
{/each}
<!-- Data polygon -->
<polygon
points={dataPolygon}
fill="rgba(59, 130, 246, 0.2)"
stroke="rgba(59, 130, 246, 0.8)"
stroke-width="1.5"
/>
<!-- Data points -->
{#each dataPoints as point}
<circle cx={point.x} cy={point.y} r="2.5" fill="#3b82f6" />
{/each}
<!-- Labels -->
{#each labelPositions as lp}
<text
x={lp.x}
y={lp.y}
text-anchor="middle"
dominant-baseline="central"
font-size="8"
font-family="system-ui, sans-serif"
fill="#94a3b8"
>
{lp.label}
</text>
{/each}
</svg>

View file

@ -1,158 +0,0 @@
<script lang="ts">
import type { RiverData } from '../data/types';
import { LAKES } from '../data/layout';
import { water } from '../data/colors';
let { river }: { river: RiverData } = $props();
function lakeName(id: string): string {
const lake = LAKES.find((l) => l.id === id);
return lake?.label || id;
}
let fromLabel = $derived(river.from === 'source' ? 'User Requests' : lakeName(river.from));
let toLabel = $derived(lakeName(river.to));
let speedLabel = $derived(
river.flowSpeed >= 0.8 ? 'Schnell' : river.flowSpeed >= 0.5 ? 'Mittel' : 'Ruhig'
);
let speedColor = $derived(
river.flowSpeed >= 0.8 ? '#34d399' : river.flowSpeed >= 0.5 ? '#fbbf24' : '#60a5fa'
);
</script>
<div class="river-card">
<!-- SVG preview -->
<div class="river-preview">
<svg width="240" height="60" viewBox="0 0 240 60" xmlns="http://www.w3.org/2000/svg">
<!-- River line -->
<path
d="M20,30 Q80,15 120,30 Q160,45 220,30"
fill="none"
stroke={water.river}
stroke-width={river.width * 0.8}
stroke-linecap="round"
opacity="0.7"
/>
<!-- Flow particles -->
<path
d="M20,30 Q80,15 120,30 Q160,45 220,30"
fill="none"
stroke={water.highlight}
stroke-width={river.width * 0.3}
stroke-linecap="round"
stroke-dasharray="8 16"
opacity="0.5"
>
<animate
attributeName="stroke-dashoffset"
values="24;0"
dur="{2 + (1 - river.flowSpeed) * 3}s"
repeatCount="indefinite"
/>
</path>
<!-- Endpoints -->
<circle cx="20" cy="30" r="4" fill={water.deep} />
<circle cx="220" cy="30" r="4" fill={water.deep} />
<!-- Arrow -->
<path
d="M210,26 L220,30 L210,34"
fill="none"
stroke={water.highlight}
stroke-width="1.5"
opacity="0.6"
/>
</svg>
</div>
<div class="river-info">
<div class="river-flow">
<span class="river-from">{fromLabel}</span>
<span class="river-arrow"></span>
<span class="river-to">{toLabel}</span>
</div>
<div class="river-stats">
<span class="river-stat">
<span class="stat-label">Geschwindigkeit</span>
<span class="stat-value" style="color: {speedColor}">{speedLabel}</span>
</span>
<span class="river-stat">
<span class="stat-label">Breite</span>
<span class="stat-value">{river.width}px</span>
</span>
</div>
</div>
</div>
<style>
.river-card {
flex-shrink: 0;
width: 260px;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(8px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
overflow: hidden;
transition:
transform 0.2s,
border-color 0.2s;
}
.river-card:hover {
transform: translateY(-3px);
border-color: rgba(59, 130, 246, 0.2);
}
.river-preview {
display: flex;
justify-content: center;
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
padding: 8px 0;
}
.river-info {
padding: 12px 14px;
}
.river-flow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.river-from,
.river-to {
font-size: 12px;
font-weight: 600;
color: #e2e8f0;
}
.river-arrow {
color: #60a5fa;
font-size: 14px;
}
.river-stats {
display: flex;
gap: 16px;
}
.river-stat {
display: flex;
flex-direction: column;
gap: 1px;
}
.stat-label {
font-size: 9px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 12px;
font-weight: 600;
color: #cbd5e1;
}
</style>

View file

@ -1,234 +0,0 @@
<script lang="ts">
import type { AppData } from '../data/types';
let { apps }: { apps: AppData[] } = $props();
// Build timeline data from apps that have trends (previousScore)
// For now we create 2 data points: "before" and "now"
const timePoints = ['Vorher', 'Aktuell'];
const statusColors: Record<string, string> = {
mature: '#34d399',
production: '#60a5fa',
beta: '#fbbf24',
alpha: '#f97316',
prototype: '#94a3b8',
};
// Chart dimensions
const width = 800;
const height = 400;
const padL = 50;
const padR = 30;
const padT = 20;
const padB = 40;
const chartW = width - padL - padR;
const chartH = height - padT - padB;
// Apps sorted by current score
let sorted = $derived(apps.toSorted((a, b) => b.score - a.score));
// Generate line path for each app
function appLine(app: AppData): { path: string; startY: number; endY: number } {
const prev = app.trend !== 0 ? app.score - app.trend : app.score;
const x0 = padL;
const x1 = padL + chartW;
const y0 = padT + chartH - (prev / 100) * chartH;
const y1 = padT + chartH - (app.score / 100) * chartH;
return {
path: `M${x0},${y0} L${x1},${y1}`,
startY: y0,
endY: y1,
};
}
// Grid lines
const gridLines = [0, 25, 50, 75, 100];
let hoveredApp = $state<string | null>(null);
let gainers = $derived(sorted.filter((a) => a.trend > 0).sort((a, b) => b.trend - a.trend));
let avgScore = $derived(Math.round(sorted.reduce((s, a) => s + a.score, 0) / sorted.length));
</script>
<div class="trends-chart">
<svg
viewBox="0 0 {width} {height}"
style="width: 100%; height: auto; max-height: 450px;"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Grid -->
{#each gridLines as val}
{@const y = padT + chartH - (val / 100) * chartH}
<line
x1={padL}
y1={y}
x2={padL + chartW}
y2={y}
stroke="rgba(148, 163, 184, 0.08)"
stroke-width="0.5"
/>
<text
x={padL - 8}
{y}
text-anchor="end"
dominant-baseline="central"
font-size="9"
fill="#64748b"
>
{val}
</text>
{/each}
<!-- Time labels -->
<text x={padL} y={height - 10} text-anchor="start" font-size="10" fill="#64748b">
{timePoints[0]}
</text>
<text x={padL + chartW} y={height - 10} text-anchor="end" font-size="10" fill="#64748b">
{timePoints[1]}
</text>
<!-- Lines for each app -->
{#each sorted as app (app.id)}
{@const line = appLine(app)}
{@const isHovered = hoveredApp === app.id}
{@const color = statusColors[app.status] || '#94a3b8'}
<g opacity={hoveredApp && !isHovered ? 0.15 : 1} style="transition: opacity 0.2s;">
<!-- Line -->
<path
d={line.path}
fill="none"
stroke={color}
stroke-width={isHovered ? 3 : 1.5}
stroke-linecap="round"
/>
<!-- Start dot -->
<circle cx={padL} cy={line.startY} r={isHovered ? 4 : 2.5} fill={color} />
<!-- End dot -->
<circle cx={padL + chartW} cy={line.endY} r={isHovered ? 4 : 2.5} fill={color} />
<!-- Label at end -->
<text
x={padL + chartW + 6}
y={line.endY}
font-size={isHovered ? 11 : 9}
font-weight={isHovered ? 600 : 400}
fill={isHovered ? '#f1f5f9' : '#94a3b8'}
dominant-baseline="central"
>
{app.displayName}
{app.score}
</text>
<!-- Hover area (invisible wide line) -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- This is an SVG hover area for tooltip display, not an interactive control -->
<path
d={line.path}
fill="none"
stroke="transparent"
stroke-width="12"
onmouseenter={() => (hoveredApp = app.id)}
onmouseleave={() => (hoveredApp = null)}
style="cursor: pointer;"
/>
</g>
{/each}
<!-- Trend annotation for apps with big changes -->
{#each sorted.filter((a) => Math.abs(a.trend) >= 10) as app (app.id)}
{@const line = appLine(app)}
{@const midX = padL + chartW * 0.5}
{@const midY = (line.startY + line.endY) / 2}
<text
x={midX}
y={midY - 8}
text-anchor="middle"
font-size="10"
font-weight="600"
fill={app.trend > 0 ? '#34d399' : '#ef4444'}
opacity={hoveredApp && hoveredApp !== app.id ? 0.15 : 0.7}
>
{app.trend > 0 ? '+' : ''}{app.trend}
</text>
{/each}
</svg>
<!-- Summary cards -->
<div class="trend-summary">
{#if gainers.length}
<div class="summary-card">
<span class="summary-label">Grosste Verbesserungen</span>
<div class="summary-items">
{#each gainers as app (app.id)}
<span class="summary-item gain">
{app.displayName} <strong>+{app.trend}</strong>
</span>
{/each}
</div>
</div>
{/if}
<div class="summary-card">
<span class="summary-label">Durchschnitt</span>
<span class="summary-value">{avgScore}</span>
</div>
</div>
</div>
<style>
.trends-chart {
display: flex;
flex-direction: column;
gap: 20px;
}
.trend-summary {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.summary-card {
background: rgba(15, 23, 42, 0.5);
border: 1px solid rgba(148, 163, 184, 0.08);
border-radius: 10px;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.summary-label {
font-size: 10px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.summary-items {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.summary-item {
font-size: 12px;
color: #cbd5e1;
}
.summary-item.gain strong {
color: #34d399;
}
.summary-value {
font-size: 24px;
font-weight: 800;
color: #f1f5f9;
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -1,69 +0,0 @@
<script lang="ts">
import type { RiverData } from '../data/types';
import { water } from '../data/colors';
let { river }: { river: RiverData } = $props();
let duration = $derived(`${3 + (1 - river.flowSpeed) * 4}s`);
let dashLength = $derived(river.width * 1.5);
let gapLength = $derived(river.width * 3);
</script>
<g class="river" data-river={river.id}>
<!-- River bed (wider, darker) -->
<path
d={river.path}
fill="none"
stroke={water.deep}
stroke-width={river.width + 4}
stroke-linecap="round"
stroke-linejoin="round"
opacity="0.3"
/>
<!-- River water -->
<path
d={river.path}
fill="none"
stroke={water.river}
stroke-width={river.width}
stroke-linecap="round"
stroke-linejoin="round"
opacity="0.7"
/>
<!-- Animated flow particles -->
<path
d={river.path}
fill="none"
stroke={water.highlight}
stroke-width={river.width * 0.4}
stroke-linecap="round"
stroke-dasharray="{dashLength} {gapLength}"
opacity="0.4"
>
<animate
attributeName="stroke-dashoffset"
values="{dashLength + gapLength};0"
dur={duration}
repeatCount="indefinite"
/>
</path>
<!-- Surface shimmer -->
<path
d={river.path}
fill="none"
stroke="white"
stroke-width={river.width * 0.2}
stroke-dasharray="2 {gapLength * 1.5}"
opacity="0.2"
>
<animate
attributeName="stroke-dashoffset"
values="{gapLength * 1.5 + 2};0"
dur={duration}
repeatCount="indefinite"
/>
</path>
</g>

View file

@ -22,6 +22,9 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [
'albumItems', // TODO: audit
'albums', // TODO: audit
'articleTags', // FK-only junction into globalTags (articleId, tagId). Tag names live in globalTags.
'articleImportJobs', // Bulk-import job header (counters, status, lease metadata). Pure operational state, no user-typed content. See docs/plans/articles-bulk-import.md.
'articleImportItems', // One row per URL in a bulk job. URL is plaintext by necessity — server-worker reads it without master-key access (same rationale as articles.originalUrl).
'articleExtractPickup', // Short-lived server-write inbox; the client picks up the extracted payload, encrypts it into the articles table, deletes the row. Plaintext by necessity (server has no master key); empty in steady state.
'automations', // TODO: audit
'boardViews', // TODO: audit
'budgets', // TODO: audit

View file

@ -168,11 +168,6 @@ db.version(1).stores({
timeWorldClocks: 'id, sortOrder, timezone',
entryTags: 'id, entryId, tagId, [entryId+tagId]',
// ─── Context (appId: 'context') ───
contextSpaces: 'id, pinned, prefix',
documents: 'id, spaceId, type, pinned, title, [spaceId+type], updatedAt',
documentTags: 'id, documentId, tagId, [documentId+tagId]',
// ─── Questions (appId: 'questions') ───
qCollections: 'id, sortOrder, isDefault',
questions: 'id, collectionId, status, priority, [collectionId+status]',
@ -673,11 +668,10 @@ db.version(30).stores({
_serverIterationExecutions: 'iterationId, missionId, executedAt',
});
// v31 — Rename the legacy `spaceId` field to `contextSpaceId` on four
// v31 — Rename the legacy `spaceId` field to `contextSpaceId` on three
// tables that owned the term before the multi-tenancy Spaces foundation
// arrived (v28):
// - conversations (chat module's reference to a context-space folder)
// - documents (context module's parent context-space)
// - spaceMembers (memoro's members of a context-space)
// - memoSpaces (memoro's memo ↔ context-space join)
//
@ -696,12 +690,11 @@ db.version(30).stores({
db.version(31)
.stores({
conversations: 'id, isArchived, isPinned, contextSpaceId, templateId, updatedAt',
documents: 'id, contextSpaceId, type, pinned, title, [contextSpaceId+type], updatedAt',
spaceMembers: 'id, contextSpaceId, userId',
memoSpaces: 'id, memoId, contextSpaceId',
})
.upgrade(async (tx) => {
const tables = ['conversations', 'documents', 'spaceMembers', 'memoSpaces'] as const;
const tables = ['conversations', 'spaceMembers', 'memoSpaces'] as const;
for (const name of tables) {
await tx
.table(name)
@ -1404,6 +1397,39 @@ db.version(55).upgrade(async (tx) => {
}
});
// v56 — Articles Bulk-Import (docs/plans/articles-bulk-import.md Phase 1).
// Three new tables that ride the standard sync pipeline under the
// articles appId:
//
// articleImportJobs — one row per bulk-import the user kicked off.
// Indexed on `status` for the JobsList tab filter, `[spaceId+status]`
// for the per-Space active-job query the worker projection runs,
// and `_updatedAtIndex` for chronological sort. Lease columns are
// scanned via JS filter — only ~tens of running jobs per user.
// articleImportItems — one row per URL inside a job. `[jobId+state]`
// is the hot index: the JobDetailView range-scans pending+running
// items per job, and the worker pulls "items in state=pending for
// these jobIds". `idx` is plain so the in-list display order
// scrolls without an extra sort key. `state` standalone is used by
// the cross-job retry-failed query.
// articleExtractPickup — short-lived inbox between server-worker
// write and client-pickup-consumer read. `itemId` indexed so the
// consumer can join back to the owning item row. Empty in steady
// state; server-side GC caps it at 24 h.
//
// All three are plaintext (encryption registry: plaintext-allowlist).
// `articleImportItems.url` and `articleExtractPickup.payload` ARE
// user-typed-adjacent content but stay plaintext by necessity — the
// server-side worker reads them without master-key access. Same
// rationale as articles.originalUrl. Once the article is persisted,
// the encrypted copy lives in `articles` and the item carries only an
// articleId pointer.
db.version(56).stores({
articleImportJobs: 'id, status, [spaceId+status], _updatedAtIndex',
articleImportItems: 'id, jobId, [jobId+state], state, idx',
articleExtractPickup: 'id, itemId, _updatedAtIndex',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -7,8 +7,19 @@
*/
import { db } from '$lib/data/database';
import type { LocalArticle, LocalHighlight, LocalArticleTag } from './types';
import type {
LocalArticle,
LocalArticleExtractPickup,
LocalArticleImportItem,
LocalArticleImportJob,
LocalArticleTag,
LocalHighlight,
} from './types';
export const articleTable = db.table<LocalArticle>('articles');
export const articleHighlightTable = db.table<LocalHighlight>('articleHighlights');
export const articleTagTable = db.table<LocalArticleTag>('articleTags');
export const articleImportJobTable = db.table<LocalArticleImportJob>('articleImportJobs');
export const articleImportItemTable = db.table<LocalArticleImportItem>('articleImportItems');
export const articleExtractPickupTable =
db.table<LocalArticleExtractPickup>('articleExtractPickup');

View file

@ -1,12 +1,19 @@
import type { ModuleConfig } from '$lib/data/module-registry';
/**
* Articles module saved web articles + highlights + tag links.
* Articles module saved web articles + highlights + tag links + bulk-
* import jobs.
*
* `articleTags` is a pure junction into globalTags (the core `tags`
* appId). The junction itself syncs under `articles` appId with its
* owning rows, the same pattern every other tagged module uses
* (noteTags, eventTags, contactTags, placeTags, ).
*
* `articleImportJobs` + `articleImportItems` + `articleExtractPickup`
* implement the durable bulk-import pipeline (docs/plans/articles-bulk-
* import.md). All three sync under the articles appId so multi-device
* progress and server-worker state-transitions ride the standard
* sync_changes channel.
*/
export const articlesModuleConfig: ModuleConfig = {
appId: 'articles',
@ -14,5 +21,8 @@ export const articlesModuleConfig: ModuleConfig = {
{ name: 'articles' },
{ name: 'articleHighlights', syncName: 'highlights' },
{ name: 'articleTags' },
{ name: 'articleImportJobs', syncName: 'importJobs' },
{ name: 'articleImportItems', syncName: 'importItems' },
{ name: 'articleExtractPickup', syncName: 'extractPickup' },
],
};

View file

@ -1,7 +1,7 @@
/**
* Articles module Pocket-style read-it-later.
*
* Three Dexie tables:
* Six Dexie tables:
*
* articles saved URLs + extracted Readability content
* (encrypted: title, excerpt, content, htmlContent,
@ -12,6 +12,23 @@
* articleTags pure junction into globalTags. No user-typed
* content lives here tag names/colors are in
* the global tag system (appId: 'tags').
*
* articleImportJobs Bulk-Import job header. Plaintext: counters,
* status, lease metadata. See
* docs/plans/articles-bulk-import.md.
* articleImportItems One row per URL in a bulk job. URL stays
* plaintext (server-worker reads it without
* master-key access same rationale as
* articles.originalUrl). State machine:
* pending extracting extracted
* (saved | duplicate | consent-wall | error |
* cancelled).
* articleExtractPickup Server-write inbox: the worker drops the
* extracted payload here, the client picks it
* up, runs encryptRecord + articleTable.add,
* then deletes the row. Plaintext by necessity
* (server has no master key); empty in steady
* state.
*/
import type { BaseRecord } from '@mana/local-store';
@ -115,3 +132,124 @@ export interface Highlight {
createdAt: string;
updatedAt: string;
}
// ─── Bulk Import (docs/plans/articles-bulk-import.md) ─────
/**
* Job status drives the index list filter and the JobDetailView's
* action bar. `running` is the only state where the worker actively
* pulls items; `paused` lets the user stop progress without losing the
* remaining queue, `cancelled` is a hard stop with all pending items
* flipped to terminal `cancelled`.
*/
export type ArticleImportJobStatus = 'queued' | 'running' | 'paused' | 'done' | 'cancelled';
/**
* Item state machine. Server-side transitions: pending extracting
* extracted (worker has dropped a pickup row). Client-side transitions:
* extracted saved | duplicate | consent-wall (pickup-consumer
* applied the result). Both sides may transition to error (worker after
* 3 retries, client if encryptRecord/add fails). cancelled is terminal
* and only set when the parent job is cancelled before the item ran.
*/
export type ArticleImportItemState =
| 'pending'
| 'extracting'
| 'extracted'
| 'saved'
| 'duplicate'
| 'consent-wall'
| 'error'
| 'cancelled';
export interface LocalArticleImportJob extends BaseRecord {
totalUrls: number;
status: ArticleImportJobStatus;
/** Worker lease — workerId of the apps/api instance that claimed the job. */
leasedBy: string | null;
/** ISO timestamp; lease is dead once `leasedUntil < now`. */
leasedUntil: string | null;
startedAt: string | null;
finishedAt: string | null;
/** Counters mirror the per-item terminal states. Cache for fast list
* rendering truth lives in the item rows. Worker stamps these on
* each transition. */
savedCount: number;
duplicateCount: number;
errorCount: number;
warningCount: number;
}
export interface LocalArticleImportItem extends BaseRecord {
jobId: string;
/** Original position in the user-provided URL list. Drives display order. */
idx: number;
/** Plaintext server worker reads it without master-key access. Same
* rationale as articles.originalUrl / newsArticles.originalUrl. */
url: string;
state: ArticleImportItemState;
/** Pointer into `articles` table once the article is persisted. */
articleId: string | null;
warning: 'probable_consent_wall' | null;
/** Plaintext technical error message ("502 Bad Gateway", "timeout"). */
error: string | null;
attempts: number;
lastAttemptAt: string | null;
}
/**
* Server client handoff. Lives only between worker-write and
* pickup-consumer-read. Empty in steady state.
*/
export interface LocalArticleExtractPickup extends BaseRecord {
itemId: string;
/** The server's ExtractedArticle JSON, plaintext. Mirrors the shape
* in articles/api.ts but lives here as a structural type so the
* database layer doesn't import the API client. */
payload: {
originalUrl: string;
title: string;
excerpt: string | null;
content: string;
htmlContent: string;
author: string | null;
siteName: string | null;
wordCount: number;
readingTimeMinutes: number;
warning?: 'probable_consent_wall';
};
}
// Public DTOs used by views (livequery converters strip the BaseRecord
// internals + map state to display-friendly shapes).
export interface ArticleImportJob {
id: string;
totalUrls: number;
status: ArticleImportJobStatus;
leasedBy: string | null;
leasedUntil: string | null;
startedAt: string | null;
finishedAt: string | null;
savedCount: number;
duplicateCount: number;
errorCount: number;
warningCount: number;
createdAt: string;
updatedAt: string;
}
export interface ArticleImportItem {
id: string;
jobId: string;
idx: number;
url: string;
state: ArticleImportItemState;
articleId: string | null;
warning: 'probable_consent_wall' | null;
error: string | null;
attempts: number;
lastAttemptAt: string | null;
createdAt: string;
updatedAt: string;
}

View file

@ -1,351 +0,0 @@
<script lang="ts">
import SeenplatteScene from '$lib/components/observatory/SeenplatteScene.svelte';
import PlantCard from '$lib/components/observatory/ui/PlantCard.svelte';
import LakeCard from '$lib/components/observatory/ui/LakeCard.svelte';
import RiverCard from '$lib/components/observatory/ui/RiverCard.svelte';
import Leaderboard from '$lib/components/observatory/ui/Leaderboard.svelte';
import CompareView from '$lib/components/observatory/ui/CompareView.svelte';
import TrendsChart from '$lib/components/observatory/ui/TrendsChart.svelte';
import DetailPanel from '$lib/components/observatory/ui/DetailPanel.svelte';
import { createMockEcosystem } from '$lib/components/observatory/data/mockData';
import { LAKES, RIVERS } from '$lib/components/observatory/data/layout';
import type { AppData } from '$lib/components/observatory/data/types';
import { RoutePage } from '$lib/components/shell';
type Tab = 'scene' | 'plants' | 'lakes' | 'rivers' | 'leaderboard' | 'compare' | 'trends';
let activeTab = $state<Tab>('scene');
let selectedApp = $state<AppData | null>(null);
const apps = createMockEcosystem();
const lakes = LAKES;
const rivers = RIVERS;
// Sort apps by score descending for gallery
const sortedApps = apps.toSorted((a, b) => b.score - a.score);
// Group by status
const matureApps = sortedApps.filter((a) => a.status === 'mature');
const productionApps = sortedApps.filter((a) => a.status === 'production');
const betaApps = sortedApps.filter((a) => a.status === 'beta');
const alphaApps = sortedApps.filter((a) => a.status === 'alpha');
const tabs: { id: Tab; label: string; count?: number }[] = [
{ id: 'scene', label: 'Seenplatte' },
{ id: 'plants', label: 'Pflanzen', count: apps.length },
{ id: 'lakes', label: 'Seen', count: lakes.length },
{ id: 'rivers', label: 'Flusse', count: rivers.length },
{ id: 'leaderboard', label: 'Rangliste' },
{ id: 'compare', label: 'Vergleich' },
{ id: 'trends', label: 'Trends' },
];
</script>
<RoutePage appId="observatory">
<div class="observatory-page">
<!-- Tab bar -->
<div class="tab-bar">
{#each tabs as tab}
<button
type="button"
class="tab-btn"
class:active={activeTab === tab.id}
onclick={() => (activeTab = tab.id)}
>
{tab.label}
{#if tab.count}
<span class="tab-count">{tab.count}</span>
{/if}
</button>
{/each}
</div>
<!-- Tab content -->
{#if activeTab === 'scene'}
<div class="scene-container">
<SeenplatteScene />
</div>
{:else if activeTab === 'plants'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Alle Pflanzen</h2>
<p class="gallery-subtitle">
Jede App im Mana-Okosystem als Pflanze — Grosse und Art spiegeln den ManaScore wider
</p>
</div>
{#if matureApps.length}
<div class="gallery-group">
<h3 class="group-label">
<span class="group-dot" style="background: #34d399"></span>
Mature
</h3>
<div class="gallery-scroll">
{#each matureApps as app (app.id)}
<PlantCard {app} onclick={() => (selectedApp = app)} />
{/each}
</div>
</div>
{/if}
{#if productionApps.length}
<div class="gallery-group">
<h3 class="group-label">
<span class="group-dot" style="background: #60a5fa"></span>
Production
</h3>
<div class="gallery-scroll">
{#each productionApps as app (app.id)}
<PlantCard {app} onclick={() => (selectedApp = app)} />
{/each}
</div>
</div>
{/if}
{#if betaApps.length}
<div class="gallery-group">
<h3 class="group-label">
<span class="group-dot" style="background: #fbbf24"></span>
Beta
</h3>
<div class="gallery-scroll">
{#each betaApps as app (app.id)}
<PlantCard {app} onclick={() => (selectedApp = app)} />
{/each}
</div>
</div>
{/if}
{#if alphaApps.length}
<div class="gallery-group">
<h3 class="group-label">
<span class="group-dot" style="background: #f97316"></span>
Alpha
</h3>
<div class="gallery-scroll">
{#each alphaApps as app (app.id)}
<PlantCard {app} onclick={() => (selectedApp = app)} />
{/each}
</div>
</div>
{/if}
</div>
{:else if activeTab === 'lakes'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Alle Seen</h2>
<p class="gallery-subtitle">
Infrastruktur-Services als Gewasser — Klarheit und Fullstand zeigen den Systemzustand
</p>
</div>
<div class="gallery-scroll lakes-scroll">
{#each lakes as lake (lake.id)}
<LakeCard {lake} />
{/each}
</div>
</div>
{:else if activeTab === 'rivers'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Alle Flusse</h2>
<p class="gallery-subtitle">
Datenstrome zwischen den Seen — Geschwindigkeit und Breite zeigen den Durchsatz
</p>
</div>
<div class="gallery-scroll">
{#each rivers as river (river.id)}
<RiverCard {river} />
{/each}
</div>
</div>
{:else if activeTab === 'leaderboard'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Rangliste</h2>
<p class="gallery-subtitle">
Alle Apps sortiert nach Score — klicke auf Spalten zum Sortieren, auf Zeilen fur Details
</p>
</div>
<Leaderboard apps={sortedApps} onselect={(app) => (selectedApp = app)} />
</div>
{:else if activeTab === 'compare'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Vergleich</h2>
<p class="gallery-subtitle">
Wahle bis zu 4 Apps und vergleiche ihre Starken und Schwachen direkt
</p>
</div>
<CompareView {apps} />
</div>
{:else if activeTab === 'trends'}
<div class="gallery-section">
<div class="gallery-header">
<h2 class="gallery-title">Trends</h2>
<p class="gallery-subtitle">
Score-Entwicklung aller Apps uber die Zeit — hover uber eine Linie fur Details
</p>
</div>
<TrendsChart {apps} />
</div>
{/if}
</div>
<!-- Detail panel (shared across tabs) -->
<DetailPanel app={selectedApp} onclose={() => (selectedApp = null)} />
</RoutePage>
<style>
.observatory-page {
display: flex;
flex-direction: column;
gap: 0;
}
/* Tab bar */
.tab-bar {
display: flex;
gap: 2px;
background: rgba(15, 23, 42, 0.4);
padding: 4px;
border-radius: 10px;
margin-bottom: 12px;
}
.tab-btn {
flex: 1;
padding: 8px 16px;
border: none;
background: transparent;
color: #94a3b8;
font-size: 13px;
font-weight: 500;
border-radius: 7px;
cursor: pointer;
transition:
background 0.2s,
color 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.tab-btn:hover {
color: #cbd5e1;
background: rgba(148, 163, 184, 0.08);
}
.tab-btn.active {
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
font-weight: 600;
}
.tab-count {
font-size: 10px;
background: rgba(148, 163, 184, 0.15);
padding: 1px 6px;
border-radius: 8px;
font-weight: 600;
}
.tab-btn.active .tab-count {
background: rgba(59, 130, 246, 0.2);
color: #93c5fd;
}
/* Scene tab */
.scene-container {
height: calc(100vh - 14rem);
min-height: 500px;
width: 100%;
}
/* Gallery sections */
.gallery-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.gallery-header {
margin-bottom: 4px;
}
.gallery-title {
font-size: 20px;
font-weight: 700;
color: hsl(var(--color-foreground));
margin: 0 0 4px 0;
}
.gallery-subtitle {
font-size: 13px;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
/* Horizontal scroll container */
.gallery-scroll {
display: flex;
gap: 14px;
overflow-x: auto;
padding-bottom: 12px;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
}
.gallery-scroll > :global(*) {
scroll-snap-align: start;
}
/* Hide scrollbar but keep functionality */
.gallery-scroll::-webkit-scrollbar {
height: 6px;
}
.gallery-scroll::-webkit-scrollbar-track {
background: rgba(148, 163, 184, 0.05);
border-radius: 3px;
}
.gallery-scroll::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.15);
border-radius: 3px;
}
.gallery-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.25);
}
/* Group headers */
.gallery-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.group-label {
font-size: 12px;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0;
display: flex;
align-items: center;
gap: 6px;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
</style>