feat(v5): 5.5.0 — Kern und Oberfläche aufgeteilt, Übersicht und Einstellungen neu, Erst-Einrichtung mit Poster-Kulisse

Commander-Wünsche vom 02.09.2026 vor der Freigabe von 5.4.0 (die deshalb nie
veröffentlicht wurde):

Kern: kern/index.ts ist nur noch Bootstrap und Router; Dienste in
kern/dienste/ (Einstellungen, Werkzeuge, Laufwerke, Erkennung, Vorgänge,
Speicher, Benachrichtigung, Automatik), Kontext in kern/kontext.ts,
Protokoll-Datei in gemeinsam/protokoll.ts, Platz-Rechnung in gemeinsam/platz.ts.

Oberfläche: fenster/App.tsx ist nur noch der Rahmen; bausteine.tsx, dialoge/,
uebersicht/ (LaufwerkKachel, KompressionsKarte, SpeicherKarte, VerlaufKarte),
einstellungen/ (Karten je Funktion, SprachAuswahl), einrichtung/
(ErstEinrichtung, PosterHintergrund).

Neu: Bibliothek weg, dafür Verlauf; Speicher-und-Bilanz-Karte (statfs);
LED-Fußleiste; MakeMKV-Kopf in die Einstellungen; Sprach-Chips mit Dropdown
statt Allgemeinem Preset; Karten Automatik/Benachrichtigung/Allgemein/Update;
Durchsuchen bei Werkzeugen; Arbeitsordner lokal (Encode dort, dann in die
Ablage); Platz-Prüfung vor dem Rip (Dialog und Kern); Automatik mit Countdown;
Serien-Merker über Discs; Rettungs-Abbild mit Nullen nur nach gesehenen
Lesefehlern (kern/rip/rettung.ts, Phase rettet, iso:-Quelle); Warteschlange
pausieren; Discord/Telegram-Benachrichtigung; Protokoll-Datei je Tag;
Fensterlage 80 % mittig, gemerkt; Erst-Einrichtung in acht Schritten vor dem
Poster-Grid.

Beweise: 413 Tests (neu: speicher, benachrichtigung, automatik, rettung,
fensterlage, protokoll, kleinkram-550), drei Typprüfungen, Klick-Beweis mit
29 Schritten inklusive Erst-Einrichtung (zweiter Start mit
RIPPY_START_BEREICH=einrichtung), Bilder beweise/klick/01…09.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-02 17:51:15 +02:00
co-authored by Claude Fable 5.1
parent 6678c81592
commit 3dc6798c79
67 changed files with 6961 additions and 3950 deletions
@@ -0,0 +1,485 @@
// Die Bühne: Backdrop als Panorama, Poster mit Goldkante, Plakat-Titel —
// und beim Arbeiten die Aussteuerungsanzeige samt Mono-Protokoll.
// 5.4.0: Kino-Banner v2 (Logo, Tagline, Meta-Zeile, Darsteller), Laufwerks-
// Gesundheit, Stand der Kompression nach dem Rip. 5.5.0: Automatik-Countdown
// und das Rettungs-Abbild — nur, wenn Rippy Lesefehler gesehen hat.
import { useState } from 'react'
import type {
AutomatikStand,
DiscInfoStand,
GeraeteInfo,
KompressionStand,
LaufwerkBericht,
RipStatus,
VorgangEintrag,
} from '../../gemeinsam/nachrichten'
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
import {
Badge,
IconDisc,
IconEject,
IconPlay,
IconStift,
IconStop,
InfoKachel,
LAUFENDE_PHASEN,
PHASEN_MONO,
SegmentBalken,
TYP_NAMEN,
backdropUrl,
discHerkunft,
gb,
logoUrl,
metaZeile,
posterUrlGross,
profilUrl,
restzeitText,
sicherheitsWort,
} from '../bausteine'
import {
automatikJetzt,
automatikStopp,
auswerfen,
laufwerkPruefen,
rettungStarten,
ripAbbrechen,
ripUeberspringen,
type Verbindung,
} from '../kernverbindung'
/** Welches Preset diese Disc bekäme — dieselbe Kette wie im Kern
* (eigene Wahl → Empfehlung → Default). */
export function presetAnzeige(typ: GeraeteInfo['typ'], verbindung: Verbindung): string {
if (typ === 'cd') return 'FLAC (verlustfrei)'
if (typ === 'unknown') return 'ISO-Abbild'
const werte = verbindung.einstellungen
if (werte['transcodeEnabled'] === 'false') return 'ohne Kompression'
const schluessel = typ === 'dvd' ? 'transcodePresetDvd' : typ === 'uhd' ? 'transcodePresetUhd' : 'transcodePresetBluray'
const eigene = werte[schluessel] ?? ''
const empfehlung = presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], verbindung.werkzeuge?.presets ?? [])
const gewaehlt = eigene.length > 0 ? eigene : empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
return gewaehlt === 'keine' ? 'ohne Kompression' : gewaehlt
}
export function LaufwerkKachel({
geraet,
rip,
info,
ripStartMs,
protokoll,
verbindung,
bericht,
vorgang,
kompression,
wartePlatz,
automatik,
aufRippen,
aufAendern,
}: {
geraet: GeraeteInfo
rip: RipStatus | undefined
info: DiscInfoStand | undefined
ripStartMs: number | undefined
protokoll: string[]
verbindung: Verbindung
/** Was das Windows-Protokoll zu diesem Laufwerk sagt (5.4.0). */
bericht: LaufwerkBericht | undefined
/** Der Vorgang des letzten Rips — für den Stand der Kompression (5.4.0). */
vorgang: VorgangEintrag | undefined
kompression: KompressionStand | null
wartePlatz: number
/** Der Automatik-Countdown (5.5.0). */
automatik: AutomatikStand | undefined
/** Die Dialoge leben im App-Zustand (5.4.0): Ein leerer Takt der Wache
* räumte sonst Kachel UND Dialog ab. */
aufRippen: () => void
aufAendern: () => void
}) {
const [protokollOffen, setProtokollOffen] = useState(false)
const [berichtOffen, setBerichtOffen] = useState(false)
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
const discDa = geraet.status === 'ready'
const backdrop = discDa && info !== undefined ? backdropUrl(info.backdropPfad) : ''
const poster = discDa && info !== undefined ? posterUrlGross(info.posterPfad) : ''
const erkannt = discDa && info !== undefined && info.titel.length > 0
const logo = erkannt ? logoUrl(info.logoPfad) : ''
const rettungMoeglich = discDa && !ripLaeuft && rip !== undefined && rip.rettungMoeglich === true
const berichtTon =
bericht === undefined
? ''
: bericht.urteil === 'ruhig'
? 'text-gruen border-gruen/40 bg-gruen/10'
: bericht.urteil === 'disc'
? 'text-warn border-warn/45 bg-warn/10'
: bericht.urteil === 'hardware'
? 'text-rot border-rot/45 bg-rot/10'
: 'text-dunst border-white/12 bg-samt/60'
return (
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-karte/50">
{backdrop.length > 0 && (
<>
<img src={backdrop} alt="" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-r from-buehne via-buehne/85 to-buehne/45" />
<div className="absolute inset-0 bg-gradient-to-t from-buehne via-transparent to-transparent" />
</>
)}
<div className="relative p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span
className={`inline-block h-2 w-2 rounded-full ${
ripLaeuft
? 'animate-pulse bg-rec shadow-[0_0_9px_oklch(0.72_0.17_45/0.9)]'
: discDa
? 'bg-gruen'
: geraet.status === 'empty'
? 'bg-white/25'
: 'bg-rot'
}`}
/>
<span className="text-dunst">LAUFWERK {geraet.id}:</span>
<span>·</span>
<span className="truncate">{geraet.modell.length > 0 ? geraet.modell : 'MODELL UNBEKANNT'}</span>
</div>
<div className="flex flex-none gap-2">
{ripLaeuft ? (
<button
onClick={() => ripAbbrechen(geraet.id)}
className="inline-flex items-center gap-2 rounded-xl border border-rot/60 bg-rot/10 px-4 py-1.5 text-xs font-semibold text-rot hover:bg-rot/20"
>
<IconStop />
Abbrechen
</button>
) : (
<button
onClick={aufRippen}
disabled={!discDa}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4),0_8px_24px_oklch(0.75_0.13_60/0.3)] hover:from-gold-hell hover:to-gold disabled:cursor-not-allowed disabled:opacity-35 disabled:shadow-none"
title="Titel der Disc lesen und auswaehlen — danach verlustfrei rippen"
>
<IconPlay />
Rippen
</button>
)}
<button
onClick={() => auswerfen(geraet.id)}
disabled={!discDa || ripLaeuft}
className="inline-flex items-center gap-2 rounded-xl border border-white/15 bg-gradient-to-b from-karte/80 to-samt/80 px-4 py-1.5 text-xs text-dunst shadow-[inset_0_1px_0_oklch(1_0_0/0.07)] hover:text-schnee disabled:cursor-not-allowed disabled:opacity-35"
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
>
<IconEject />
Auswerfen
</button>
</div>
</div>
{!discDa && (
<div className="mt-5 flex items-center gap-5 py-5">
<IconDisc groesse={52} farbe={geraet.status === 'empty' ? 'oklch(0.4 0.02 75)' : 'oklch(0.68 0.19 25)'} />
<p className={`max-w-2xl font-mono text-xs leading-5 ${geraet.status === 'empty' ? 'text-nebel' : 'text-rot'}`}>
{geraet.status === 'empty' ? 'Keine Disc eingelegt — Disc rein genügt, Rippy meldet sich.' : geraet.grund}
</p>
</div>
)}
{discDa && (
<div className="mt-4 flex gap-5">
{poster.length > 0 ? (
<img src={poster} alt="" className="h-44 w-[118px] flex-none rounded-xl object-cover shadow-2xl shadow-black/60 ring-1 ring-gold/35" />
) : (
<div className="flex h-44 w-[118px] flex-none items-center justify-center rounded-xl bg-gradient-to-br from-karte to-samt ring-1 ring-white/10">
<IconDisc groesse={40} farbe="oklch(0.5 0.05 75)" />
</div>
)}
<div className="min-w-0 flex-1">
{/* Kino-Banner v2 (5.4.0): Logo statt Text, wo TMDb eines hat —
der größte optische Sprung. Titel und Jahr bleiben lesbar. */}
{erkannt ? (
logo.length > 0 ? (
<div className="flex flex-wrap items-end gap-x-3 gap-y-1">
<img src={logo} alt={info.titel} className="max-h-16 max-w-[380px] object-contain object-left drop-shadow-[0_2px_12px_rgba(0,0,0,0.75)]" />
<span className="pb-0.5 text-sm text-dunst">
{info.titel}
{info.jahr !== null && ` (${info.jahr})`}
</span>
</div>
) : (
<h3 className="truncate font-display text-[26px] font-bold leading-tight text-schnee drop-shadow">
{info.titel}
{info.jahr !== null && <span className="ml-2 font-medium text-dunst">({info.jahr})</span>}
</h3>
)
) : (
<h3 className="font-display text-[26px] font-bold leading-tight text-schnee">{TYP_NAMEN[geraet.typ]}</h3>
)}
{erkannt && info.tagline.length > 0 && <p className="mt-1 font-display text-[15px] italic text-gold-hell/90">{info.tagline}</p>}
{erkannt && metaZeile(info).length > 0 && <p className="mt-1 font-mono text-[11px] tracking-wide text-dunst">{metaZeile(info)}</p>}
{erkannt && info.beschreibung.length > 0 && <p className="mt-1.5 line-clamp-2 max-w-2xl text-xs leading-5 text-dunst">{info.beschreibung}</p>}
{!erkannt && (
<p className="mt-1.5 font-mono text-xs text-nebel">
{geraet.typ === 'cd' ? 'Titel kommen beim Rippen von MusicBrainz.' : 'Noch keine Zuordnung — „Ändern …" hilft nach.'}
</p>
)}
{/* Zwei Kacheln nebeneinander im schmalen Fenster, vier im
breiten — vorher immer vier, also im schmalen gequetscht. */}
<div className="mt-3 grid max-w-3xl grid-cols-2 gap-2.5 sm:grid-cols-4">
<InfoKachel label="MEDIUM" wert={TYP_NAMEN[geraet.typ]} />
<InfoKachel label="GRÖSSE" wert={geraet.groesseBytes > 0 ? gb(geraet.groesseBytes) : '—'} />
<InfoKachel
label="ERKENNUNG"
wert={erkannt ? `${Math.round(info.confidence * 100)} %${info.quelle.length > 0 ? ` · ${info.quelle}` : ''}` : '—'}
farbe={erkannt ? (info.confidence >= 0.8 ? 'text-gruen' : 'text-warn') : undefined}
/>
<InfoKachel label="PRESET" wert={presetAnzeige(geraet.typ, verbindung)} />
</div>
{/* Darsteller-Streifen (5.4.0) */}
{erkannt && info.besetzung.length > 0 && (
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2">
{info.besetzung.map((d) => (
<div key={`${d.name}-${d.rolle}`} className="flex items-center gap-2" title={d.rolle}>
{d.bildPfad.length > 0 ? (
<img src={profilUrl(d.bildPfad)} alt="" className="h-9 w-9 rounded-full object-cover ring-1 ring-gold/30" />
) : (
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-samt font-mono text-[11px] text-nebel ring-1 ring-white/10">
{d.name
.split(' ')
.map((w) => w[0] ?? '')
.join('')
.slice(0, 2)
.toUpperCase()}
</span>
)}
<span className="leading-tight">
<span className="block text-[11px] text-schnee">{d.name}</span>
{d.rolle.length > 0 && <span className="block text-[10px] text-nebel">{d.rolle}</span>}
</span>
</div>
))}
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
onClick={aufAendern}
disabled={ripLaeuft}
className="inline-flex items-center gap-2 rounded-lg border border-white/15 bg-samt/60 px-3 py-1 text-xs text-dunst hover:text-schnee disabled:opacity-35"
title="Falsch erkannt? Selbst suchen und wählen — die Ablage folgt deiner Wahl."
>
<IconStift />
Ändern
</button>
<button
onClick={() => {
setBerichtOffen(true)
laufwerkPruefen(geraet.id)
}}
className="inline-flex items-center gap-2 rounded-lg border border-white/15 bg-samt/60 px-3 py-1 text-xs text-dunst hover:text-schnee"
title="Liest die cdrom-Ereignisse aus dem Windows-Protokoll: Disc oder Hardware?"
>
Laufwerk prüfen
</button>
{rettungMoeglich && (
<button
onClick={() => rettungStarten(geraet.id)}
className="inline-flex items-center gap-2 rounded-lg border border-warn/50 bg-warn/12 px-3 py-1 text-xs font-semibold text-warn hover:bg-warn/20"
title="Rippy hat an dieser Disc Lesefehler gesehen: erst ein Abbild Sektor für Sektor (Unlesbares mit Nullen), dann derselbe Rip aus dem Abbild — es kann nicht mehr hängen. Braucht doppelten Platz."
>
Rettungs-Abbild
</button>
)}
{erkannt && <Badge text={`${sicherheitsWort(info.confidence)}`} ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'} />}
{info !== undefined && discHerkunft(info).length > 0 && <Badge text={discHerkunft(info)} ton="blau" />}
{info !== undefined && info.schonGerippt !== null && <Badge text={`schon gerippt am ${info.schonGerippt.slice(0, 10)}`} ton="gelb" />}
</div>
{/* Was Rippy NACH dem Info-Lauf dazugelernt hat (Erkennung v2). */}
{erkannt && info.hinweise.length > 0 && (
<div className="mt-2 space-y-0.5">
{info.hinweise.map((h) => (
<p
key={h}
className={`font-mono text-[11px] ${h.includes('NICHT') ? 'text-warn' : h.includes('bestätigt') || h.includes('passt') ? 'text-gruen' : 'text-nebel'}`}
>
{h.includes('NICHT') ? '⚠ ' : h.includes('bestätigt') ? '✓ ' : '· '}
{h}
</p>
))}
</div>
)}
{geraet.grund.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{geraet.grund}</p>}
</div>
</div>
)}
{/* Automatik (5.5.0): Countdown mit Stopp und Jetzt — oder der
Grund, warum Rippy wartet. */}
{discDa && !ripLaeuft && automatik !== undefined && automatik.text.length > 0 && (
<div className={`mt-4 flex flex-wrap items-center gap-3 rounded-xl border p-3 ${automatik.aktiv ? 'border-gold/45 bg-gold/10' : 'border-white/10 bg-samt/60'}`}>
{automatik.aktiv && (
<span className="font-mono text-[28px] font-bold leading-none text-gold-hell">
{automatik.sekunden}
<span className="text-sm text-gold/70"> s</span>
</span>
)}
<p className={`min-w-0 flex-1 font-mono text-[11px] leading-5 ${automatik.aktiv ? 'text-gold-hell' : 'text-nebel'}`}>{automatik.text}</p>
{automatik.aktiv && (
<div className="flex gap-2">
<button onClick={() => automatikJetzt(geraet.id)} className="rounded-lg border border-gold/40 bg-gold/15 px-3 py-1 text-[11px] font-semibold text-gold-hell hover:bg-gold/25">
Jetzt
</button>
<button onClick={() => automatikStopp(geraet.id)} className="rounded-lg border border-white/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
Stopp
</button>
</div>
)}
</div>
)}
{/* Laufwerks-Gesundheit (5.4.0, Punkt 8): Disc oder Hardware? Kommt
von selbst nach einem gescheiterten Rip — oder auf Knopfdruck. */}
{bericht !== undefined && (berichtOffen || bericht.urteil !== 'ruhig') && (
<div className={`mt-4 rounded-xl border p-3 ${berichtTon}`}>
<div className="flex items-start justify-between gap-3">
<p className="font-mono text-[11px] leading-5">
<span className="font-semibold">
{bericht.urteil === 'ruhig' ? 'LAUFWERK RUHIG' : bericht.urteil === 'disc' ? 'VERDACHT: DIE DISC' : bericht.urteil === 'hardware' ? 'VERDACHT: DIE HARDWARE' : 'PROTOKOLL'}
</span>{' '}
{bericht.text}
</p>
<button onClick={() => setBerichtOffen(false)} className="flex-none font-mono text-[10.5px] text-nebel hover:text-dunst">
ausblenden
</button>
</div>
{bericht.letzte.length > 0 && (
<div className="mt-1.5 space-y-0.5">
{bericht.letzte.map((z, i) => (
<p key={i} className="font-mono text-[10.5px] text-nebel">
{z}
</p>
))}
</div>
)}
</div>
)}
{rip !== undefined && (
<div className="mt-4 rounded-xl border border-white/10 bg-samt/80 p-4 backdrop-blur">
<div className="flex items-end justify-between gap-5">
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-baseline justify-between gap-3">
<span
className={`font-mono text-[10.5px] tracking-[0.12em] ${
rip.phase === 'fehler' ? 'text-rot' : rip.phase === 'fertig' ? (rip.lesefehler ? 'text-warn' : 'text-gruen') : 'text-gold-hell'
}`}
>
{PHASEN_MONO[rip.phase]}
{rip.lesefehler && rip.phase === 'fertig' ? ' — MIT LESEFEHLERN' : ''}
</span>
{ripLaeuft && <span className="font-mono text-[10.5px] text-nebel">{restzeitText(rip.prozent, ripStartMs)}</span>}
</div>
{ripLaeuft && <SegmentBalken prozent={rip.prozent} />}
{ripLaeuft && (rip.titelGesamt ?? 0) > 1 && (
<p className="mt-1.5 font-mono text-[10.5px] text-nebel">
Titel {rip.titelNr} von {rip.titelGesamt}
</p>
)}
{rip.rettung !== undefined && (
<p className="mt-1.5 font-mono text-[10.5px] text-warn">
Rettungs-Abbild: {rip.rettung.unlesbareSektoren} unlesbare Sektoren an {rip.rettung.stellen} Stellen mit Nullen gefüllt
</p>
)}
{/* Der Defekt-Waechter (§ 6.2): Dieselbe Stelle wieder und
wieder ist kein Fortschritt, sondern ein physischer
Defekt. Vorher lief MakeMKV dort stundenlang weiter,
waehrend identische Zeilen durchs Protokoll rauschten. */}
{rip.haengt != null && (
<div className="mt-3 rounded-xl border border-warn/45 bg-warn/10 p-3">
<p className="font-mono text-[11px] font-semibold text-warn">Die Disc hat an einer Stelle einen Defekt.</p>
<p className="mt-1 font-mono text-[11px] leading-5 text-dunst">
MakeMKV versucht es seit {rip.haengt.wiederholungen} Meldungen immer wieder an derselben Stelle (Byte {rip.haengt.offset}). Das wird nicht
besser der Rest der Disc ist davon nicht betroffen. Nach dem Abbruch bietet Rippy das Rettungs-Abbild an.
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
onClick={() => ripUeberspringen(geraet.id)}
className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25"
title="Diesen Titel aufgeben und mit dem naechsten weitermachen"
>
Titel ueberspringen
</button>
<span className="font-mono text-[10.5px] text-nebel">oder weiter versuchen abbrechen geht oben.</span>
</div>
</div>
)}
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={rip.text}>
&gt; {rip.text}
</p>
{rip.dateien.map((datei) => (
<p key={datei} className="break-all font-mono text-[11px] text-nebel/80">
{datei}
</p>
))}
{/* Nach dem Rip (5.4.0): Wo steht die Kompression? Das
Laufwerk ist schon frei — die Arbeit läuft im Hintergrund. */}
{rip.phase === 'fertig' && vorgang !== undefined && (
<div className="mt-3 rounded-lg border border-white/8 bg-buehne/60 p-2.5">
{kompression !== null ? (
<>
<p className="font-mono text-[10.5px] tracking-[0.12em] text-gold-hell">
KOMPRESSION LÄUFT{kompression.prozent >= 0 ? `${kompression.prozent} %` : ''}
</p>
<div className="mt-1.5">
<SegmentBalken prozent={kompression.prozent} />
</div>
<p className="mt-1.5 truncate font-mono text-[11px] text-nebel" title={kompression.text}>
&gt; {kompression.text}
</p>
</>
) : wartePlatz > 0 ? (
<p className="font-mono text-[11px] text-dunst">Kompression wartet Platz {wartePlatz} in der Warteschlange.</p>
) : vorgang.phase === 'fertig' ? (
<p className="break-all font-mono text-[11px] text-gruen">
{vorgang.komprimiert ? 'Komprimiert und abgelegt' : 'Verlustfrei abgelegt'}: {vorgang.zielOrdner}
{vorgang.fertigBytes > 0 && ` (${gb(vorgang.fertigBytes)})`}
</p>
) : vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen' ? (
<p className="break-all font-mono text-[11px] text-warn"> {vorgang.fehler.length > 0 ? vorgang.fehler : `Kompression ${vorgang.phase}`}</p>
) : (
<p className="font-mono text-[11px] text-dunst">Kompression: {vorgang.phase}</p>
)}
</div>
)}
</div>
{ripLaeuft && rip.prozent >= 0 && (
<div className="flex-none font-mono text-[42px] font-bold leading-none text-gold-hell">
{rip.prozent}
<span className="text-lg text-gold/70">%</span>
</div>
)}
</div>
{protokoll.length > 1 && (
<button onClick={() => setProtokollOffen((o) => !o)} className="mt-2 font-mono text-[10.5px] text-nebel hover:text-dunst">
{protokollOffen ? '▾ PROTOKOLL AUSBLENDEN' : `▸ PROTOKOLL (${protokoll.length} ZEILEN)`}
</button>
)}
{protokollOffen && (
<div className="mt-1 max-h-40 space-y-0.5 overflow-y-auto rounded-lg bg-buehne p-2">
{protokoll.map((zeile, i) => (
<p key={i} className="break-all font-mono text-[10.5px] leading-4 text-nebel">
&gt; {zeile}
</p>
))}
</div>
)}
</div>
)}
</div>
</div>
)
}