design v5: alle Seiten im neuen Look

- Gemeinsamer Lagekopf für beide Cockpits (Statusblock mit Schein, Kacheln mit
  Logo/Symbol und Zustandspunkt); KI-Box-Lampen als Dienst-Kacheln, Texte in
  normaler Schreibung. Alte Lampen-Komponente und Leuchtenfarben entfernt.
- Instrumente, Zählwerk, Checkliste, Flugplan, Radar-Kasten im neuen Stil;
  Instrumente ruhig in Indigo, Farbe für Zustände erst ab der Grenze.
- Übrige Seiten (Updates, Modelle, Nutzung, Suche, Monitoring, Dienste,
  Einstellungen, Protokoll, Dialoge): Glasflächen, feine Linien, Etiketten
  statt Instrumenten-Schild, Glas-Eingabefelder, deckende Dialoge.
- Dienste der KI-Box und Geräte im Monitoring mit Symbol bzw. Logo.
- Einstellungen: Karten mit Bereichs-Schein; Lizenzhinweis der Logos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 15:46:35 +02:00
co-authored by Claude Opus 5.5
parent db452aa38a
commit 0d2687436e
30 changed files with 301 additions and 262 deletions
@@ -38,7 +38,7 @@ export function Auftraege({ alsPanel = false }: { alsPanel?: boolean }) {
if (alsPanel) return <Panel titel="Aufträge">{inhalt}</Panel>
return (
<section aria-label="Aufträge" className="flex flex-col gap-2.5">
<h3 className="schild m-0 text-sm text-text-3">Aufträge</h3>
<h3 className="etikett m-0 text-[11.5px] tracking-[0.16em] text-text-3">Aufträge</h3>
{inhalt}
</section>
)
@@ -26,13 +26,13 @@ export function Bestaetigen({ offen, titel, text, knopf, neinKnopf = "Abbrechen"
{/* Nie höher als der Bildschirm: Auf kleinen Handys (320 × 568) lagen die Knöpfe sonst außerhalb
und ließen sich nicht erreichen. Der Text scrollt, die Knöpfe bleiben unten stehen — bündig,
darum ohne Bodenpolster (sticky rechnet das Polster ein, darunter schien sonst Text durch). */}
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto border-linie bg-panel pb-0 sm:max-w-lg">
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto border-white/10 bg-[#0e121a] pb-0 sm:max-w-lg">
<DialogHeader>
<DialogTitle className="schild pr-10 text-xl break-words">{titel}</DialogTitle>
<DialogTitle className="pr-10 text-[19px] leading-snug font-semibold break-words">{titel}</DialogTitle>
<DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription>
</DialogHeader>
{children}
<DialogFooter className="sticky bottom-0 mb-0 gap-2 bg-erhaben sm:gap-2">
<DialogFooter className="sticky bottom-0 mb-0 gap-2 bg-[#121722] sm:gap-2">
<Button variant="outline" onClick={onNein}>
{neinKnopf}
</Button>
@@ -42,11 +42,12 @@ function Punkt({ h }: { h: Hinweis }) {
}
return (
<article className="flex flex-col gap-2.5 border-t border-linie pt-4">
<article className={cn("flex flex-col gap-2.5 rounded-2xl px-4 py-3.5 ring-1 ring-inset",
rot ? "bg-rot/[0.07] ring-rot/35" : "bg-bernstein/[0.06] ring-bernstein/30")}>
<div className="flex items-center gap-3">
<h3 className="min-w-0 font-sans text-[17px] leading-snug font-semibold">{h.titel}</h3>
<span aria-hidden className="hidden h-0 flex-grow border-b-2 border-dotted border-linie-stark sm:block" />
<span className={cn("schild shrink-0 text-base", rot ? "text-rot" : "text-bernstein")}>
<h3 className="min-w-0 flex-1 font-sans text-[16.5px] leading-snug font-semibold">{h.titel}</h3>
<span className={cn("etikett shrink-0 rounded-full px-2.5 py-0.5 text-[10.5px] tracking-[0.12em] ring-1 ring-inset",
rot ? "bg-rot/12 text-rot-text ring-rot/40" : "bg-bernstein/10 text-bernstein ring-bernstein/35")}>
{rot ? "Jetzt" : "Prüfen"}
</span>
</div>
@@ -101,7 +102,7 @@ export function Checkliste({ hinweise, verlauf }: { hinweise: Hinweis[]; verlauf
)
if (hinweise.length === 0) {
return (
<div className="flex flex-col gap-2 border-t border-linie pt-4">
<div className="flex flex-col gap-2">
<p className="text-[15px] text-text-2">Keine offenen Punkte. Der Wächter prüft jede Minute.</p>
{erledigtHeute.length > 0 && (
<ul className="flex flex-col gap-1 text-sm text-text-3">
@@ -116,7 +117,7 @@ export function Checkliste({ hinweise, verlauf }: { hinweise: Hinweis[]; verlauf
)
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
{hinweise.map((h) => (
<Punkt key={h.id} h={h} />
))}
+10 -7
View File
@@ -2,10 +2,10 @@ import { cn } from "cn"
import type { FlugplanEintrag } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string }> = {
erledigt: { text: "Erledigt", farbe: "text-gruen" },
fehler: { text: "Fehler", farbe: "text-rot" },
geplant: { text: "Geplant", farbe: "text-text-3" },
const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string; punkt: string }> = {
erledigt: { text: "Erledigt", farbe: "text-gruen-text", punkt: "bg-gruen" },
fehler: { text: "Fehler", farbe: "text-rot-text", punkt: "bg-rot" },
geplant: { text: "Geplant", farbe: "text-text-3", punkt: "bg-white/25" },
}
// Die Liste richtet sich nach der Breite ihres Feldes, nicht des Bildschirms (@container): Schmal —
@@ -14,15 +14,18 @@ const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string }>
function Zeile({ e }: { e: FlugplanEintrag }) {
const s = STATUS[e.status] ?? STATUS.geplant
// Updates sind das Wichtigste im Plan — sie stehen in Cyan.
const farbe = e.status === "geplant" && /update/i.test(e.titel) ? "text-cyan" : s.farbe
const farbe = e.status === "geplant" && /update/i.test(e.titel) ? "text-cyan-text" : s.farbe
return (
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 border-t border-linie py-2.5 @sm:grid-cols-[112px_minmax(0,1fr)_auto]">
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 border-t border-white/[0.06] py-2.5 first:border-t-0 @sm:grid-cols-[112px_minmax(0,1fr)_auto]">
<span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{flugplanZeit(e.zeit)}</span>
<span className="col-span-2 row-start-2 min-w-0 text-base break-words @sm:col-span-1 @sm:col-start-2 @sm:row-start-1">
{e.titel}
{e.text && <span className="text-text-3">, {e.text}</span>}
</span>
<span className={cn("schild col-start-2 row-start-1 justify-self-end text-[13px] @sm:col-start-3", farbe)}>{s.text}</span>
<span className={cn("etikett col-start-2 row-start-1 flex items-center gap-1.5 justify-self-end text-[10.5px] tracking-[0.12em] @sm:col-start-3", farbe)}>
<span aria-hidden className={cn("size-1.5 rounded-full", s.punkt)} />
{s.text}
</span>
</li>
)
}
@@ -9,7 +9,7 @@ export interface Zone {
export function Instrument({
anteil,
farbe = "var(--cyan)",
farbe = "var(--box)",
zonen = [],
mitte,
unter,
@@ -28,23 +28,24 @@ export function Instrument({
return (
<figure className="m-0 flex min-w-0 flex-col items-center gap-1">
<svg viewBox="0 0 200 150" className="h-auto w-full max-w-[200px]" role="img" aria-label={ariaText}>
<path d={bogenPfad(0, 1, 75)} fill="none" stroke="var(--linie)" strokeWidth={12} strokeLinecap="round" />
<path d={bogenPfad(0, 1, 75)} fill="none" stroke="rgb(255 255 255 / 0.07)" strokeWidth={12} strokeLinecap="round" />
{zonen.map((z) => (
<path key={`${z.von}-${z.bis}`} d={bogenPfad(z.von, z.bis, 86)} fill="none" stroke={z.farbe} strokeWidth={4} strokeLinecap="round" />
))}
{zeigen && (
<path d={bogenPfad(0, anteil, 75)} fill="none" stroke={farbe} strokeWidth={12} strokeLinecap="round" />
<path d={bogenPfad(0, anteil, 75)} fill="none" stroke={farbe} strokeWidth={12} strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 7px color-mix(in srgb, ${farbe} 60%, transparent))` }} />
)}
<text x="100" y="100" textAnchor="middle" className="ziffern" style={{ fontSize: 34, fontWeight: 500, fill: "var(--foreground)" }}>
<text x="100" y="102" textAnchor="middle" style={{ fontFamily: "var(--font-anzeige)", fontSize: 40, fontWeight: 600, fill: "var(--foreground)" }}>
{mitte}
</text>
{unter && (
<text x="100" y="122" textAnchor="middle" style={{ fontFamily: "var(--font-anzeige)", fontSize: 14, fontWeight: 600, letterSpacing: "0.12em", fill: "var(--text-3)" }}>
{unter.toUpperCase()}
<text x="100" y="124" textAnchor="middle" style={{ fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 500, fill: "var(--text-3)" }}>
{unter}
</text>
)}
</svg>
<figcaption className="schild text-[15px] text-text-3">{beschriftung}</figcaption>
<figcaption className="etikett text-[11.5px] tracking-[0.14em] text-text-3">{beschriftung}</figcaption>
</figure>
)
}
+1 -1
View File
@@ -62,7 +62,7 @@ export function JobKarte({ job, onAbbrechen, onQuittieren, abbrechenGesperrt, eb
const Titel = ebene === 3 ? "h3" : "h4"
return (
<article aria-label={`Auftrag: ${titel}`} className="flex flex-col gap-2.5 rounded-xl border border-linie bg-erhaben/50 p-3.5">
<article aria-label={`Auftrag: ${titel}`} className="flex flex-col gap-2.5 flaeche-innen rounded-2xl p-3.5">
<div className="flex items-start justify-between gap-3">
<Titel className="m-0 min-w-0 text-[15px] leading-snug font-semibold break-words">{titel}</Titel>
<Chip art={zustand.art}>{zustand.text}</Chip>
@@ -0,0 +1,97 @@
import type { ReactNode } from "react"
import { cn } from "cn"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import type { Geraetebild, Punktzustand } from "@/lib/geraetebild"
import type { LampenZustand } from "@/lib/typen"
// Der Kopf beider Cockpits (Design v5, 25.09.2026): links der Statusblock mit Schein in der Farbe der Lage — drücken
// führt dorthin, wo etwas zu tun ist —, rechts die Kacheln der Geräte bzw. Dienste. Zustand immer auch in Worten.
export type Lageart = "ok" | "info" | "gelb" | "rot" | "stumm"
const ART: Record<Lageart, { block: string; glut: string; text: string }> = {
ok: { block: "bg-gruen/[0.07] ring-gruen/30", glut: "var(--gruen)", text: "text-gruen" },
info: { block: "bg-cyan/[0.07] ring-cyan/30", glut: "var(--cyan)", text: "text-cyan-text" },
gelb: { block: "bg-bernstein/[0.08] ring-bernstein/35", glut: "var(--bernstein)", text: "text-bernstein" },
rot: { block: "bg-rot/10 ring-rot/40", glut: "var(--rot)", text: "text-rot-text" },
stumm: { block: "bg-white/[0.04] ring-white/15", glut: "var(--text-3)", text: "text-text-2" },
}
export function Lagekopf({ art, oben, mitte, unten, aktiv, onAnsehen, label, children }: {
art: Lageart
oben: string
mitte: string
unten: string
aktiv: boolean
onAnsehen: () => void
/** Name des Abschnitts für Screenreader („Lage im Homelab“). */
label: string
/** Die Kacheln rechts (eine Liste von <li>). */
children: ReactNode
}) {
const a = ART[art]
return (
<section aria-label={label} className="auftauchen flaeche relative overflow-hidden rounded-3xl">
<div aria-hidden className="glut-atmet pointer-events-none absolute -top-40 -left-32 size-[460px] rounded-full"
style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${a.glut} 20%, transparent), transparent)` }} />
<div className="relative grid gap-4 p-4 sm:p-5 lg:grid-cols-[280px_minmax(0,1fr)] lg:gap-5">
<button
type="button"
onClick={onAnsehen}
disabled={!aktiv}
aria-label={`${oben}: ${mitte}`}
className={cn(
"flex min-h-[132px] flex-col items-start justify-center gap-1.5 rounded-2xl px-5 py-4 text-left ring-1 ring-inset transition-[filter]",
a.block, aktiv ? "cursor-pointer hover:brightness-125" : "cursor-default",
art === "gelb" && "lampe-atmet",
)}
>
<span className="etikett text-[11px] tracking-[0.18em] text-text-3">{oben}</span>
<span className={cn("text-[30px] leading-tight font-semibold tracking-[-0.01em] sm:text-[34px]", a.text)}>{mitte}</span>
<span className="text-[13.5px] text-text-2">{unten}</span>
</button>
<ul className="m-0 grid list-none grid-cols-1 gap-2 p-0 min-[480px]:grid-cols-2 xl:grid-cols-4">{children}</ul>
</div>
</section>
)
}
const LAMPE_PUNKT: Record<LampenZustand, Punktzustand> = { ok: "ok", aus: "aus", warn: "warn", info: "info", fehler: "rot" }
const WERT: Record<LampenZustand, string> = {
ok: "text-text-3", aus: "text-text-3", warn: "text-bernstein", info: "text-cyan-text", fehler: "text-rot-text",
}
const ZUSTAND_TEXT: Record<LampenZustand, string> = {
ok: "in Ordnung", aus: "aus", warn: "braucht Aufmerksamkeit", info: "Info", fehler: "Störung",
}
/** Eine Kachel im Lagekopf: Bild mit Zustandspunkt, Name, darunter der Wert in der Farbe des Zustands. */
export function Lagekachel({ bild, name, wert, zustand, onClick }: {
bild: Geraetebild
name: string
wert: string
zustand: LampenZustand
onClick?: () => void
}) {
const inhalt = (
<>
<Geraetesymbol bild={bild} punkt={LAMPE_PUNKT[zustand]} />
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-[14.5px] leading-snug font-medium">{name}</span>
<span className={cn("truncate text-[12.5px] leading-snug", WERT[zustand])}>{wert}</span>
<span className="sr-only">{ZUSTAND_TEXT[zustand]}</span>
</span>
</>
)
const stil = "flaeche-innen flex h-full w-full min-w-0 items-center gap-3 rounded-xl px-3 py-2.5 text-left"
return (
<li>
{onClick ? (
<button type="button" onClick={onClick} className={cn(stil, "transition-colors hover:border-white/20 hover:bg-white/[0.05]")}>
{inhalt}
</button>
) : (
<div className={stil}>{inhalt}</div>
)}
</li>
)
}
-42
View File
@@ -1,42 +0,0 @@
import { cn } from "cn"
import type { Lampe as LampenDaten, LampenZustand } from "@/lib/typen"
// Warnlampen wie im Flugzeug: Grün = läuft, dunkel = aus/auf Abruf, Bernstein = kümmern,
// Cyan = Info, Rot = Störung. Bernstein und Rot leuchten, alles andere bleibt ruhig.
const LAMPEN_STIL: Record<LampenZustand, { feld: string; name: string; wert: string }> = {
ok: { feld: "border-gruen-rand bg-gruen-grund", name: "text-gruen", wert: "text-gruen-text" },
aus: { feld: "border-aus-rand bg-aus-grund", name: "text-aus-text", wert: "text-text-3" },
warn: { feld: "border-bernstein bg-bernstein-lampe lampe-atmet", name: "text-bernstein", wert: "text-[#f2c46b]" },
info: { feld: "border-cyan-rand bg-cyan-grund", name: "text-cyan", wert: "text-cyan-text" },
fehler: {
feld: "border-rot bg-rot-grund shadow-[0_0_18px_rgba(255,90,79,0.35)]",
name: "text-rot",
wert: "text-rot-text",
},
}
const ZUSTAND_TEXT: Record<LampenZustand, string> = {
ok: "in Ordnung",
aus: "aus",
warn: "braucht Aufmerksamkeit",
info: "Info",
fehler: "Störung",
}
export function Lampe({ lampe }: { lampe: LampenDaten }) {
const stil = LAMPEN_STIL[lampe.zustand] ?? LAMPEN_STIL.aus
return (
<li
className={cn(
"flex min-h-[58px] flex-col items-center justify-center gap-0.5 rounded-lg border px-2 py-2 text-center",
stil.feld,
)}
>
<span className={cn("schild text-[17px] leading-none font-bold tracking-[0.18em]", stil.name)}>
{lampe.label}
</span>
<span className={cn("ziffern text-xs uppercase", stil.wert)}>{lampe.wert}</span>
<span className="sr-only">{ZUSTAND_TEXT[lampe.zustand]}</span>
</li>
)
}
@@ -11,12 +11,12 @@ export function Protokollfenster({ offen, titel, text, onSchliessen }: {
}) {
return (
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent side="right" className="border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl">
<SheetContent side="right" className="border-white/10 bg-[#0e121a] data-[side=right]:w-full data-[side=right]:sm:max-w-2xl">
<SheetHeader>
<SheetTitle className="schild text-lg">Protokoll</SheetTitle>
<SheetTitle className="text-[19px] font-semibold">Protokoll</SheetTitle>
<SheetDescription className="text-text-2">{titel}</SheetDescription>
</SheetHeader>
<pre className="ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2">
<pre className="ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg bg-black/30 ring-1 ring-white/[0.07] ring-inset p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2">
{text}
</pre>
</SheetContent>
+13 -26
View File
@@ -1,39 +1,26 @@
import { cn } from "cn"
import { Lagekachel, Lagekopf } from "@/components/cockpit/Lagekopf"
import { hauptleuchte } from "@/lib/anzeige"
import { boxbild } from "@/lib/geraetebild"
import type { Lampe as LampenDaten, Start } from "@/lib/typen"
import { hauptleuchte, LEUCHTE } from "@/lib/anzeige"
import { Lampe } from "./Lampe"
type Zustand = Start["zustand"]
/**
* Der Kopf des KI-Box-Cockpits: links die Hauptleuchte (drücken führt zur Checkliste, wenn etwas offen ist), rechts
* je Dienst eine Kachel — Motor, Hirn, Coder, Hermes, Aufträge, Sicherung, Platte, Updates.
*/
export function Warnpanel({ zustand, lampen, onAnsehen }: {
zustand: Zustand
lampen: LampenDaten[]
onAnsehen: () => void
}) {
const h = hauptleuchte(zustand)
const aktiv = h.art === "gelb" || h.art === "rot"
return (
<section aria-label="Warnpanel" className="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)] md:gap-5">
<button
type="button"
onClick={onAnsehen}
disabled={!aktiv}
aria-label={`${h.oben} ${h.mitte}`}
className={cn(
"flex min-h-[130px] flex-col items-center justify-center gap-1 rounded-2xl border px-4 py-4 font-anzeige transition-colors",
LEUCHTE[h.art],
aktiv ? "cursor-pointer" : "cursor-default",
)}
>
<span className="schild text-sm tracking-[0.26em]">{h.oben}</span>
<span className="text-[40px] leading-none font-bold tracking-[0.04em] sm:text-[44px]">{h.mitte}</span>
<span className="font-sans text-sm opacity-80">{h.unten}</span>
</button>
<ul className="grid grid-cols-2 gap-2.5 rounded-2xl border border-linie bg-panel p-3 sm:grid-cols-4">
{lampen.map((l) => (
<Lampe key={l.id} lampe={l} />
))}
</ul>
</section>
<Lagekopf art={h.art} oben={h.oben} mitte={h.mitte} unten={h.unten} aktiv={h.art === "gelb" || h.art === "rot"}
onAnsehen={onAnsehen} label="Warnpanel">
{lampen.map((l) => (
<Lagekachel key={l.id} bild={boxbild(l.id)} name={l.label} wert={l.wert} zustand={l.zustand} />
))}
</Lagekopf>
)
}
@@ -5,7 +5,7 @@ import { laufzeit } from "@/lib/zeit"
// es bei den alten 38 × 56 px je Ziffer.
function Ziffer({ z }: { z: string }) {
return (
<span className="ziffern inline-flex h-[1.75em] w-[1.1875em] shrink-0 items-center justify-center rounded-md border border-[#2a3036] bg-[#0b0d0f] font-medium">
<span className="ziffern inline-flex h-[1.75em] w-[1.1875em] shrink-0 items-center justify-center rounded-lg border border-white/10 bg-gradient-to-b from-white/[0.07] to-white/[0.02] font-medium shadow-[inset_0_1px_0_rgb(255_255_255/0.06)]">
{z}
</span>
)
@@ -27,12 +27,12 @@ export function Zaehlwerk({ sekunden }: { sekunden: number | null | undefined })
>
<Ziffer z={lz ? tage[0] : "–"} />
<Ziffer z={lz ? tage[1] : "–"} />
<span className="schild mr-[0.444em] ml-[0.111em] text-[0.5625em] font-bold text-text-3">T</span>
<span className="etikett mr-[0.444em] ml-[0.111em] text-[0.5625em] text-text-3">T</span>
<Ziffer z={lz ? std[0] : "–"} />
<Ziffer z={lz ? std[1] : "–"} />
<span className="schild ml-[0.111em] text-[0.5625em] font-bold text-text-3">H</span>
<span className="etikett ml-[0.111em] text-[0.5625em] text-text-3">H</span>
</div>
<figcaption className="schild text-[15px] text-text-3">Ohne Neustart</figcaption>
<figcaption className="etikett text-[11.5px] tracking-[0.14em] text-text-3">Ohne Neustart</figcaption>
</figure>
)
}
@@ -22,12 +22,12 @@ describe("bogenPfad", () => {
describe("hauptleuchte", () => {
it("bleibt ruhig, wenn nichts offen ist", () => {
expect(hauptleuchte(ruhig)).toMatchObject({ art: "ok", mitte: "IN ORDNUNG" })
expect(hauptleuchte(ruhig)).toMatchObject({ art: "ok", mitte: "In Ordnung" })
})
it("zeigt Bernstein bei gelben und Rot bei roten Hinweisen", () => {
expect(hauptleuchte({ ...ruhig, stufe: "gelb", anzahl: 2 })).toMatchObject({ art: "gelb", mitte: "2 HINWEISE" })
expect(hauptleuchte({ ...ruhig, stufe: "rot", anzahl: 1 })).toMatchObject({ art: "rot", mitte: "1 HINWEIS" })
expect(hauptleuchte({ ...ruhig, stufe: "gelb", anzahl: 2 })).toMatchObject({ art: "gelb", mitte: "2 Hinweise" })
expect(hauptleuchte({ ...ruhig, stufe: "rot", anzahl: 1 })).toMatchObject({ art: "rot", mitte: "1 Hinweis" })
})
it("meldet einen schweigenden Wächter vor allem anderen", () => {
@@ -50,6 +50,6 @@ describe("Warnpanel", () => {
expect(screen.getByText("Motor")).toBeInTheDocument()
expect(screen.getByText("b11057")).toBeInTheDocument()
expect(screen.getByText("braucht Aufmerksamkeit")).toBeInTheDocument()
expect(screen.getByRole("button", { name: /Achtung 2 HINWEISE/ })).toBeEnabled()
expect(screen.getByRole("button", { name: /Achtung: 2 Hinweise/ })).toBeEnabled()
})
})
@@ -31,7 +31,7 @@ function Anzeige({ saeule, reihen, summe, className, links }: {
links?: number
}) {
return (
<div role="status" className={cn("flex flex-col gap-1 rounded-lg border border-linie-stark bg-erhaben px-3 py-2", className)}
<div role="status" className={cn("flex flex-col gap-1 rounded-xl border border-white/12 bg-white/[0.04] px-3 py-2", className)}
style={links === undefined ? undefined : { left: links }}>
<span className="ziffern text-xs text-text-3">{saeule.lang}</span>
{reihen.map((r) => (
@@ -42,7 +42,7 @@ function Anzeige({ saeule, reihen, summe, className, links }: {
</span>
))}
{reihen.length > 1 && (
<span className="flex items-baseline gap-2 border-t border-linie pt-1 text-sm">
<span className="flex items-baseline gap-2 border-t border-white/[0.06] pt-1 text-sm">
<span className="ziffern font-semibold text-foreground">{zahl.format(summe)}</span>
<span className="text-text-3">zusammen</span>
</span>
@@ -208,7 +208,7 @@ export function Saeulen({ saeulen, reihen, titel, summen = "alle", hoehe = 200,
</thead>
<tbody>
{saeulen.map((s, i) => (
<tr key={s.schluessel} className="border-t border-linie">
<tr key={s.schluessel} className="border-t border-white/[0.06]">
<th scope="row" className="py-1 pr-3 text-left font-normal text-text-3">{s.lang}</th>
{sichtbar.map((r) => (
<td key={r.name} className="ziffern py-1 pr-3 text-right text-text-2">{zahl.format(s.werte[r.name] ?? 0)}</td>
@@ -132,7 +132,7 @@ export function Verlauf({ reihen, einheit, langerZeitraum, hoehe = 180, titel }:
{aktiv !== null && (
<div
role="status"
className="pointer-events-none absolute top-1 z-10 flex min-w-36 flex-col gap-1 rounded-lg border border-linie-stark bg-erhaben px-3 py-2 shadow-lg"
className="pointer-events-none absolute top-1 z-10 flex min-w-36 flex-col gap-1 rounded-xl border border-white/12 bg-white/[0.04] px-3 py-2 shadow-lg"
style={tipLinks > breite / 2 ? { right: breite - tipLinks + 10 } : { left: tipLinks + 10 }}
>
<span className="ziffern text-xs text-text-3">{zeitText(aktiv, true)}</span>
@@ -157,7 +157,7 @@ export function Verlauf({ reihen, einheit, langerZeitraum, hoehe = 180, titel }:
</thead>
<tbody>
{letzte.map(([t], i) => (
<tr key={t} className="border-t border-linie">
<tr key={t} className="border-t border-white/[0.06]">
<td className="py-1 pr-4 text-text-3">{zeitText(t, langerZeitraum)}</td>
{reihen.map((r) => (
<td key={r.name} className="py-1 pr-4 text-text-2">
+13 -57
View File
@@ -1,22 +1,7 @@
import { cn } from "cn"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { geraetebild, type Punktzustand } from "@/lib/geraetebild"
import { Lagekachel, Lagekopf } from "@/components/cockpit/Lagekopf"
import { geraetebild } from "@/lib/geraetebild"
import { type Lage, kurzname, lampeFuer } from "@/lib/homelab"
import type { LampenZustand, ZielStand } from "@/lib/typen"
// Farbe der Lage (Design v5): Tönung und Schein des Statusblocks. Zustand immer auch in Worten (oben/mitte/unten).
const ART: Record<Lage["art"], { block: string; glut: string; text: string }> = {
ok: { block: "bg-gruen/[0.07] ring-gruen/30", glut: "var(--gruen)", text: "text-gruen" },
info: { block: "bg-cyan/[0.07] ring-cyan/30", glut: "var(--cyan)", text: "text-cyan-text" },
gelb: { block: "bg-bernstein/[0.08] ring-bernstein/35", glut: "var(--bernstein)", text: "text-bernstein" },
rot: { block: "bg-rot/10 ring-rot/40", glut: "var(--rot)", text: "text-rot-text" },
stumm: { block: "bg-white/[0.04] ring-white/15", glut: "var(--text-3)", text: "text-text-2" },
}
const PUNKT: Record<LampenZustand, Punktzustand> = { ok: "ok", aus: "aus", warn: "warn", info: "info", fehler: "rot" }
const WERT: Record<LampenZustand, string> = {
ok: "text-text-3", aus: "text-text-3", warn: "text-bernstein", info: "text-cyan-text", fehler: "text-rot-text",
}
import type { ZielStand } from "@/lib/typen"
/**
* Die Lage im Homelab auf einen Blick: links der Statusblock (drücken führt dorthin, wo etwas zu tun ist), rechts je
@@ -30,45 +15,16 @@ export function Lagebild({ lage, ziele, imUpdate, onAnsehen, onGeraet }: {
/** Klick auf eine Kachel: Updates, wenn etwas ansteht, sonst die Zeile in der Geräte-Tabelle. */
onGeraet: (ziel: ZielStand, hatUpdate: boolean) => void
}) {
const a = ART[lage.art]
const aktiv = lage.ziel !== null
return (
<section aria-label="Lage im Homelab" className="auftauchen flaeche relative overflow-hidden rounded-3xl">
<div aria-hidden className="glut-atmet pointer-events-none absolute -top-40 -left-32 size-[460px] rounded-full"
style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${a.glut} 20%, transparent), transparent)` }} />
<div className="relative grid gap-4 p-4 sm:p-5 lg:grid-cols-[280px_minmax(0,1fr)] lg:gap-5">
<button
type="button"
onClick={onAnsehen}
disabled={!aktiv}
aria-label={`${lage.oben}: ${lage.mitte}`}
className={cn(
"flex min-h-[132px] flex-col items-start justify-center gap-1.5 rounded-2xl px-5 py-4 text-left ring-1 ring-inset transition-colors",
a.block, aktiv ? "cursor-pointer hover:brightness-125" : "cursor-default",
)}
>
<span className="etikett text-[11px] tracking-[0.18em] text-text-3">{lage.oben}</span>
<span className={cn("text-[30px] leading-tight font-semibold tracking-[-0.01em] sm:text-[34px]", a.text)}>{lage.mitte}</span>
<span className="text-[13.5px] text-text-2">{lage.unten}</span>
</button>
<ul aria-label="Geräte" className="m-0 grid list-none grid-cols-1 gap-2 p-0 min-[480px]:grid-cols-2 xl:grid-cols-4">
{ziele.map((z) => {
const l = lampeFuer(z, imUpdate.has(z.id))
return (
<li key={z.id}>
<button type="button" onClick={() => onGeraet(z, l.zustand === "info")}
className="flaeche-innen group flex h-full w-full min-w-0 items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors hover:border-white/20 hover:bg-white/[0.05]">
<Geraetesymbol bild={geraetebild(z)} punkt={PUNKT[l.zustand]} />
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-[14.5px] leading-snug font-medium">{kurzname(z)}</span>
<span className={cn("truncate text-[12.5px] leading-snug", WERT[l.zustand])}>{l.wert}</span>
</span>
</button>
</li>
)
})}
</ul>
</div>
</section>
<Lagekopf art={lage.art} oben={lage.oben} mitte={lage.mitte} unten={lage.unten} aktiv={lage.ziel !== null}
onAnsehen={onAnsehen} label="Lage im Homelab">
{ziele.map((z) => {
const l = lampeFuer(z, imUpdate.has(z.id))
return (
<Lagekachel key={z.id} bild={geraetebild(z)} name={kurzname(z)} wert={l.wert} zustand={l.zustand}
onClick={() => onGeraet(z, l.zustand === "info")} />
)
})}
</Lagekopf>
)
}
@@ -61,7 +61,7 @@ export function AlleKnopf({ anzahl, gesperrt }: { anzahl: number; gesperrt: bool
const SCHRITT: Record<SammelSchrittStatus, { Symbol: typeof CircleCheck; farbe: string; text: string }> = {
wartet: { Symbol: CircleDashed, farbe: "text-text-3", text: "wartet" },
laeuft: { Symbol: LoaderCircle, farbe: "text-cyan", text: "läuft" },
laeuft: { Symbol: LoaderCircle, farbe: "text-cyan-text", text: "läuft" },
eingespielt: { Symbol: CircleCheck, farbe: "text-gruen", text: "eingespielt" },
zurueckgerollt: { Symbol: Undo2, farbe: "text-bernstein", text: "zurückgerollt" },
fehler: { Symbol: CircleX, farbe: "text-rot", text: "Fehler" },