Die Oberflaeche auf das Manner-CD umstellen
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m45s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled

Farben, Schrift, Logo und Symbole folgen jetzt dem CD Manual 2022. An der
Logik aendert sich nichts: keine Migration, keine Abfrage, keine Berechtigung.

Die zwei Markenfarben stehen in §1.1 als #F69686 (Rosa) und #164194 (Blau).
Welche davon was traegt, ist nicht gewaehlt, sondern nachgerechnet: Weiss auf
Rosa kommt auf 2.18:1 und faellt damit auch fuer Grossschrift durch, Blau auf
Rosa auf 4.34:1 und reicht fuer das Logo, nicht fuer Text. Schwarz auf Rosa
sind 7.47:1, Weiss auf Blau 9.47:1. Rosa ist deshalb Flaeche, Blau ist
Interaktion — zwei Skalen, brand-* und accent-*, statt einer geteilten.

Von den 26 Stellen mit brand-50/100/200 sind nur sieben auf accent gewandert.
Der Rest meint einen Zustand und keinen Markenton: die Zeile unter dem Zeiger,
der gewaehlte Listeneintrag, der aktive Menuepunkt. Gegen die warme
Seitenflaeche ist ein kuehler Ton dort deutlicher, und Blau heisst in dieser
Oberflaeche ab jetzt "reagiert auf dich". Nach accent gingen die Faelle ohne
eigene Bedeutung — "Geplant", "Offen", der neutrale Protokoll-Chip — und die
offene Planstelle im Organigramm, die vorher ein blasses Blau war und damit
wie eine schwaechere Person aussah.

Die Funktionsfarben bleiben, was sie sind. Das Manual regelt die Identitaet,
nicht die Rueckmeldung: Rot heisst Fehler, weil die Benutzerin das mitbringt.
`info` bleibt bewusst tuerkis — Blau saehe ab jetzt bedienbar aus, und gemessen
kaeme ein blaues info dem violetten Chip auf dE 6.8 nahe, also nicht
unterscheidbar. Violett ist dabei nachgezogen: gegen den Fehler-Chip stand es
bei dE 8.0, "Austritt" und "Befoerderung" waren im Vorbeigehen dieselbe blasse
Flaeche. Jetzt dE 15.0.

Die Schrift ist Barlow. Nachgezaehlt ist das Manual zu 95 % in DIN gesetzt
(Regular 79 %, Bold 16 %); Helvetica Neue steht nur in den Visitenkarten und im
Claim. DIN laesst sich nicht ausliefern — eine Drucklizenz deckt keinen Webfont
—, und die freien Nachbauten der DIN 1451 sind Schilderschriften, bei 14 px in
einer langen Tabelle schlechter lesbar als das, was sie ersetzen. Die Variable
heisst --font-din und nicht --font-barlow: liegt eines Tages eine Web-Lizenz
vor, ist der Wechsel diese eine Deklaration.

Nebenbei zwei Dinge repariert, die vorher schon falsch waren. Die Umrandung von
Eingabefeldern, Knoepfen und Suchfeldern lag bei 1.30:1 und damit unter den 3:1,
die WCAG 1.4.11 fuer Bedienelemente verlangt; border-strong bringt 3.56:1. Und
das mitgelieferte favicon.ico liess sich gar nicht bauen: eingebettet waren
24-Bit-RGB-PNG, waehrend der Kopf 32 bpp behauptete. Alle Rastersymbole liegen
jetzt als RGBA vor, und ihr Blau ist auf den CD-Wert gezogen — samt der
kantengeglaetteten Raender, indem je Pixel der Blauanteil bestimmt und neu
gemischt wurde.

Das Logo ist das Markenlogo (§4.1), nicht das Unternehmenslogo, das §3.2 fuer
eine Anwendung mit der AG als Absender vorsaehe — es liegt nicht vor. Der
Freiraum X/3 steckt im Bauteil selbst und nicht in den Aufrufstellen, sonst
haengt seine Einhaltung daran, dass jede einzelne daran denkt.

docs/farbschema.html zeigt Token, Kontraste und Bauteile nebeneinander und
laesst sich ohne Server oeffnen.

Nicht im Browser gesehen: Anmeldung laeuft ueber das Firmenkonto und die
Datenbank ist von hier nicht erreichbar. Lint, Typen, Schemaabgleich, 524 Tests
und der Build sind sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 19:12:52 +02:00
parent 99e4357c02
commit f7a5c48615
33 changed files with 1305 additions and 92 deletions

View File

@@ -49,7 +49,7 @@ export function AuditDetail({ eintrag }: { eintrag: AuditEintrag }) {
>
<span>{eintrag.details ?? "–"}</span>
{anzahl > 0 && (
<span className="ml-2 whitespace-nowrap rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-semibold text-brand-700">
<span className="ml-2 whitespace-nowrap rounded-full bg-accent-100 px-2 py-0.5 text-[11px] font-semibold text-brand-700">
{anzahl} {anzahl === 1 ? "Feld" : "Felder"}
</span>
)}

75
components/brand/Logo.tsx Normal file
View File

@@ -0,0 +1,75 @@
// Das Manner-Logo und die Wortmarke der Anwendung — an einer Stelle, weil
// Kopfleiste und Anmeldeseite dasselbe zeigen sollen.
//
// ═══ Welches Logo ═══
//
// Verwendet wird das **Markenlogo** (CD §4.1): der Schriftzug allein, ohne
// den Stephansdom-Medaillon. Das Unternehmenslogo (§3.1) waere nach §3.2 das
// formal richtige, wo „Josef Manner & Comp. AG" als Absender auftritt — es
// steht hier aber nicht zur Verfuegung, und das Markenlogo ist die Fassung,
// die vorliegt.
//
// ═══ Freiraum ═══
//
// §4.1 verlangt „genuegend Freiraum rundum (X, X/3)". X ist der Modul des
// Logos; gelesen wird er hier als seine Hoehe, der Freiraum also als ein
// Drittel davon. Er steckt als Polsterung in diesem Bauteil und nicht in den
// Aufrufstellen — sonst haengt die Einhaltung daran, dass jede einzelne
// daran denkt.
//
// ═══ Rosa ═══
//
// §1.2: der Schriftzug darf ausschliesslich zur Gaenze auf Rosa stehen. Die
// Datei bringt ihr rosa Feld selbst mit, das Logo ist also auf jedem
// Untergrund regelkonform — auch auf dem blauen Panel der Anmeldeseite.
//
// Als <img> und nicht inline: die Datei wiegt 73 kB, weil die Kontur des
// Schriftzugs aus 77 Pfaden besteht. Inline laege sie in jeder einzelnen
// Seitenauslieferung; als Datei holt der Browser sie einmal und nimmt sie
// danach aus dem Zwischenspeicher.
/** Seitenverhaeltnis der Datei: viewBox 288.6781 x 116.18912. */
const VERHAELTNIS = 288.6781 / 116.18912;
export function MannerLogo({ hoehe = 28, className = "" }: { hoehe?: number; className?: string }) {
const breite = Math.round(hoehe * VERHAELTNIS);
return (
// next/image bringt Groessenvarianten und Ladelogik fuer Fotos mit; fuer
// eine Vektordatei fester Groesse ist beides Ballast, und die
// Bildoptimierung laesst SVG ohnehin unveraendert durch.
// eslint-disable-next-line @next/next/no-img-element
<img
src="/brand/manner-logo.svg"
alt="Manner"
width={breite}
height={hoehe}
// Der Freiraum nach §4.1, als Polsterung. `box-content`, damit die
// Breite oben die des Logos bleibt und nicht die des Feldes.
style={{ padding: Math.round(hoehe / 3), width: breite, height: hoehe }}
className={`box-content shrink-0 ${className}`}
/>
);
}
/**
* Logo plus Produktname.
*
* Die beiden gehoeren zusammen und meinen Verschiedenes: das Logo sagt, wem
* das System gehoert, der Name sagt, was es ist.
*/
export function AppWortmarke({
hoehe = 26,
textKlasse = "text-ink",
className = "",
}: {
hoehe?: number;
textKlasse?: string;
className?: string;
}) {
return (
<span className={`flex items-center ${className}`}>
<MannerLogo hoehe={hoehe} />
<span className={`text-base font-extrabold tracking-tight ${textKlasse}`}>Alpenwerk HR</span>
</span>
);
}

View File

@@ -52,7 +52,7 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
<div className="overflow-x-auto rounded border border-border">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="border-b border-border bg-brand-50 text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
<tr className="border-b border-border bg-accent-50 text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
<th className="px-4 py-2.5">Name</th>
<th className="px-4 py-2.5">Verhältnis</th>
<th className="px-4 py-2.5">SVNR</th>

View File

@@ -103,7 +103,7 @@ export function NotizenTab({ employeeId, notes }: { employeeId: string; notes: N
{n.done ? (
<span className="rounded-full bg-success-bg px-2 py-0.5 text-xs font-semibold text-success-text">Erledigt</span>
) : (
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700">Offen</span>
<span className="rounded-full bg-accent-200 px-2 py-0.5 text-xs font-semibold text-brand-700">Offen</span>
)}
</div>
<p className="mt-1 text-sm text-ink">{n.note_text}</p>

View File

@@ -156,7 +156,7 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
<div key={e.id}>
<div
ref={e.id === focusId ? focusRef : undefined}
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-brand-100" : ""}`}
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-accent-200" : ""}`}
style={{ paddingLeft: depth * 24 + 8 }}
>
{hasChildren ? (

View File

@@ -23,11 +23,15 @@ import type { ChartNode, ChartNodeKind } from "./types";
// object as a change and remounts every custom node otherwise.
const NODE_TYPES = { orgNode: OrgChartNode };
// React Flow nimmt echte Farbwerte, keine Klassen — deshalb stehen die
// Token hier als Literal. Sie folgen KIND_ACCENT in OrgChartNode; laeuft das
// eine dem anderen davon, zeigt die Uebersichtskarte andere Farben als die
// Karten, ueber die sie liegt.
const MINIMAP_COLORS: Record<ChartNodeKind, string> = {
person: "#d6046e",
role: "#5c2e91",
group: "#c9b3c0",
vacancy: "#f6cfe2",
person: "#164194", // brand-500, Manner Blau
role: "#54277f", // purple-text
group: "#756c6e", // ink-muted
vacancy: "#f69686", // accent-500, Manner Rosa
};
export type GraphOrgChartProps = {
@@ -118,7 +122,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
fitView
fitViewOptions={{ padding: 0.2 }}
>
<Background variant={BackgroundVariant.Dots} gap={22} size={1.4} color="#eedde6" />
<Background variant={BackgroundVariant.Dots} gap={22} size={1.4} color="#f0dbd4" />
<Controls showInteractive={false} />
{/* Hidden under lg via CSS: on a phone it would cover a real
fraction of the canvas for little navigational benefit. */}
@@ -126,8 +130,8 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
pannable
zoomable
ariaLabel="Übersichtskarte"
maskColor="rgba(249, 241, 245, 0.75)"
nodeColor={(n) => MINIMAP_COLORS[(n.data as OrgChartNodeDataLike).chartNode.kind] ?? "#d6046e"}
maskColor="rgba(253, 246, 244, 0.75)"
nodeColor={(n) => MINIMAP_COLORS[(n.data as OrgChartNodeDataLike).chartNode.kind] ?? "#164194"}
nodeStrokeWidth={0}
nodeBorderRadius={3}
/>

View File

@@ -25,14 +25,18 @@ const KIND_ACCENT: Record<ChartNodeKind, string> = {
person: "bg-brand-500",
role: "bg-purple-text",
group: "bg-ink-muted",
vacancy: "bg-brand-200",
// Rosa statt einer blassen Blaustufe: eine offene Stelle ist kein
// schwaecheres Abbild einer Person, sondern etwas anderes. Gegen das
// Blau der Person ist der Unterschied auch dann noch zu sehen, wenn
// herausgezoomt nur noch die Streifen uebrig sind.
vacancy: "bg-accent-500",
};
const KIND_SHELL: Record<ChartNodeKind, string> = {
person: "border-border bg-white",
role: "border-border bg-white",
group: "border-border-subtle bg-surface",
vacancy: "border-dashed border-brand-200 bg-brand-50",
vacancy: "border-dashed border-accent-500 bg-accent-50",
};
// React Flow re-renders node components on every pan/zoom frame — memo is
@@ -48,7 +52,7 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
{kind === "person" && avatar ? (
<Avatar firstName={avatar.firstName} lastName={avatar.lastName} size="sm" />
) : kind === "vacancy" ? (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed border-brand-200 text-brand-500">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed border-accent-500 text-accent-700">
<Plus className="h-3.5 w-3.5" />
</span>
) : kind === "role" ? (

View File

@@ -538,10 +538,12 @@ function Stapel({ units, ebenen, mitPersonen }: { units: PrintUnit[]; ebenen: nu
const KOPFFARBE: Record<PrintUnit["unitType"], string> = {
// Nach unten hin blasser: so bleibt die Rangfolge auch auf dem
// Schwarzweissdrucker sichtbar, auf dem die meisten Ausdrucke landen.
// Vier Stufen einer Farbe statt drei plus der Seitenflaeche: so ist es
// eine Reihe und nicht eine Reihe mit einem Fremdkoerper am Ende.
Gesellschaft: "bg-brand-500 text-white",
Bereich: "bg-brand-100 text-ink",
Abteilung: "bg-brand-50 text-ink",
Team: "bg-surface text-ink",
Bereich: "bg-brand-200 text-ink",
Abteilung: "bg-brand-100 text-ink",
Team: "bg-brand-50 text-ink",
};
function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }) {

View File

@@ -4,6 +4,7 @@ import { BarChart3, Building2, History, LayoutGrid, Network, Upload, Users, X }
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { AppWortmarke } from "@/components/brand/Logo";
const NAV_ITEMS = [
{ href: "/", label: "Übersicht", icon: LayoutGrid },
@@ -55,8 +56,12 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
open ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border pl-5 pr-3">
<span className="text-base font-extrabold text-brand-700">Alpenwerk HR</span>
{/* pl-2 statt pl-5: der Freiraum des Logos bringt seinen eigenen
Abstand mit (siehe components/brand/Logo.tsx), und beides
uebereinander schoebe die Wortmarke sichtbar aus der Flucht der
Navigationspunkte darunter. */}
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border pl-2 pr-3">
<AppWortmarke hoehe={24} textKlasse="text-brand-700" />
<button
type="button"
onClick={onClose}

View File

@@ -14,7 +14,10 @@ const BASE =
const VARIANTS = {
primary: "bg-brand-500 text-white hover:bg-brand-600",
secondary: "border border-border bg-white text-ink-body hover:bg-surface",
// border-strong wie bei den Eingabefeldern: die Umrandung ist hier das
// Einzige, was den Knopf vom Hintergrund trennt, und muss deshalb die
// 3:1 aus WCAG 1.4.11 erreichen.
secondary: "border border-border-strong bg-white text-ink-body hover:bg-surface",
ghost: "text-ink-body hover:bg-surface",
danger: "bg-danger-solid text-white hover:brightness-110",
// Square icon-only button; pair with an aria-label.

View File

@@ -12,16 +12,21 @@ import { useId, type ReactNode, type SelectHTMLAttributes, type InputHTMLAttribu
// impossible to get wrong, and gives every field one place to fix focus
// styling, error display and sizing.
// `border-strong` und nicht `border`: der blasse Kartenrahmen kommt gegen
// Weiss auf 1.3:1 und ist als Schmuck in Ordnung, die Umrandung eines
// Bedienelements aber nicht — WCAG 1.4.11 verlangt dafuer 3:1, und ohne sie
// ist auf einem hellen Bildschirm nicht zu sehen, wo ein Feld anfaengt.
// border-strong liegt bei 3.56:1.
export const CONTROL_CLASS =
"w-full rounded border border-border bg-white px-3 py-2 text-sm text-ink " +
"w-full rounded border border-border-strong bg-white px-3 py-2 text-sm text-ink " +
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500 " +
"disabled:cursor-not-allowed disabled:bg-surface disabled:text-ink-muted";
"disabled:cursor-not-allowed disabled:border-border disabled:bg-surface disabled:text-ink-muted";
const INVALID_CLASS = "border-danger-solid";
/** Auto-width select for filter bars, where the label is an aria-label. */
export const FILTER_SELECT_CLASS =
"rounded border border-border bg-white px-3 py-2 text-sm text-ink " +
"rounded border border-border-strong bg-white px-3 py-2 text-sm text-ink " +
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500";
type FieldChildProps = {

View File

@@ -126,7 +126,7 @@ export function Lookup<T>({
return (
<div ref={containerRef} className="relative">
<div className="flex items-center gap-2 rounded border border-border bg-white px-3 py-2 focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-brand-500">
<div className="flex items-center gap-2 rounded border border-border-strong bg-white px-3 py-2 focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-brand-500">
<Search className="h-4 w-4 shrink-0 text-ink-muted" aria-hidden />
<input
id={id}

View File

@@ -25,7 +25,7 @@ export function SearchInput({
const id = useId();
return (
<div
className={`flex min-w-[240px] flex-1 items-center gap-2 rounded border border-border bg-white px-3 py-2 focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-brand-500 ${className}`}
className={`flex min-w-[240px] flex-1 items-center gap-2 rounded border border-border-strong bg-white px-3 py-2 focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-brand-500 ${className}`}
>
<Search className="h-4 w-4 shrink-0 text-ink-muted" aria-hidden />
<label htmlFor={id} className="sr-only">