feat(todo): inline "Neues Board" card as last column in all layouts

Replace the toolbar add-column button with an inline placeholder card
that appears as the last column/sheet in the board when in edit mode.
Styled with dashed border and + icon, matching each layout's aesthetic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 13:58:01 +02:00
parent dc4ba0a39c
commit 41ca11eddc
8 changed files with 679 additions and 46 deletions

View file

@ -0,0 +1,248 @@
{
"generatedAt": "2026-03-31T11:53:48.040Z",
"overallScore": 76,
"scores": {
"sharedPackages": 90,
"iconConsistency": 89,
"modalConsistency": 24,
"errorHandling": 20,
"i18nCoverage": 86,
"localFirst": 93,
"styleConsistency": 87
},
"weights": {
"sharedPackages": 25,
"iconConsistency": 15,
"modalConsistency": 10,
"errorHandling": 10,
"i18nCoverage": 15,
"localFirst": 10,
"styleConsistency": 15
},
"details": {
"icons": {
"adoption": 89,
"phosphorFiles": 353,
"inlineSvgFiles": 45,
"perApp": {
"calc": {
"phosphor": 1,
"inlineSvg": 0
},
"calendar": {
"phosphor": 34,
"inlineSvg": 0
},
"chat": {
"phosphor": 18,
"inlineSvg": 0
},
"citycorners": {
"phosphor": 9,
"inlineSvg": 0
},
"clock": {
"phosphor": 3,
"inlineSvg": 7
},
"contacts": {
"phosphor": 27,
"inlineSvg": 4
},
"context": {
"phosphor": 13,
"inlineSvg": 0
},
"inventar": {
"phosphor": 12,
"inlineSvg": 1
},
"manacore": {
"phosphor": 34,
"inlineSvg": 25
},
"manadeck": {
"phosphor": 2,
"inlineSvg": 1
},
"manavoxel": {
"phosphor": 0,
"inlineSvg": 0
},
"matrix": {
"phosphor": 26,
"inlineSvg": 0
},
"moodlit": {
"phosphor": 5,
"inlineSvg": 0
},
"mukke": {
"phosphor": 21,
"inlineSvg": 0
},
"news": {
"phosphor": 1,
"inlineSvg": 0
},
"nutriphi": {
"phosphor": 6,
"inlineSvg": 1
},
"photos": {
"phosphor": 11,
"inlineSvg": 3
},
"picture": {
"phosphor": 27,
"inlineSvg": 0
},
"planta": {
"phosphor": 0,
"inlineSvg": 0
},
"playground": {
"phosphor": 4,
"inlineSvg": 1
},
"presi": {
"phosphor": 6,
"inlineSvg": 0
},
"questions": {
"phosphor": 7,
"inlineSvg": 0
},
"skilltree": {
"phosphor": 12,
"inlineSvg": 1
},
"storage": {
"phosphor": 25,
"inlineSvg": 1
},
"times": {
"phosphor": 13,
"inlineSvg": 0
},
"todo": {
"phosphor": 22,
"inlineSvg": 0
},
"uload": {
"phosphor": 3,
"inlineSvg": 0
},
"wisekeep": {
"phosphor": 2,
"inlineSvg": 0
},
"zitare": {
"phosphor": 9,
"inlineSvg": 0
}
}
},
"modals": {
"adoption": 24,
"total": 51,
"sharedUsage": 12,
"focusTrapUsage": 7
},
"packages": {
"coreAdoption": 90,
"totalApps": 29,
"perPackage": {
"Auth": {
"count": 29,
"total": 29,
"adoption": 100
},
"UI": {
"count": 28,
"total": 29,
"adoption": 97
},
"Theme": {
"count": 24,
"total": 29,
"adoption": 83
},
"Branding": {
"count": 28,
"total": 29,
"adoption": 97
},
"i18n": {
"count": 23,
"total": 29,
"adoption": 79
},
"Error Tracking": {
"count": 24,
"total": 29,
"adoption": 83
},
"Icons": {
"count": 25,
"total": 29,
"adoption": 86
},
"Local Store": {
"count": 27,
"total": 29,
"adoption": 93
}
}
},
"errors": {
"adoption": 20,
"inline": 193,
"shared": 47
},
"i18n": {
"adoption": 86,
"withI18n": 25,
"without": 4
},
"localFirst": {
"adoption": 93,
"count": 27
},
"styles": {
"themeAdoption": 83,
"tailwindAdoption": 90
}
},
"apps": [
"calc",
"calendar",
"chat",
"citycorners",
"clock",
"contacts",
"context",
"inventar",
"manacore",
"manadeck",
"manavoxel",
"matrix",
"moodlit",
"mukke",
"news",
"nutriphi",
"photos",
"picture",
"planta",
"playground",
"presi",
"questions",
"skilltree",
"storage",
"times",
"todo",
"uload",
"wisekeep",
"zitare"
]
}

View file

@ -0,0 +1,252 @@
---
import Layout from '../../layouts/Layout.astro';
import Navbar from '../../components/navigation/Navbar.astro';
import Footer from '../../components/navigation/Footer.astro';
import Section from '../../components/content/Section.astro';
import Container from '../../components/layout/Container.astro';
import HeroSection from '../../components/content/HeroSection.astro';
import ecosystemData from '../../data/ecosystem-health.json';
const { overallScore, scores, weights, details } = ecosystemData;
function getScoreColor(score: number): string {
if (score >= 85) return 'text-emerald-500';
if (score >= 70) return 'text-green-500';
if (score >= 50) return 'text-yellow-500';
if (score >= 30) return 'text-orange-500';
return 'text-red-500';
}
function getBarColor(score: number): string {
if (score >= 85) return 'bg-emerald-500';
if (score >= 70) return 'bg-green-500';
if (score >= 50) return 'bg-yellow-500';
if (score >= 30) return 'bg-orange-500';
return 'bg-red-500';
}
function getBarBg(score: number): string {
if (score >= 85) return 'bg-emerald-500/10';
if (score >= 70) return 'bg-green-500/10';
if (score >= 50) return 'bg-yellow-500/10';
if (score >= 30) return 'bg-orange-500/10';
return 'bg-red-500/10';
}
const categories = [
{
key: 'sharedPackages',
label: 'Shared Packages',
icon: '📦',
description: 'Adoption der 6 Core-Packages über alle Apps',
},
{
key: 'iconConsistency',
label: 'Icon Consistency',
icon: '🎨',
description: 'Phosphor Icons vs inline SVGs',
},
{
key: 'modalConsistency',
label: 'Modal Consistency',
icon: '🪟',
description: 'shared-ui Modal vs Custom-Implementierungen',
},
{
key: 'errorHandling',
label: 'Error Handling',
icon: '⚠️',
description: 'Shared Helper vs inline instanceof Error',
},
{
key: 'i18nCoverage',
label: 'i18n Coverage',
icon: '🌍',
description: 'Apps mit Internationalisierung',
},
{
key: 'localFirst',
label: 'Local-First',
icon: '💾',
description: 'Apps mit Offline-fähigem Local Store',
},
{
key: 'styleConsistency',
label: 'Style Consistency',
icon: '🎭',
description: 'Theme-Variablen + Tailwind CSS Nutzung',
},
];
const packageDetails = details.packages.perPackage;
const generatedDate = new Date(ecosystemData.generatedAt).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
---
<Layout
title="Ecosystem Health - ManaScore"
description="Ecosystem-weite Konsistenz- und Qualitätsmetriken für das ManaCore Ökosystem"
>
<Navbar />
<HeroSection
badge="ManaScore"
title="Ecosystem Health"
subtitle="Wie konsistent und einheitlich ist das ManaCore Ökosystem? Diese Metriken messen die Adoption gemeinsamer Packages, Patterns und Konventionen über alle Apps hinweg."
/>
<Section>
<Container>
<div class="mx-auto max-w-4xl">
<!-- Overall Score -->
<div class="mb-12 text-center">
<div class={`text-7xl font-bold ${getScoreColor(overallScore)} mb-2`}>
{overallScore}
</div>
<div class="text-muted-foreground text-lg">Ecosystem Health Score</div>
<div class="text-muted-foreground/60 mt-1 text-sm">
{details.packages.totalApps} Web Apps analysiert &middot; Stand: {generatedDate}
</div>
</div>
<!-- Score Breakdown -->
<div class="mb-12 space-y-4">
<h2 class="text-foreground mb-6 text-xl font-semibold">Score Breakdown</h2>
{
categories.map(({ key, label, icon, description }) => {
const score = scores[key as keyof typeof scores];
const weight = weights[key as keyof typeof weights];
return (
<div class="border-border/50 rounded-xl border bg-gradient-to-br from-white/5 to-white/[0.02] p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3">
<span class="text-lg">{icon}</span>
<div>
<div class="text-foreground font-medium text-sm">{label}</div>
<div class="text-muted-foreground text-xs">{description}</div>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-muted-foreground text-xs">Gewicht: {weight}%</span>
<span class={`text-2xl font-bold ${getScoreColor(score)}`}>{score}%</span>
</div>
</div>
<div class={`h-2 rounded-full ${getBarBg(score)}`}>
<div
class={`h-2 rounded-full transition-all ${getBarColor(score)}`}
style={`width: ${score}%`}
/>
</div>
</div>
);
})
}
</div>
<!-- Shared Package Details -->
<div class="mb-12">
<h2 class="text-foreground mb-6 text-xl font-semibold">Shared Package Adoption</h2>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
{
Object.entries(packageDetails).map(([name, data]: [string, any]) => (
<div class="border-border/50 rounded-lg border bg-gradient-to-br from-white/5 to-white/[0.02] p-4 text-center">
<div class={`text-2xl font-bold ${getScoreColor(data.adoption)}`}>
{data.adoption}%
</div>
<div class="text-foreground mt-1 text-sm font-medium">{name}</div>
<div class="text-muted-foreground text-xs">
{data.count}/{data.total} Apps
</div>
</div>
))
}
</div>
</div>
<!-- Icon Details -->
<div class="mb-12">
<h2 class="text-foreground mb-6 text-xl font-semibold">Icon Consistency Details</h2>
<div class="grid grid-cols-2 gap-4">
<div
class="border-border/50 rounded-lg border bg-gradient-to-br from-white/5 to-white/[0.02] p-4"
>
<div class="text-emerald-500 text-3xl font-bold">{details.icons.phosphorFiles}</div>
<div class="text-muted-foreground text-sm">Dateien mit Phosphor Icons</div>
</div>
<div
class="border-border/50 rounded-lg border bg-gradient-to-br from-white/5 to-white/[0.02] p-4"
>
<div
class={`text-3xl font-bold ${details.icons.inlineSvgFiles > 0 ? 'text-orange-500' : 'text-emerald-500'}`}
>
{details.icons.inlineSvgFiles}
</div>
<div class="text-muted-foreground text-sm">Dateien mit Inline SVGs</div>
</div>
</div>
</div>
<!-- Modal Details -->
<div class="mb-12">
<h2 class="text-foreground mb-6 text-xl font-semibold">Modal Consistency</h2>
<div class="grid grid-cols-3 gap-4">
<div
class="border-border/50 rounded-lg border bg-gradient-to-br from-white/5 to-white/[0.02] p-4 text-center"
>
<div class="text-foreground text-3xl font-bold">{details.modals.total}</div>
<div class="text-muted-foreground text-sm">Modals gesamt</div>
</div>
<div
class="border-border/50 rounded-lg border bg-gradient-to-br from-white/5 to-white/[0.02] p-4 text-center"
>
<div class="text-emerald-500 text-3xl font-bold">{details.modals.sharedUsage}</div>
<div class="text-muted-foreground text-sm">Nutzen shared-ui Modal</div>
</div>
<div
class="border-border/50 rounded-lg border bg-gradient-to-br from-white/5 to-white/[0.02] p-4 text-center"
>
<div class="text-foreground text-3xl font-bold">{details.modals.focusTrapUsage}</div>
<div class="text-muted-foreground text-sm">Mit focusTrap (A11y)</div>
</div>
</div>
</div>
<!-- Error Handling -->
<div class="mb-12">
<h2 class="text-foreground mb-6 text-xl font-semibold">Error Handling Patterns</h2>
<div class="grid grid-cols-2 gap-4">
<div
class="border-border/50 rounded-lg border bg-gradient-to-br from-white/5 to-white/[0.02] p-4"
>
<div class="text-emerald-500 text-3xl font-bold">{details.errors.shared}</div>
<div class="text-muted-foreground text-sm">Shared Helper Nutzungen</div>
</div>
<div
class="border-border/50 rounded-lg border bg-gradient-to-br from-white/5 to-white/[0.02] p-4"
>
<div
class={`text-3xl font-bold ${details.errors.inline > 20 ? 'text-orange-500' : 'text-yellow-500'}`}
>
{details.errors.inline}
</div>
<div class="text-muted-foreground text-sm">Inline instanceof Error</div>
</div>
</div>
</div>
<!-- Back link -->
<div class="text-center">
<a href="/manascore" class="text-primary hover:text-primary/80 text-sm transition-colors">
&larr; Zurück zur ManaScore Übersicht
</a>
</div>
</div>
</Container>
</Section>
<Footer />
</Layout>

View file

@ -6,6 +6,7 @@ import Section from '../../components/content/Section.astro';
import Container from '../../components/layout/Container.astro';
import HeroSection from '../../components/content/HeroSection.astro';
import { getCollection } from 'astro:content';
import ecosystemData from '../../data/ecosystem-health.json';
const scores = await getCollection('manascore');
// Default: sort by score descending
@ -53,6 +54,31 @@ const statuses = [...new Set(sortedAudits.map((a) => a.data.status))];
<Section>
<Container>
{/* Ecosystem Health Link */}
<div class="mx-auto mb-8 max-w-4xl">
<a
href="/manascore/ecosystem"
class="border-border/50 hover:border-primary/50 group flex items-center justify-between rounded-xl border bg-gradient-to-br from-white/5 to-white/[0.02] p-5 transition-all hover:shadow-lg"
>
<div>
<div class="text-foreground flex items-center gap-2 font-semibold">
<span class="text-lg">🌐</span>
Ecosystem Health
</div>
<div class="text-muted-foreground mt-1 text-sm">
Konsistenz- und Vereinheitlichungsmetriken über alle {ecosystemData.apps.length} Apps
</div>
</div>
<div class="flex items-center gap-3">
<span class={`text-3xl font-bold ${getScoreColor(ecosystemData.overallScore)}`}>
{ecosystemData.overallScore}
</span>
<span class="text-muted-foreground transition-transform group-hover:translate-x-1"
>&rarr;</span
>
</div>
</a>
</div>
{/* Filter bar */}
<div class="mx-auto mb-6 flex max-w-4xl flex-wrap items-center gap-3">
{/* Status filter */}

View file

@ -15,6 +15,7 @@
onColumnColorChange?: (colIdx: number, color: string) => void;
onColumnMove?: (colIdx: number, dir: -1 | 1) => void;
onColumnDelete?: (colIdx: number) => void;
onAddColumn?: () => void;
}
let {
@ -24,6 +25,7 @@
onColumnColorChange,
onColumnMove,
onColumnDelete,
onAddColumn,
}: Props = $props();
let activeLayout = $derived(layoutOverride || view.layout);
@ -89,6 +91,7 @@
{onColumnColorChange}
{onColumnMove}
{onColumnDelete}
{onAddColumn}
/>
{:else if activeLayout === 'grid'}
<GridLayout
@ -101,6 +104,7 @@
{onColumnColorChange}
{onColumnMove}
{onColumnDelete}
{onAddColumn}
/>
{:else}
<KanbanLayout
@ -113,5 +117,6 @@
{onColumnColorChange}
{onColumnMove}
{onColumnDelete}
{onAddColumn}
/>
{/if}

View file

@ -18,6 +18,7 @@
onColumnColorChange?: (colIdx: number, color: string) => void;
onColumnMove?: (colIdx: number, dir: -1 | 1) => void;
onColumnDelete?: (colIdx: number) => void;
onAddColumn?: () => void;
}
let {
@ -30,6 +31,7 @@
onColumnColorChange,
onColumnMove,
onColumnDelete,
onAddColumn,
}: Props = $props();
const PAGE_WIDTH_MAP: Record<string, string> = {
@ -164,6 +166,15 @@
</div>
</div>
{/each}
{#if onAddColumn}
<div class="fokus-sheet add-sheet">
<button class="add-sheet-btn" onclick={onAddColumn}>
<span class="add-sheet-icon">+</span>
<span class="add-sheet-label">Neues Board</span>
</button>
</div>
{/if}
</div>
<!-- Page dots -->
@ -261,6 +272,46 @@
background: rgba(139, 92, 246, 0.04);
}
/* Add sheet */
.add-sheet {
border: 2px dashed rgba(139, 92, 246, 0.3) !important;
background: rgba(139, 92, 246, 0.02) !important;
box-shadow: none !important;
}
.add-sheet:hover {
border-color: #8b5cf6 !important;
background: rgba(139, 92, 246, 0.06) !important;
}
:global(.dark) .add-sheet {
border-color: rgba(139, 92, 246, 0.25) !important;
background: rgba(139, 92, 246, 0.04) !important;
}
.add-sheet-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
height: 100%;
min-height: 200px;
background: transparent;
border: none;
cursor: pointer;
}
.add-sheet-icon {
font-size: 2rem;
font-weight: 300;
color: #8b5cf6;
line-height: 1;
}
.add-sheet-label {
font-size: 0.875rem;
font-weight: 500;
color: #8b5cf6;
}
/* Page dots */
.page-dots {
display: flex;

View file

@ -13,6 +13,7 @@
onColumnColorChange?: (colIdx: number, color: string) => void;
onColumnMove?: (colIdx: number, dir: -1 | 1) => void;
onColumnDelete?: (colIdx: number) => void;
onAddColumn?: () => void;
}
let {
@ -25,6 +26,7 @@
onColumnColorChange,
onColumnMove,
onColumnDelete,
onAddColumn,
}: Props = $props();
</script>
@ -46,6 +48,15 @@
/>
</div>
{/each}
{#if onAddColumn}
<div class="grid-cell">
<button class="add-column-card" onclick={onAddColumn}>
<span class="add-icon">+</span>
<span class="add-label">Neues Board</span>
</button>
</div>
{/if}
</div>
<style>
@ -89,4 +100,40 @@
height: 100%;
max-height: none;
}
.add-column-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
height: 100%;
min-height: 200px;
border: 2px dashed rgba(139, 92, 246, 0.3);
border-radius: 0.375rem;
background: rgba(139, 92, 246, 0.03);
cursor: pointer;
transition: all 0.15s;
}
.add-column-card:hover {
border-color: #8b5cf6;
background: rgba(139, 92, 246, 0.08);
}
:global(.dark) .add-column-card {
background: rgba(139, 92, 246, 0.05);
}
:global(.dark) .add-column-card:hover {
background: rgba(139, 92, 246, 0.12);
}
.add-icon {
font-size: 1.5rem;
font-weight: 300;
color: #8b5cf6;
}
.add-label {
font-size: 0.8125rem;
font-weight: 500;
color: #8b5cf6;
}
</style>

View file

@ -13,6 +13,7 @@
onColumnColorChange?: (colIdx: number, color: string) => void;
onColumnMove?: (colIdx: number, dir: -1 | 1) => void;
onColumnDelete?: (colIdx: number) => void;
onAddColumn?: () => void;
}
let {
@ -25,6 +26,7 @@
onColumnColorChange,
onColumnMove,
onColumnDelete,
onAddColumn,
}: Props = $props();
</script>
@ -46,6 +48,15 @@
/>
</div>
{/each}
{#if onAddColumn}
<div class="kanban-column-wrapper">
<button class="add-column-card" onclick={onAddColumn}>
<span class="add-column-icon">+</span>
<span class="add-column-label">Neues Board</span>
</button>
</div>
{/if}
</div>
<style>
@ -94,4 +105,42 @@
max-width: 340px;
flex-shrink: 0;
}
.add-column-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
min-height: 250px;
border: 2px dashed rgba(139, 92, 246, 0.3);
border-radius: 0.375rem;
background: rgba(139, 92, 246, 0.03);
cursor: pointer;
transition: all 0.15s;
}
.add-column-card:hover {
border-color: #8b5cf6;
background: rgba(139, 92, 246, 0.08);
}
:global(.dark) .add-column-card {
background: rgba(139, 92, 246, 0.05);
border-color: rgba(139, 92, 246, 0.25);
}
:global(.dark) .add-column-card:hover {
border-color: #8b5cf6;
background: rgba(139, 92, 246, 0.12);
}
.add-column-icon {
font-size: 1.5rem;
font-weight: 300;
color: #8b5cf6;
line-height: 1;
}
.add-column-label {
font-size: 0.8125rem;
font-weight: 500;
color: #8b5cf6;
}
</style>

View file

@ -4,7 +4,6 @@
import { BoardViewRenderer } from '$lib/components/board-views';
import { todoSettings, type PageWidth } from '$lib/stores/settings.svelte';
import { boardViewsStore } from '$lib/stores/board-views.svelte';
import { Plus } from '@manacore/shared-icons';
// Get active view + edit mode from layout context
const activeViewCtx: { readonly value: LocalBoardView | null } = getContext('activeView');
@ -147,17 +146,6 @@
</div>
</div>
</div>
{#if columnsEditable}
<div class="add-col-bar">
<button class="col-add-btn" onclick={addColumn} title="Spalte hinzufügen">
<Plus size={14} />
<span>Spalte</span>
</button>
</div>
{:else}
<p class="columns-hint">Spalten werden automatisch aus der Gruppierung erzeugt.</p>
{/if}
{/if}
<!-- Board Content -->
@ -169,6 +157,7 @@
onColumnColorChange={columnsEditable ? (i, color) => updateColumn(i, { color }) : undefined}
onColumnMove={columnsEditable ? moveColumn : undefined}
onColumnDelete={columnsEditable ? removeColumn : undefined}
onAddColumn={columnsEditable && editMode ? addColumn : undefined}
/>
{:else}
<div class="empty-state">
@ -279,38 +268,4 @@
border-color: #8b5cf6;
color: white;
}
/* ── Add Column Bar ───────────────────────────────────── */
.add-col-bar {
display: flex;
padding: 0.5rem 1.5rem;
flex-shrink: 0;
border-bottom: 1px solid rgba(139, 92, 246, 0.08);
}
.col-add-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
border: 1px dashed rgba(139, 92, 246, 0.4);
color: #8b5cf6;
background: transparent;
cursor: pointer;
transition: all 0.15s;
}
.col-add-btn:hover {
border-color: #8b5cf6;
background: rgba(139, 92, 246, 0.08);
}
.columns-hint {
padding: 0.5rem 1.5rem;
font-size: 0.75rem;
color: #9ca3af;
flex-shrink: 0;
}
</style>