style(web): Modul-Kacheln einfarbig, Akzent nur fuer das Modul im Fokus
Statt bunter Modulfarben neutrale graue Kacheln; gelb (persoenliche Akzentfarbe) nur fuer den gewaehlten Eintrag der Seitenleiste und den Seitenkopf des Moduls. Schrift auf Akzentflaechen wird jetzt je nach Akzentfarbe hell oder dunkel gewaehlt (readableOnAccent). Anmelde- Mosaik und Dashboard-Hintergruende nur noch in Gelb, Grau, Graphit; Salbei ersetzt durch Kiesel. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,7 +44,7 @@ export default function LoginPage() {
|
|||||||
<div className="flex min-h-screen bg-background">
|
<div className="flex min-h-screen bg-background">
|
||||||
{/* Links (ab lg): dunkles Markenpanel im Ton der App-Leiste mit
|
{/* Links (ab lg): dunkles Markenpanel im Ton der App-Leiste mit
|
||||||
grosser Bildmarke, Produktname, einem ruhigen Satz und einem
|
grosser Bildmarke, Produktname, einem ruhigen Satz und einem
|
||||||
stillen Mosaik aus den Modulfarben — kein Verlauf, keine Animation. */}
|
stillen Mosaik in Grau und Gelb — kein Verlauf, keine Animation. */}
|
||||||
<div className="relative hidden w-[45%] shrink-0 flex-col overflow-hidden bg-appbar px-14 py-12 text-appbar-foreground lg:flex">
|
<div className="relative hidden w-[45%] shrink-0 flex-col overflow-hidden bg-appbar px-14 py-12 text-appbar-foreground lg:flex">
|
||||||
<TesseraLogo
|
<TesseraLogo
|
||||||
variant="horizontal"
|
variant="horizontal"
|
||||||
@@ -205,20 +205,21 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stilles Mosaik aus den Modulfarben (Design „Mosaik“): acht Kacheln in einem
|
* Stilles Mosaik (Design „Mosaik“): acht Kacheln in einem lockeren
|
||||||
* lockeren 4 x 3-Raster, eine davon in der Akzentfarbe und — wie die
|
* 4 x 3-Raster in Grautoenen, eine davon in der Akzentfarbe und — wie die
|
||||||
* Signalkachel der Bildmarke — leicht gedreht. Rein dekorativ.
|
* Signalkachel der Bildmarke — leicht gedreht. Rein dekorativ.
|
||||||
*/
|
*/
|
||||||
function LoginMosaic() {
|
function LoginMosaic() {
|
||||||
|
// Runde 2: nur Gelb und Graphit-/Grautoene des dunklen Panels.
|
||||||
const tiles: { col: number; row: number; color: string; rotate?: boolean }[] = [
|
const tiles: { col: number; row: number; color: string; rotate?: boolean }[] = [
|
||||||
{ col: 1, row: 1, color: 'oklch(0.56 0.09 195)' },
|
{ col: 1, row: 1, color: 'oklch(1 0 0 / 0.14)' },
|
||||||
{ col: 2, row: 1, color: 'oklch(0.55 0.14 292)' },
|
{ col: 2, row: 1, color: 'oklch(1 0 0 / 0.08)' },
|
||||||
{ col: 4, row: 1, color: 'oklch(0.58 0.14 250)' },
|
{ col: 4, row: 1, color: 'oklch(1 0 0 / 0.1)' },
|
||||||
{ col: 2, row: 2, color: 'oklch(0.62 0.135 68)' },
|
{ col: 2, row: 2, color: 'oklch(1 0 0 / 0.18)' },
|
||||||
{ col: 3, row: 2, color: 'var(--primary)', rotate: true },
|
{ col: 3, row: 2, color: 'var(--primary)', rotate: true },
|
||||||
{ col: 4, row: 2, color: 'oklch(0.58 0.16 35)' },
|
{ col: 4, row: 2, color: 'oklch(1 0 0 / 0.06)' },
|
||||||
{ col: 1, row: 3, color: 'oklch(0.58 0.12 150)' },
|
{ col: 1, row: 3, color: 'oklch(1 0 0 / 0.1)' },
|
||||||
{ col: 3, row: 3, color: 'oklch(1 0 0 / 0.1)' },
|
{ col: 3, row: 3, color: 'oklch(1 0 0 / 0.05)' },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div aria-hidden="true" className="grid w-max grid-cols-4 grid-rows-3 gap-3">
|
<div aria-hidden="true" className="grid w-max grid-cols-4 grid-rows-3 gap-3">
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function PageHeader({ title, description, actions, moduleSlug, className
|
|||||||
return (
|
return (
|
||||||
<div className={`flex flex-wrap items-start justify-between gap-x-6 gap-y-3 ${className}`}>
|
<div className={`flex flex-wrap items-start justify-between gap-x-6 gap-y-3 ${className}`}>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
{moduleSlug && <ModuleTile slug={moduleSlug} size={36} />}
|
{moduleSlug && <ModuleTile slug={moduleSlug} size={36} tone="accent" />}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-semibold text-foreground">{title}</h1>
|
<h1 className="text-2xl font-semibold text-foreground">{title}</h1>
|
||||||
{description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>}
|
{description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>}
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export function Sidebar() {
|
|||||||
<li key={mod.id}>
|
<li key={mod.id}>
|
||||||
<Link href={href} className={itemClass(active)} aria-label={mod.name} title={mod.name}>
|
<Link href={href} className={itemClass(active)} aria-label={mod.name} title={mod.name}>
|
||||||
{active && <SelectionPill />}
|
{active && <SelectionPill />}
|
||||||
<ModuleTile slug={mod.slug} size={20} />
|
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -220,7 +220,7 @@ export function Sidebar() {
|
|||||||
<li key={mod.id}>
|
<li key={mod.id}>
|
||||||
<Link href={href} className={itemClass(active)}>
|
<Link href={href} className={itemClass(active)}>
|
||||||
{active && <SelectionPill />}
|
{active && <SelectionPill />}
|
||||||
<ModuleTile slug={mod.slug} size={20} />
|
<ModuleTile slug={mod.slug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||||
<span className="truncate">{mod.name}</span>
|
<span className="truncate">{mod.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { getModuleIdentity, type ModuleIconId } from '@/lib/module-identity';
|
import { getModuleIcon, type ModuleIconId } from '@/lib/module-identity';
|
||||||
|
|
||||||
const GLYPHS: Record<ModuleIconId, ReactNode> = {
|
const GLYPHS: Record<ModuleIconId, ReactNode> = {
|
||||||
radar: (
|
radar: (
|
||||||
@@ -56,27 +56,36 @@ interface ModuleTileProps {
|
|||||||
slug: string;
|
slug: string;
|
||||||
/** Kantenlaenge in Pixeln (Seitenleiste 20, Karten 40, Seitenkopf 36). */
|
/** Kantenlaenge in Pixeln (Seitenleiste 20, Karten 40, Seitenkopf 36). */
|
||||||
size?: number;
|
size?: number;
|
||||||
|
/**
|
||||||
|
* `neutral` (Vorgabe): graue Flaeche, dunkelgraues Symbol.
|
||||||
|
* `accent`: Akzentflaeche mit passender Schrift — fuer das Modul im Fokus.
|
||||||
|
*/
|
||||||
|
tone?: 'neutral' | 'accent';
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-Kachel eines Moduls: abgerundetes Quadrat in der Modulfarbe mit weissem
|
* App-Kachel eines Moduls: abgerundetes Quadrat mit Symbol (siehe
|
||||||
* Symbol (siehe lib/module-identity.ts). Rein dekorativ — der Modulname steht
|
* lib/module-identity.ts). Kontrast Symbol/Flaeche: neutral hell 7,4:1,
|
||||||
* immer als Text daneben.
|
* dunkel 8,9:1; Akzent ueber `--primary-foreground`, das fuer jede
|
||||||
|
* persoenliche Akzentfarbe hell oder dunkel gewaehlt wird (lib/color.ts).
|
||||||
|
* Rein dekorativ — der Modulname steht immer als Text daneben.
|
||||||
*/
|
*/
|
||||||
export function ModuleTile({ slug, size = 20, className = '' }: ModuleTileProps) {
|
export function ModuleTile({ slug, size = 20, tone = 'neutral', className = '' }: ModuleTileProps) {
|
||||||
const { color, icon } = getModuleIdentity(slug);
|
const icon = getModuleIcon(slug);
|
||||||
const glyph = Math.round(size * 0.6);
|
const glyph = Math.round(size * 0.6);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
data-module-tile={slug}
|
data-module-tile={slug}
|
||||||
className={`inline-flex shrink-0 items-center justify-center text-white ${className}`}
|
data-tone={tone}
|
||||||
|
className={`inline-flex shrink-0 items-center justify-center ${
|
||||||
|
tone === 'accent' ? 'bg-primary text-primary-foreground' : 'bg-tile text-tile-foreground'
|
||||||
|
} ${className}`}
|
||||||
style={{
|
style={{
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
borderRadius: Math.max(4, Math.round(size * 0.24)),
|
borderRadius: Math.max(4, Math.round(size * 0.24)),
|
||||||
backgroundColor: color,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -86,7 +95,7 @@ export function ModuleTile({ slug, size = 20, className = '' }: ModuleTileProps)
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={size <= 24 ? 2.4 : 2}
|
strokeWidth={size <= 24 ? 2.2 : 1.9}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { normalizeHexColor } from './color';
|
import { DARK_ON_ACCENT, LIGHT_ON_ACCENT, normalizeHexColor, readableOnAccent, relativeLuminance } from './color';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* color.test — Normalisierung der Hex-Eingabe der Akzentfarbe
|
* color.test — Normalisierung der Hex-Eingabe der Akzentfarbe
|
||||||
@@ -42,3 +42,31 @@ describe('normalizeHexColor', () => {
|
|||||||
expect(normalizeHexColor('#1234567')).toBeNull();
|
expect(normalizeHexColor('#1234567')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('readableOnAccent (Design „Mosaik“)', () => {
|
||||||
|
const contrast = (a: string, b: string) => {
|
||||||
|
const la = relativeLuminance(a);
|
||||||
|
const lb = relativeLuminance(b);
|
||||||
|
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('Markengelb und helle Akzente: dunkle Schrift', () => {
|
||||||
|
expect(readableOnAccent('#ffed00')).toBe(DARK_ON_ACCENT);
|
||||||
|
expect(readableOnAccent('#a8e6cf')).toBe(DARK_ON_ACCENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dunkle Akzente: helle Schrift', () => {
|
||||||
|
expect(readableOnAccent('#123456')).toBe(LIGHT_ON_ACCENT);
|
||||||
|
expect(readableOnAccent('#6b21a8')).toBe(LIGHT_ON_ACCENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('haelt fuer eine Reihe von Akzenten mindestens 3:1 (Symbol auf der Kachel)', () => {
|
||||||
|
for (const accent of ['#ffed00', '#123456', '#e11d48', '#0ea5e9', '#16a34a', '#777777', '#f97316']) {
|
||||||
|
expect(contrast(accent, readableOnAccent(accent))).toBeGreaterThanOrEqual(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ungueltige Eingabe: dunkle Schrift', () => {
|
||||||
|
expect(readableOnAccent('kein-hex')).toBe(DARK_ON_ACCENT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,3 +28,37 @@ export function normalizeHexColor(input: string): string | null {
|
|||||||
|
|
||||||
return `#${expanded.toLowerCase()}`;
|
return `#${expanded.toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function channelToLinear(value: number): number {
|
||||||
|
const c = value / 255;
|
||||||
|
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relative Leuchtdichte (WCAG) einer kanonischen Hex-Farbe `#rrggbb`. */
|
||||||
|
export function relativeLuminance(hex: string): number {
|
||||||
|
const r = channelToLinear(Number.parseInt(hex.slice(1, 3), 16));
|
||||||
|
const g = channelToLinear(Number.parseInt(hex.slice(3, 5), 16));
|
||||||
|
const b = channelToLinear(Number.parseInt(hex.slice(5, 7), 16));
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dunkle Schrift auf Akzentflaechen (entspricht --primary-foreground, #1f1c13). */
|
||||||
|
export const DARK_ON_ACCENT = '#1f1c13';
|
||||||
|
/** Helle Schrift auf dunklen Akzentflaechen. */
|
||||||
|
export const LIGHT_ON_ACCENT = '#ffffff';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schriftfarbe fuer Flaechen in der persoenlichen Akzentfarbe (Knoepfe,
|
||||||
|
* Modul-Kachel im Fokus): die Variante mit dem hoeheren Kontrast. Damit
|
||||||
|
* bleibt auch eine dunkle Akzentfarbe lesbar (vorher stand immer dunkle
|
||||||
|
* Schrift auf der Akzentflaeche). Ungueltige Eingaben ergeben dunkle Schrift.
|
||||||
|
*/
|
||||||
|
export function readableOnAccent(hex: string): string {
|
||||||
|
const normalized = normalizeHexColor(hex);
|
||||||
|
if (!normalized) return DARK_ON_ACCENT;
|
||||||
|
const l = relativeLuminance(normalized);
|
||||||
|
const dark = relativeLuminance(DARK_ON_ACCENT);
|
||||||
|
const contrastDark = (l + 0.05) / (dark + 0.05);
|
||||||
|
const contrastLight = (1 + 0.05) / (l + 0.05);
|
||||||
|
return contrastLight > contrastDark ? LIGHT_ON_ACCENT : DARK_ON_ACCENT;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
* Dashboard-Hintergrund (Prototyp, Design „Mosaik“).
|
* Dashboard-Hintergrund (Prototyp, Design „Mosaik“).
|
||||||
*
|
*
|
||||||
* Wahl je Benutzer: kein Hintergrund, ein eingebauter (ruhige Flaechen und
|
* Wahl je Benutzer: kein Hintergrund, ein eingebauter (ruhige Flaechen und
|
||||||
* drei abstrakte Motive in gedaempften Modulfarben — keine Regenbogen-
|
* drei abstrakte Motive in Gelb, Grau und Graphit — keine Regenbogen-
|
||||||
* verlaeufe) oder ein eigenes Bild aus den Bilderrahmen-Bildern
|
* verlaeufe) oder ein eigenes Bild aus den Bilderrahmen-Bildern
|
||||||
* (`/dashboard/images`, keine API-Aenderung). Gespeichert wird vorerst im
|
* (`/dashboard/images`, keine API-Aenderung). Gespeichert wird vorerst im
|
||||||
* localStorage des Browsers, Schluessel je Benutzerkennung; ein Datenbankfeld
|
* localStorage des Browsers, Schluessel je Benutzerkennung; ein Datenbankfeld
|
||||||
* kann folgen, wenn die Funktion bleibt.
|
* kann folgen, wenn die Funktion bleibt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type BackgroundPresetId = 'mist' | 'sage' | 'bloom' | 'dunes' | 'mosaic';
|
export type BackgroundPresetId = 'mist' | 'pebble' | 'bloom' | 'dunes' | 'mosaic';
|
||||||
|
|
||||||
export type DashboardBackground =
|
export type DashboardBackground =
|
||||||
| { kind: 'none' }
|
| { kind: 'none' }
|
||||||
@@ -46,22 +46,22 @@ function dunes(base: string, a: string, b: string, c: string): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mosaic(base: string, alpha: number, yellowAlpha: number): string {
|
function mosaic(base: string, gray: string, alphas: number[], yellowAlpha: number): string {
|
||||||
const tiles: [number, number, string, number?][] = [
|
const tiles: [number, number][] = [
|
||||||
[1060, 120, '#2a8f8f'],
|
[1060, 120],
|
||||||
[1260, 120, '#7a5cc2'],
|
[1260, 120],
|
||||||
[1260, 320, '#3a78c9'],
|
[1260, 320],
|
||||||
[1060, 320, '#b37a1f'],
|
[1060, 320],
|
||||||
[1460, 320, '#c65436'],
|
[1460, 320],
|
||||||
[860, 520, '#3f8f5a'],
|
[860, 520],
|
||||||
[1260, 520, '#3a78c9'],
|
[1260, 520],
|
||||||
[1460, 720, '#7a5cc2'],
|
[1460, 720],
|
||||||
[1060, 720, '#2a8f8f'],
|
[1060, 720],
|
||||||
];
|
];
|
||||||
const rects = tiles
|
const rects = tiles
|
||||||
.map(
|
.map(
|
||||||
([x, y, fill]) =>
|
([x, y], i) =>
|
||||||
`<rect x="${x}" y="${y}" width="170" height="170" rx="34" fill="${fill}" fill-opacity="${alpha}"/>`,
|
`<rect x="${x}" y="${y}" width="170" height="170" rx="34" fill="${gray}" fill-opacity="${alphas[i % alphas.length]}"/>`,
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
return svgUrl(
|
return svgUrl(
|
||||||
@@ -78,26 +78,27 @@ export interface BackgroundPreset {
|
|||||||
dark: string;
|
dark: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runde 2: nur Gelb-, Grau- und Graphittoene — keine bunten Modulfarben.
|
||||||
export const BACKGROUND_PRESETS: BackgroundPreset[] = [
|
export const BACKGROUND_PRESETS: BackgroundPreset[] = [
|
||||||
{ id: 'mist', labelKey: 'mist', light: '#e4e9f1', dark: '#1d2330' },
|
{ id: 'mist', labelKey: 'mist', light: '#e4e9f1', dark: '#1d2330' },
|
||||||
{ id: 'sage', labelKey: 'sage', light: '#e3ebe6', dark: '#1c2521' },
|
{ id: 'pebble', labelKey: 'pebble', light: '#e9e7e3', dark: '#23221f' },
|
||||||
{
|
{
|
||||||
id: 'bloom',
|
id: 'bloom',
|
||||||
labelKey: 'bloom',
|
labelKey: 'bloom',
|
||||||
light: bloom('#e8ecf3', '#b7c7e6', '#bcdcd7', '#d4cbe9'),
|
light: bloom('#eceef1', '#f0e9c6', '#d3d7de', '#dddbd5'),
|
||||||
dark: bloom('#1b1f28', '#2a3a58', '#21403c', '#352e4d'),
|
dark: bloom('#1b1d22', '#37342a', '#2a2e35', '#2f2e2a'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dunes',
|
id: 'dunes',
|
||||||
labelKey: 'dunes',
|
labelKey: 'dunes',
|
||||||
light: dunes('#e6ebef', '#d3dde3', '#c1cfd8', '#aebfca'),
|
light: dunes('#eceef1', '#dcdfe4', '#caced5', '#b8bdc6'),
|
||||||
dark: dunes('#1a1f25', '#212930', '#28323a', '#303c45'),
|
dark: dunes('#1a1c20', '#23262b', '#2c3036', '#363a41'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mosaic',
|
id: 'mosaic',
|
||||||
labelKey: 'mosaic',
|
labelKey: 'mosaic',
|
||||||
light: mosaic('#eceff3', 0.16, 0.35),
|
light: mosaic('#eceef1', '#5b6270', [0.1, 0.16, 0.07, 0.13], 0.4),
|
||||||
dark: mosaic('#1b1f26', 0.24, 0.3),
|
dark: mosaic('#1b1d22', '#c9ced8', [0.06, 0.1, 0.04, 0.08], 0.78),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Modul-Identitaet (Design „Mosaik“): jedes Modul bekommt eine feste Farbe
|
* Modul-Identitaet (Design „Mosaik“, Runde 2): jedes Modul hat ein festes
|
||||||
* und ein Symbol — wie die App-Kacheln bei Microsoft 365. Die Kachel wird in
|
* Symbol. Die Kacheln sind einfarbig ins Erscheinungsbild eingebunden —
|
||||||
* der Seitenleiste (20 px), auf den Marktplatz-Karten (40 px) und im Kopf der
|
* neutral grau im Normalfall, in der Akzentfarbe (Standard Gelb) nur dort,
|
||||||
* Modulseiten (36 px) gezeigt, immer mit weissem Symbol.
|
* wo das Modul gerade im Fokus steht (gewaehlter Eintrag der Seitenleiste,
|
||||||
*
|
* Seitenkopf des Moduls). Farben stehen als Tokens in globals.css
|
||||||
* Kontrast weisses Symbol gegen Kachel (WCAG, gemessen): tender-radar 3,8:1
|
* (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`).
|
||||||
* (Ockerton gegenueber dem Entwurf oklch(0.68 0.14 70) = 2,96:1 abgedunkelt),
|
|
||||||
* dkv-fleet 4,5:1, cert-manager 5,1:1, domaincheck 4,3:1, proxmox 4,6:1,
|
|
||||||
* Reserve gruen 4,1:1, Reserve pink 4,3:1 — alle ueber den geforderten 3:1
|
|
||||||
* fuer grafische Elemente.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ModuleIconId =
|
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'tile';
|
||||||
| 'radar'
|
|
||||||
| 'fuel'
|
|
||||||
| 'certificate'
|
|
||||||
| 'globe'
|
|
||||||
| 'server'
|
|
||||||
| 'tile';
|
|
||||||
|
|
||||||
export interface ModuleIdentity {
|
const ICONS: Record<string, ModuleIconId> = {
|
||||||
/** Kachelfarbe als OKLCH-Farbwert. */
|
'tender-radar': 'radar',
|
||||||
color: string;
|
'dkv-fleet': 'fuel',
|
||||||
/** Symbol auf der Kachel. */
|
'cert-manager': 'certificate',
|
||||||
icon: ModuleIconId;
|
domaincheck: 'globe',
|
||||||
}
|
proxmox: 'server',
|
||||||
|
|
||||||
const KNOWN: Record<string, ModuleIdentity> = {
|
|
||||||
'tender-radar': { color: 'oklch(0.62 0.135 68)', icon: 'radar' },
|
|
||||||
'dkv-fleet': { color: 'oklch(0.56 0.09 195)', icon: 'fuel' },
|
|
||||||
'cert-manager': { color: 'oklch(0.55 0.14 292)', icon: 'certificate' },
|
|
||||||
domaincheck: { color: 'oklch(0.58 0.14 250)', icon: 'globe' },
|
|
||||||
proxmox: { color: 'oklch(0.58 0.16 35)', icon: 'server' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Reservefarben fuer unbekannte Module (stabil per Kennungs-Hash). */
|
/** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */
|
||||||
const RESERVE = ['oklch(0.58 0.12 150)', 'oklch(0.60 0.15 350)'] as const;
|
export function getModuleIcon(slug: string): ModuleIconId {
|
||||||
|
return ICONS[slug] ?? 'tile';
|
||||||
function stableHash(value: string): number {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < value.length; i++) {
|
|
||||||
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
|
|
||||||
}
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Farbe und Symbol eines Moduls; unbekannte Kennungen bekommen stabil eine Reservefarbe. */
|
|
||||||
export function getModuleIdentity(slug: string): ModuleIdentity {
|
|
||||||
const known = KNOWN[slug];
|
|
||||||
if (known) return known;
|
|
||||||
return { color: RESERVE[stableHash(slug) % RESERVE.length], icon: 'tile' };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { readableOnAccent } from '@/lib/color';
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,8 +32,11 @@ function applyAccentColor(color: string | null | undefined) {
|
|||||||
root.style.removeProperty('--sidebar-accent-foreground');
|
root.style.removeProperty('--sidebar-accent-foreground');
|
||||||
if (color) {
|
if (color) {
|
||||||
root.style.setProperty('--primary', color);
|
root.style.setProperty('--primary', color);
|
||||||
|
// Schrift auf der Akzentflaeche: hell oder dunkel, je nach Kontrast.
|
||||||
|
root.style.setProperty('--primary-foreground', readableOnAccent(color));
|
||||||
} else {
|
} else {
|
||||||
root.style.removeProperty('--primary');
|
root.style.removeProperty('--primary');
|
||||||
|
root.style.removeProperty('--primary-foreground');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user