feat(manacore/web): add spiral module with activity collection and page

Add spiral module (stores, components, data collection) and /spiral route
to ManaCore web. Wire up navigation entry and command palette shortcut.
Add spiral-db workspace dependency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 11:13:15 +02:00
parent 1cbd9a25a6
commit 9c0613d920
8 changed files with 1720 additions and 344 deletions

View file

@ -67,6 +67,7 @@
"@manacore/shared-uload": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@manacore/spiral-db": "workspace:*",
"@manacore/wallpaper-generator": "workspace:*",
"@calc/shared": "workspace:*",
"@clock/shared": "workspace:*",

View file

@ -0,0 +1,227 @@
/**
* Cross-App Activity Collector
*
* Reads from all cross-app IndexedDB readers and produces
* AppSnapshot objects for the Mana Spiral.
*/
import { MANA_APP_INDEX } from '@manacore/spiral-db';
import {
crossTaskCollection,
crossEventCollection,
crossContactCollection,
crossConversationCollection,
crossFavoriteCollection,
crossImageCollection,
crossAlarmCollection,
crossFileCollection,
crossSongCollection,
crossPresiDeckCollection,
crossSpaceCollection,
crossCardsDeckCollection,
crossCardsCardCollection,
type CrossAppTask,
type CrossAppContact,
type CrossAppImage,
} from '$lib/data/cross-app-stores';
import type { AppSnapshot } from './stores/mana-spiral.svelte';
/**
* Collect snapshots from all cross-app readers.
* Each collection is read once and summarized into an AppSnapshot.
*/
export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
const snapshots: AppSnapshot[] = [];
// Run all reads in parallel
const [
tasks,
events,
contacts,
conversations,
favorites,
images,
alarms,
files,
songs,
decks,
spaces,
cardDecks,
cards,
] = await Promise.all([
safeGetAll(crossTaskCollection),
safeGetAll(crossEventCollection),
safeGetAll(crossContactCollection),
safeGetAll(crossConversationCollection),
safeGetAll(crossFavoriteCollection),
safeGetAll(crossImageCollection),
safeGetAll(crossAlarmCollection),
safeGetAll(crossFileCollection),
safeGetAll(crossSongCollection),
safeGetAll(crossPresiDeckCollection),
safeGetAll(crossSpaceCollection),
safeGetAll(crossCardsDeckCollection),
safeGetAll(crossCardsCardCollection),
]);
// Todo
if (tasks.length > 0) {
const completed = (tasks as CrossAppTask[]).filter((t) => t.isCompleted).length;
snapshots.push({
app: 'Todo',
appIndex: MANA_APP_INDEX.todo,
totalItems: tasks.length,
completedItems: completed,
favoriteItems: 0,
label: `${tasks.length} Tasks (${completed} erledigt)`,
});
}
// Calendar
if (events.length > 0) {
snapshots.push({
app: 'Calendar',
appIndex: MANA_APP_INDEX.calendar,
totalItems: events.length,
completedItems: 0,
favoriteItems: 0,
label: `${events.length} Events`,
});
}
// Contacts
if (contacts.length > 0) {
const favs = (contacts as CrossAppContact[]).filter((c) => c.isFavorite).length;
snapshots.push({
app: 'Contacts',
appIndex: MANA_APP_INDEX.contacts,
totalItems: contacts.length,
completedItems: 0,
favoriteItems: favs,
label: `${contacts.length} Kontakte`,
});
}
// Chat
if (conversations.length > 0) {
snapshots.push({
app: 'Chat',
appIndex: MANA_APP_INDEX.chat,
totalItems: conversations.length,
completedItems: 0,
favoriteItems: 0,
label: `${conversations.length} Gespräche`,
});
}
// Zitare
if (favorites.length > 0) {
snapshots.push({
app: 'Zitare',
appIndex: MANA_APP_INDEX.zitare,
totalItems: favorites.length,
completedItems: 0,
favoriteItems: favorites.length,
label: `${favorites.length} Favoriten`,
});
}
// Picture
if (images.length > 0) {
const favs = (images as CrossAppImage[]).filter((i) => i.isFavorite).length;
snapshots.push({
app: 'Picture',
appIndex: MANA_APP_INDEX.picture,
totalItems: images.length,
completedItems: 0,
favoriteItems: favs,
label: `${images.length} Bilder`,
});
}
// Clock
if (alarms.length > 0) {
snapshots.push({
app: 'Clock',
appIndex: MANA_APP_INDEX.clock,
totalItems: alarms.length,
completedItems: 0,
favoriteItems: 0,
label: `${alarms.length} Alarme`,
});
}
// Storage
if (files.length > 0) {
snapshots.push({
app: 'Storage',
appIndex: MANA_APP_INDEX.storage,
totalItems: files.length,
completedItems: 0,
favoriteItems: 0,
label: `${files.length} Dateien`,
});
}
// Mukke
if (songs.length > 0) {
snapshots.push({
app: 'Mukke',
appIndex: MANA_APP_INDEX.mukke,
totalItems: songs.length,
completedItems: 0,
favoriteItems: 0,
label: `${songs.length} Songs`,
});
}
// Presi
if (decks.length > 0) {
snapshots.push({
app: 'Presi',
appIndex: MANA_APP_INDEX.presi,
totalItems: decks.length,
completedItems: 0,
favoriteItems: 0,
label: `${decks.length} Präsentationen`,
});
}
// Context
if (spaces.length > 0) {
snapshots.push({
app: 'Context',
appIndex: MANA_APP_INDEX.context,
totalItems: spaces.length,
completedItems: 0,
favoriteItems: 0,
label: `${spaces.length} Spaces`,
});
}
// Cards
if (cardDecks.length > 0 || cards.length > 0) {
snapshots.push({
app: 'Cards',
appIndex: MANA_APP_INDEX.cards,
totalItems: cards.length,
completedItems: 0,
favoriteItems: 0,
label: `${cardDecks.length} Decks, ${cards.length} Karten`,
});
}
return snapshots;
}
/**
* Safe wrapper for collection.getAll() returns empty array on error
* (e.g. if the other app's DB doesn't exist yet)
*/
async function safeGetAll(collection: { getAll: () => Promise<unknown[]> }): Promise<unknown[]> {
try {
return await collection.getAll();
} catch {
return [];
}
}

View file

@ -0,0 +1,168 @@
<script lang="ts">
import type { SpiralImage } from '@manacore/spiral-db';
import { spiralToXY, xyToSpiral } from '@manacore/spiral-db';
interface Props {
image: SpiralImage;
scale?: number;
showGrid?: boolean;
highlightIndex?: number | null;
onPixelClick?: (index: number, x: number, y: number) => void;
}
let {
image,
scale = 10,
showGrid = false,
highlightIndex = null,
onPixelClick,
}: Props = $props();
let canvas: HTMLCanvasElement;
let hoveredIndex = $state<number | null>(null);
$effect(() => {
if (!canvas || !image) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const { width, height, pixels } = image;
canvas.width = width * scale;
canvas.height = height * scale;
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const offset = (y * width + x) * 3;
const r = pixels[offset];
const g = pixels[offset + 1];
const b = pixels[offset + 2];
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
ctx.fillRect(x * scale, y * scale, scale, scale);
}
}
if (showGrid && scale >= 8) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineWidth = 1;
for (let x = 0; x <= width; x++) {
ctx.beginPath();
ctx.moveTo(x * scale, 0);
ctx.lineTo(x * scale, height * scale);
ctx.stroke();
}
for (let y = 0; y <= height; y++) {
ctx.beginPath();
ctx.moveTo(0, y * scale);
ctx.lineTo(width * scale, y * scale);
ctx.stroke();
}
}
// Center pixel highlight
const center = Math.floor(width / 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = 2;
ctx.strokeRect(center * scale, center * scale, scale, scale);
if (highlightIndex !== null && highlightIndex >= 0) {
const point = spiralToXY(highlightIndex, width);
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 2;
ctx.strokeRect(point.x * scale, point.y * scale, scale, scale);
}
if (hoveredIndex !== null) {
const point = spiralToXY(hoveredIndex, width);
ctx.strokeStyle = '#8b5cf6';
ctx.lineWidth = 2;
ctx.strokeRect(point.x * scale, point.y * scale, scale, scale);
}
});
function handleMouseMove(e: MouseEvent) {
if (!canvas || !image) return;
const rect = canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / scale);
const y = Math.floor((e.clientY - rect.top) / scale);
if (x >= 0 && x < image.width && y >= 0 && y < image.height) {
hoveredIndex = xyToSpiral(x, y, image.width);
} else {
hoveredIndex = null;
}
}
function handleMouseLeave() {
hoveredIndex = null;
}
function handleClick(e: MouseEvent) {
if (!canvas || !image || !onPixelClick) return;
const rect = canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / scale);
const y = Math.floor((e.clientY - rect.top) / scale);
if (x >= 0 && x < image.width && y >= 0 && y < image.height) {
const index = xyToSpiral(x, y, image.width);
onPixelClick(index, x, y);
}
}
</script>
<div class="spiral-canvas-container">
<canvas
bind:this={canvas}
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
onclick={handleClick}
class="spiral-canvas"
class:clickable={!!onPixelClick}
></canvas>
{#if hoveredIndex !== null}
<div class="pixel-info">
Pixel #{hoveredIndex}
</div>
{/if}
</div>
<style>
.spiral-canvas-container {
position: relative;
display: inline-block;
}
.spiral-canvas {
border-radius: 12px;
box-shadow:
0 4px 20px rgba(0, 0, 0, 0.3),
0 0 40px rgba(99, 102, 241, 0.1);
}
.spiral-canvas.clickable {
cursor: pointer;
}
.pixel-info {
position: absolute;
bottom: -30px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,12 @@
/**
* Mana Spiral module barrel exports.
*/
export { manaSpiralStore } from './stores/mana-spiral.svelte';
export type {
ManaActivityData,
ManaActivityRecord,
ManaSpiralStats,
AppSnapshot,
} from './stores/mana-spiral.svelte';
export { collectAppSnapshots } from './collect';

View file

@ -0,0 +1,231 @@
/**
* Mana Spiral Store
*
* Unified cross-app spiral visualization.
* Collects activity snapshots from all apps' IndexedDB collections
* and encodes them into a single SpiralDB image.
*/
import {
SpiralDB,
createManaActivitySchema,
MANA_APP_INDEX,
MANA_APP_NAMES,
MANA_EVENT_TYPE,
MANA_EVENT_NAMES,
type SpiralImage,
type SpiralRecord,
exportToPngBytes,
importFromPngBytes,
downloadPng,
} from '@manacore/spiral-db';
// ─── Types ─────────────────────────────────────────────────
export interface ManaActivityData extends Record<string, unknown> {
id: number;
app: number;
eventType: number;
value: number;
createdAt: Date;
label: string;
}
export interface ManaActivityRecord extends SpiralRecord<ManaActivityData> {}
export interface ManaSpiralStats {
imageSize: number;
totalPixels: number;
usedPixels: number;
totalRecords: number;
activeRecords: number;
deletedRecords: number;
currentRing: number;
compressionRatio: number;
}
export interface AppSnapshot {
app: string;
appIndex: number;
totalItems: number;
completedItems: number;
favoriteItems: number;
label: string;
}
// ─── Store ─────────────────────────────────────────────────
class ManaSpiralStore {
private db: SpiralDB<ManaActivityData>;
image = $state<SpiralImage | null>(null);
stats = $state<ManaSpiralStats | null>(null);
records = $state<ManaActivityRecord[]>([]);
snapshots = $state<AppSnapshot[]>([]);
isLoading = $state(false);
error = $state<string | null>(null);
lastCollectedAt = $state<Date | null>(null);
constructor() {
this.db = new SpiralDB<ManaActivityData>({
schema: createManaActivitySchema(),
compression: true,
});
this.updateState();
}
private updateState() {
this.image = this.db.getImage();
this.records = this.db.getAll();
const dbStats = this.db.getStats();
const jsonSize = JSON.stringify(this.records.map((r) => r.data)).length || 1;
const pixelBytes = Math.ceil((dbStats.usedPixels * 3) / 8);
this.stats = {
...dbStats,
compressionRatio: Math.round((1 - pixelBytes / jsonSize) * 100),
};
}
/**
* Collect snapshots from cross-app readers and build the spiral.
* Each app contributes a snapshot event with its item counts.
*/
collectFromApps(appSnapshots: AppSnapshot[]) {
// Reset DB with fresh data
this.db = new SpiralDB<ManaActivityData>({
schema: createManaActivitySchema(),
compression: true,
});
this.snapshots = appSnapshots;
const now = new Date();
for (const snap of appSnapshots) {
if (snap.totalItems === 0) continue;
// Snapshot event: total count
this.db.insert({
id: 0,
app: snap.appIndex,
eventType: MANA_EVENT_TYPE.snapshot,
value: snap.totalItems,
createdAt: now,
label: snap.label,
});
// Completed event (if any)
if (snap.completedItems > 0) {
this.db.insert({
id: 0,
app: snap.appIndex,
eventType: MANA_EVENT_TYPE.completed,
value: snap.completedItems,
createdAt: now,
label: `${snap.app}: ${snap.completedItems} erledigt`,
});
}
// Favorites event (if any)
if (snap.favoriteItems > 0) {
this.db.insert({
id: 0,
app: snap.appIndex,
eventType: MANA_EVENT_TYPE.favorited,
value: snap.favoriteItems,
createdAt: now,
label: `${snap.app}: ${snap.favoriteItems} Favoriten`,
});
}
}
this.lastCollectedAt = now;
this.updateState();
}
/**
* Get the display name for an app index
*/
getAppName(index: number): string {
return MANA_APP_NAMES[index] ?? 'unknown';
}
/**
* Get the display name for an event type index
*/
getEventName(index: number): string {
return MANA_EVENT_NAMES[index] ?? 'unknown';
}
/**
* Get records grouped by app
*/
getRecordsByApp(): Map<string, ManaActivityRecord[]> {
const map = new Map<string, ManaActivityRecord[]>();
for (const record of this.records) {
const appName = this.getAppName(record.data.app);
const list = map.get(appName) ?? [];
list.push(record);
map.set(appName, list);
}
return map;
}
/**
* Download spiral as PNG
*/
downloadPng(filename = 'mana-spiral.png') {
if (this.image) {
downloadPng(this.image, filename);
}
}
/**
* Get PNG bytes for sharing
*/
getPngBytes(): Uint8Array | null {
if (!this.image) return null;
return exportToPngBytes(this.image);
}
/**
* Import from a PNG file
*/
async importFromPng(file: File): Promise<{ success: boolean; error?: string }> {
try {
this.isLoading = true;
this.error = null;
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const image = await importFromPngBytes(bytes);
this.db = SpiralDB.fromImage<ManaActivityData>(image, createManaActivitySchema());
this.updateState();
return { success: true };
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
this.error = errorMessage;
return { success: false, error: errorMessage };
} finally {
this.isLoading = false;
}
}
/**
* Clear all data
*/
clear() {
this.db = new SpiralDB<ManaActivityData>({
schema: createManaActivitySchema(),
compression: true,
});
this.snapshots = [];
this.lastCollectedAt = null;
this.updateState();
}
}
export const manaSpiralStore = new ManaSpiralStore();

View file

@ -135,6 +135,7 @@
const baseNavItems: PillNavItem[] = [
{ href: '/home', label: 'Home', icon: 'home' },
{ href: '/dashboard', label: 'Dashboard', icon: 'grid' },
{ href: '/spiral', label: 'Spiral', icon: 'spiral' },
{ href: '/observatory', label: 'Observatory', icon: 'eye' },
{ href: '/credits', label: 'Credits', icon: 'creditCard' },
{ href: '/gifts', label: 'Geschenke', icon: 'gift' },
@ -307,6 +308,12 @@
category: 'Navigation',
onExecute: () => goto('/dashboard'),
},
{
id: 'spiral',
label: 'Mana Spiral',
category: 'Navigation',
onExecute: () => goto('/spiral'),
},
{ id: 'credits', label: 'Credits', category: 'Navigation', onExecute: () => goto('/credits') },
{ id: 'apps', label: 'Alle Apps', category: 'Navigation', onExecute: () => goto('/apps') },
{

View file

@ -0,0 +1,631 @@
<script lang="ts">
import { onMount } from 'svelte';
import { COLORS } from '@manacore/spiral-db';
import type { ColorDefinition } from '@manacore/spiral-db';
import SpiralCanvas from '$lib/modules/spiral/components/SpiralCanvas.svelte';
import { manaSpiralStore } from '$lib/modules/spiral';
import type { AppSnapshot } from '$lib/modules/spiral';
import { collectAppSnapshots } from '$lib/modules/spiral';
const colorsArray: ColorDefinition[] = Object.values(COLORS);
// UI state
let scale = $state(10);
let showGrid = $state(false);
let selectedPixel = $state<number | null>(null);
let isCollecting = $state(false);
let fileInput: HTMLInputElement;
// App icons for display
const APP_ICONS: Record<string, string> = {
Todo: 'check-square',
Calendar: 'calendar',
Contacts: 'users',
Chat: 'message-circle',
Zitare: 'quote',
Picture: 'image',
Clock: 'clock',
Storage: 'hard-drive',
Mukke: 'music',
Presi: 'presentation',
Context: 'file-text',
Cards: 'layers',
};
// Derived
let recordsByApp = $derived(manaSpiralStore.getRecordsByApp());
async function handleCollect() {
isCollecting = true;
try {
const snapshots = await collectAppSnapshots();
manaSpiralStore.collectFromApps(snapshots);
} finally {
isCollecting = false;
}
}
function handlePixelClick(index: number) {
selectedPixel = selectedPixel === index ? null : index;
}
function handleDownload() {
manaSpiralStore.downloadPng();
}
function handleImportClick() {
fileInput?.click();
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const result = await manaSpiralStore.importFromPng(file);
if (!result.success) {
alert(`Import fehlgeschlagen: ${result.error}`);
}
input.value = '';
}
function handleClear() {
if (confirm('Alle Spiral-Daten löschen?')) {
manaSpiralStore.clear();
}
}
// Auto-collect on mount
onMount(() => {
handleCollect();
});
</script>
<svelte:head>
<title>Mana Spiral</title>
</svelte:head>
<div class="spiral-page">
<header class="page-header">
<h1 class="page-title">Mana Spiral</h1>
<p class="page-subtitle">Dein digitaler Fussabdruck — alle Apps in einer Spirale</p>
</header>
<div class="content-grid">
<!-- Visualization -->
<section class="section viz-section">
<div class="viz-header">
<h2>Visualisierung</h2>
<div class="viz-controls">
<label class="control">
<span>Zoom</span>
<input type="range" min="4" max="20" bind:value={scale} />
<span class="mono">{scale}x</span>
</label>
<label class="control">
<input type="checkbox" bind:checked={showGrid} />
<span>Grid</span>
</label>
</div>
</div>
<div class="viz-container">
{#if manaSpiralStore.image}
<SpiralCanvas
image={manaSpiralStore.image}
{scale}
{showGrid}
highlightIndex={selectedPixel}
onPixelClick={handlePixelClick}
/>
{:else}
<div class="empty-state">
<p>Keine Daten. Klicke "Daten sammeln" um deine Spirale zu generieren.</p>
</div>
{/if}
</div>
{#if selectedPixel !== null}
<div class="pixel-detail">
Pixel <code>#{selectedPixel}</code>
</div>
{/if}
</section>
<!-- Stats -->
{#if manaSpiralStore.stats}
<section class="section">
<h2 class="section-title">Statistiken</h2>
<div class="stats-grid">
<div class="stat">
<span class="stat-value">
{manaSpiralStore.stats.imageSize}x{manaSpiralStore.stats.imageSize}
</span>
<span class="stat-label">Bildgrösse</span>
</div>
<div class="stat">
<span class="stat-value">{manaSpiralStore.stats.activeRecords}</span>
<span class="stat-label">Events</span>
</div>
<div class="stat">
<span class="stat-value">{manaSpiralStore.stats.usedPixels}</span>
<span class="stat-label">Pixel belegt</span>
</div>
<div class="stat highlight">
<span class="stat-value">{manaSpiralStore.stats.compressionRatio}%</span>
<span class="stat-label">Kompression</span>
</div>
<div class="stat">
<span class="stat-value">Ring {manaSpiralStore.stats.currentRing}</span>
<span class="stat-label">Aktueller Ring</span>
</div>
<div class="stat">
<span class="stat-value">{manaSpiralStore.snapshots.length}</span>
<span class="stat-label">Apps aktiv</span>
</div>
</div>
{#if manaSpiralStore.lastCollectedAt}
<p class="collected-at">
Zuletzt gesammelt: {manaSpiralStore.lastCollectedAt.toLocaleTimeString('de-DE')}
</p>
{/if}
</section>
{/if}
<!-- App Breakdown -->
<section class="section">
<h2 class="section-title">
Apps
{#if manaSpiralStore.snapshots.length > 0}
<span class="badge">{manaSpiralStore.snapshots.length}</span>
{/if}
</h2>
{#if manaSpiralStore.snapshots.length === 0}
<p class="empty-hint">Noch keine App-Daten gesammelt.</p>
{:else}
<div class="app-list">
{#each manaSpiralStore.snapshots as snap}
{@const appRecords = recordsByApp.get(snap.app.toLowerCase()) ?? []}
<div class="app-card">
<div class="app-header">
<span class="app-name">{snap.app}</span>
<span class="app-count">{snap.totalItems}</span>
</div>
<div class="app-bar">
<div
class="app-bar-fill"
style="width: {Math.min(
100,
(snap.totalItems /
Math.max(1, ...manaSpiralStore.snapshots.map((s) => s.totalItems))) *
100
)}%"
></div>
</div>
<div class="app-details">
<span class="app-label">{snap.label}</span>
<span class="app-events mono">{appRecords.length} Events</span>
</div>
</div>
{/each}
</div>
{/if}
</section>
<!-- Color Legend -->
<section class="section">
<h2 class="section-title">Farbpalette (3-Bit)</h2>
<div class="color-legend">
{#each colorsArray as color}
<div class="color-item">
<span
class="color-swatch"
style="background: rgb({color.rgb.r}, {color.rgb.g}, {color.rgb.b})"
></span>
<span class="color-name">{color.name}</span>
<span class="color-bits mono">{color.bits.join('')}</span>
</div>
{/each}
</div>
</section>
<!-- Actions -->
<section class="section">
<h2 class="section-title">Aktionen</h2>
<div class="actions">
<button class="btn btn-primary" onclick={handleCollect} disabled={isCollecting}>
{isCollecting ? 'Sammle...' : 'Daten sammeln'}
</button>
<button
class="btn"
onclick={handleDownload}
disabled={!manaSpiralStore.stats || manaSpiralStore.stats.totalRecords === 0}
>
PNG herunterladen
</button>
<button class="btn" onclick={handleImportClick}> PNG importieren </button>
<button
class="btn btn-danger"
onclick={handleClear}
disabled={!manaSpiralStore.stats || manaSpiralStore.stats.totalRecords === 0}
>
Zurücksetzen
</button>
</div>
<div class="info-box">
<h4>Mana Spiral</h4>
<p>
Die Mana Spiral sammelt Aktivitätsdaten aus allen deinen Apps und kodiert sie als farbige
Pixel in einem Spiralmuster. Jeder Pixel speichert 3 Bit (8 Farben). Das Bild wächst von
der Mitte nach aussen — je mehr du die Apps nutzt, desto grösser wird deine Spirale.
Exportiere sie als PNG oder nutze sie als Wallpaper.
</p>
</div>
</section>
</div>
<!-- Hidden file input -->
<input
bind:this={fileInput}
type="file"
accept=".png"
class="hidden"
onchange={handleFileSelect}
/>
</div>
<style>
.spiral-page {
padding: 0;
}
.page-header {
margin-bottom: 2rem;
}
.page-title {
font-size: 1.75rem;
font-weight: 700;
color: var(--color-foreground);
margin: 0;
}
.page-subtitle {
color: var(--color-muted-foreground);
font-size: 0.875rem;
margin: 0.25rem 0 0;
}
.content-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section {
background: var(--color-card, rgba(255, 255, 255, 0.05));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
border-radius: 12px;
padding: 1.25rem;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-foreground);
margin: 0 0 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge {
background: var(--color-primary, #6366f1);
color: white;
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: 999px;
}
/* Visualization */
.viz-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.viz-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.viz-header h2 {
font-size: 1rem;
font-weight: 600;
color: var(--color-foreground);
margin: 0;
}
.viz-controls {
display: flex;
gap: 1.5rem;
align-items: center;
flex-wrap: wrap;
}
.control {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-muted-foreground);
}
.control input[type='range'] {
width: 100px;
}
.mono {
font-family: monospace;
font-size: 0.8rem;
}
.viz-container {
display: flex;
justify-content: center;
padding: 2rem;
background: radial-gradient(ellipse at center, rgba(99, 102, 241, 0.05) 0%, transparent 70%);
border-radius: 8px;
overflow: auto;
}
.empty-state {
text-align: center;
padding: 3rem;
color: var(--color-muted-foreground);
}
.pixel-detail {
text-align: center;
color: var(--color-muted-foreground);
font-size: 0.875rem;
}
.pixel-detail code {
background: var(--color-background, rgba(0, 0, 0, 0.2));
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-family: monospace;
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
margin-bottom: 0.75rem;
}
@media (min-width: 640px) {
.stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(6, 1fr);
}
}
.stat {
text-align: center;
padding: 0.75rem;
background: var(--color-background, rgba(0, 0, 0, 0.2));
border-radius: 8px;
}
.stat-value {
display: block;
font-size: 1.25rem;
font-weight: 700;
color: var(--color-foreground);
font-family: monospace;
}
.stat-label {
display: block;
font-size: 0.7rem;
color: var(--color-muted-foreground);
margin-top: 0.25rem;
}
.stat.highlight {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(139, 92, 246, 0.2));
}
.collected-at {
font-size: 0.75rem;
color: var(--color-muted-foreground);
text-align: right;
margin: 0;
}
/* App Breakdown */
.app-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.app-card {
padding: 0.75rem;
background: var(--color-background, rgba(0, 0, 0, 0.2));
border-radius: 8px;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.app-name {
font-weight: 600;
font-size: 0.875rem;
color: var(--color-foreground);
}
.app-count {
font-family: monospace;
font-size: 1rem;
font-weight: 700;
color: var(--color-primary, #6366f1);
}
.app-bar {
height: 4px;
background: var(--color-border, rgba(255, 255, 255, 0.1));
border-radius: 2px;
overflow: hidden;
margin-bottom: 0.375rem;
}
.app-bar-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 2px;
transition: width 0.5s ease;
}
.app-details {
display: flex;
justify-content: space-between;
align-items: center;
}
.app-label {
font-size: 0.75rem;
color: var(--color-muted-foreground);
}
.app-events {
font-size: 0.7rem;
color: var(--color-muted-foreground);
}
.empty-hint {
color: var(--color-muted-foreground);
font-size: 0.875rem;
text-align: center;
padding: 1rem;
margin: 0;
}
/* Color Legend */
.color-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.color-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--color-muted-foreground);
}
.color-swatch {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.2);
flex-shrink: 0;
}
.color-bits {
opacity: 0.6;
}
/* Actions */
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
background: var(--color-background, rgba(0, 0, 0, 0.2));
color: var(--color-foreground);
cursor: pointer;
transition: all 0.15s;
}
.btn:hover:not(:disabled) {
background: var(--color-accent, rgba(255, 255, 255, 0.1));
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-primary, #6366f1);
border-color: var(--color-primary, #6366f1);
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
}
.info-box {
padding: 1rem;
background: var(--color-background, rgba(0, 0, 0, 0.2));
border-radius: 8px;
border-left: 4px solid var(--color-primary, #6366f1);
}
.info-box h4 {
font-size: 0.875rem;
font-weight: 600;
margin: 0 0 0.5rem;
color: var(--color-foreground);
}
.info-box p {
font-size: 0.8rem;
color: var(--color-muted-foreground);
margin: 0;
line-height: 1.5;
}
.hidden {
display: none;
}
</style>

787
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff