Hustler-Pixelwelt T7: Detailansicht eines Hustlers (Szene nach Status, Daten, Spur, Blättern, Fokus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-22 08:19:02 +00:00
1 parent 363419ed12
commit 3027f03dbe
3 files changed
+928

No files matched your search

@@ -0,0 +1,193 @@
// Detailansicht eines Hustlers im Hochhaus (Spec E10, E14): Szene mit großer Figur nach Status (Büro, Stecker oder Brücke), rechts Name,
// Status, Modell, Etage · Raum, Aufgabe, Gerade, Heute, Kosten, die Spur und unten ◂ Nummer ▸. Aufbau und Klassen wie Entwurf/markup.html der
// Zeichenfläche (Block „offen“), alle Klassen mit hk- davor; die Inline-Stile der Vorlage stehen als Klassen in styles/hustler/hustler-detail.css.
// Die Felder liefert hustlerSicht.detailModell, die Positionen kommen aus DETAIL in hustlerWelt.js, die Spur aus useWorkerTrace (alle 15 s).
// Kein CSS-Import (die Stile hängt WorkersPage ein), keine Bilder, kein innerHTML.
//
// Dialog: role="dialog" aria-modal, Fokus beim Öffnen auf „Schließen“, Tab bleibt im Dialog (erster Knopf = Schließen, letzter = ▸),
// Esc schließt, ←/→ blättern in Roster-Reihenfolge, ein Klick auf den Hintergrund schließt erst 400 ms nach dem Öffnen (Geister-Klick nach
// dem Antippen der Figur). Beim Schließen geht der Fokus zurück auf das Element, das beim Öffnen den Fokus hatte (wenn es noch im Dokument
// steht), sonst auf die Figur des zuletzt gezeigten Hustlers ([data-hk-hustler="<id>"], die setzt die Welt).
import { useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { useWorkerTrace } from '../../../hooks/useWorkersFeed'
import { blaettern, detailModell } from '../../../lib/hustlerSicht'
import { DETAIL } from '../../../lib/hustlerWelt'
import { safeLink } from '../../../lib/workersView'
// So lange nach dem Öffnen schließt der Hintergrund nicht (der Antipp-Klick, der den Dialog öffnet, kommt sonst als Geister-Klick darauf an)
const HINTERGRUND_SPERRE_MS = 400
// Ids der Hustler sind einfache Kennungen; nur solche gehen in den Attribut-Selektor
const EINFACHE_ID = /^[A-Za-z0-9_-]+$/
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
// Einziger Ort des Dialogs, an dem ein Ziel für einen Link entsteht (wie TraceText in WorkerCard): nur was safeLink durchlässt, wird ein Link
// (extern in neuem Tab mit noopener, intern über den Router), alles andere bleibt Text.
function SpurText({ link, children }) {
const safe = isRecord(link) ? safeLink(link.href) : null
if (!safe) return <>{children}</>
if (safe.external) return <a href={safe.href} target="_blank" rel="noopener noreferrer">{children}</a>
return <Link to={safe.href}>{children}</Link>
}
// Stelle der Figur in der Szene: Brücke (Overseer), Stecker-Szene (nicht angeschlossen) oder Büro
function figurStelle(szene) {
if (szene.includes('hk-sz-bruecke')) return DETAIL.figur.bruecke
if (szene.includes('hk-sz-nicht')) return DETAIL.figur.nicht
return DETAIL.figur.buero
}
// Props: api (Dienst-API für die Spur), workerId (null = nichts anzeigen), workers (Hustler-Tabelle der Seite, Zustandseinträge nach workerId),
// onClose(), onWechsel(nextId) (Blättern; die Seite setzt die neue Id)
export default function HustlerDialog({ api, workerId, workers, onClose, onWechsel }) {
const { trace, loading, error } = useWorkerTrace(api, workerId)
const ersterRef = useRef(null) // Knopf „Schließen“ (Fokus beim Öffnen, Anfang der Fokusfalle)
const letzterRef = useRef(null) // Knopf „Nächster Hustler“ (Ende der Fokusfalle)
const sectionRef = useRef(null)
const hintenRef = useRef(null)
const geoeffnetRef = useRef(0)
const openerRef = useRef(null)
const idRef = useRef(workerId)
const offen = Boolean(workerId)
if (workerId) idRef.current = workerId
// Öffnen: merken, wer den Fokus hatte, und den Fokus auf „Schließen“ setzen; Schließen (oder Abbau): Fokus zurückgeben
useEffect(() => {
if (!offen) return undefined
const aktiv = typeof document !== 'undefined' ? document.activeElement : null
openerRef.current = aktiv && aktiv !== document.body ? aktiv : null
geoeffnetRef.current = Date.now()
const erster = ersterRef.current
if (erster && typeof erster.focus === 'function') erster.focus({ preventScroll: true })
return () => {
const opener = openerRef.current
openerRef.current = null
let ziel = opener && opener.isConnected ? opener : null
if (!ziel && typeof document !== 'undefined' && typeof document.querySelector === 'function' && EINFACHE_ID.test(String(idRef.current ?? ''))) {
ziel = document.querySelector(`[data-hk-hustler="${idRef.current}"]`)
}
if (ziel && typeof ziel.focus === 'function') ziel.focus({ preventScroll: true })
}
}, [offen])
if (!workerId) return null
const eintrag = isRecord(workers) && Object.hasOwn(workers, workerId) ? workers[workerId] : null
const m = detailModell(workerId, eintrag, trace, Date.now())
const stelle = figurStelle(m.szene)
const o = m.overlays
const wechsel = (schritt) => onWechsel(blaettern(workerId, schritt))
// Hintergrund: schließt erst nach der Sperrzeit
const hintergrund = () => {
if (Date.now() - geoeffnetRef.current > HINTERGRUND_SPERRE_MS) onClose()
}
// Tasten (am äußeren Rahmen, damit sie auch gelten, wenn der Fokus auf dem Hintergrund oder dem Dialog selbst liegt):
// Esc schließt, ←/→ blättern, Tab wandert im Kreis zwischen „Schließen“ und „Nächster Hustler“
const taste = (e) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
onClose()
return
}
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
if (e.altKey || e.ctrlKey || e.metaKey) return // Alt+← ist „Zurück“ im Browser
e.preventDefault()
e.stopPropagation()
wechsel(e.key === 'ArrowRight' ? 1 : -1)
return
}
if (e.key !== 'Tab') return
const aktiv = typeof document !== 'undefined' ? document.activeElement : null
const erster = ersterRef.current
const letzter = letzterRef.current
if (!e.shiftKey && aktiv === letzter && erster) {
e.preventDefault()
erster.focus()
} else if (e.shiftKey && letzter && (aktiv === erster || aktiv === sectionRef.current || aktiv === hintenRef.current || aktiv === e.currentTarget)) {
e.preventDefault()
letzter.focus()
}
}
return (
<div className="hk-dialog-ueber" onKeyDown={taste}>
<button type="button" tabIndex={-1} className="hk-dialog-hinten" aria-label="Detailansicht schließen" ref={hintenRef} onClick={hintergrund} />
<section role="dialog" aria-modal="true" aria-label={`Hustler ${m.name}`} tabIndex={-1} className="hk-panel hk-dialog" ref={sectionRef}>
<div className={m.szene} aria-hidden="true">
<span className={o.gedimmt ? `${m.figur} hk-gedimmt` : m.figur} aria-hidden="true" style={{ left: stelle.left, top: stelle.top }}>
{o.alarm && <span className="hk-alarm hk-alarm-gross" aria-hidden="true">!</span>}
</span>
{o.monitor && <span className="hk-spr hk-monitor-gross" aria-hidden="true" style={{ left: DETAIL.monitor.left, top: DETAIL.monitor.top }} />}
{o.dampf && <span className="hk-spr hk-dampf" aria-hidden="true" style={{ left: DETAIL.dampf.left, top: DETAIL.dampf.top }} />}
{o.zzz && <span className="hk-spr hk-zzz-gross" aria-hidden="true" style={{ left: DETAIL.zzz.left, top: DETAIL.zzz.top }} />}
{o.wirbel && <span className="hk-spr hk-wirbel" aria-hidden="true" style={{ left: DETAIL.wirbel.left, top: DETAIL.wirbel.top }} />}
{o.schild && (
<span className="hk-dialog-schild" style={{ left: DETAIL.schild.left, top: DETAIL.schild.top, width: DETAIL.schild.w, height: DETAIL.schild.h }}>
noch nicht angeschlossen
</span>
)}
</div>
<div className="hk-dialog-daten">
<div className="hk-dialog-kopf">
<div>
<h2 className="hk-dialog-name">{m.name}</h2>
<div className="hk-dialog-status" style={{ '--hk-status': m.statusFarbe }}>
<span className="hk-dialog-punkt" aria-hidden="true" />
{m.statusText}
</div>
</div>
<button type="button" className="hk-knopf" ref={ersterRef} aria-label="Schließen" onClick={() => onClose()}>
<span className="hk-ic hk-schliessen" aria-hidden="true" />
</button>
</div>
<dl className="hk-dialog-dl">
<dt>Modell</dt>
<dd>{m.modell}</dd>
<dt>Etage</dt>
<dd>{m.etageRaum}</dd>
<dt>Aufgabe</dt>
<dd>{m.aufgabe}</dd>
<dt>Gerade</dt>
<dd>{m.gerade}</dd>
<dt>Heute</dt>
<dd>{m.heute}</dd>
<dt>Kosten</dt>
<dd>{`${m.kosten} · ${m.tokens} Tokens`}</dd>
</dl>
<div>
<h3 className="hk-dialog-spurtitel">Spur</h3>
{error && <p className="hk-dialog-fehler" role="alert">{error}</p>}
{loading && <p className="hk-dialog-hinweis" role="status">Spur wird geladen …</p>}
{!loading && m.spur.length === 0 && !error && <p className="hk-dialog-hinweis">Noch keine Einträge.</p>}
{m.spur.length > 0 && (
<ol className="hk-dialog-spurliste">
{m.spur.map((row, i) => (
<li key={`${row.id}-${i}`} className="hk-dialog-spurzeile">
<span className="hk-dialog-zeit">{row.time}</span>
<span className="hk-dialog-spurtext">
<SpurText link={row.link}>{row.text}</SpurText>
</span>
</li>
))}
</ol>
)}
</div>
<div className="hk-dialog-fuss">
<button type="button" className="hk-knopf" aria-label="Vorheriger Hustler" onClick={() => wechsel(-1)}>
<span className="hk-ic hk-pfeil-links" aria-hidden="true" />
</button>
<span className="hk-dialog-nummer">{m.nummer}</span>
<button type="button" className="hk-knopf" ref={letzterRef} aria-label="Nächster Hustler" onClick={() => wechsel(1)}>
<span className="hk-ic hk-pfeil-rechts" aria-hidden="true" />
</button>
</div>
</div>
</section>
</div>
)
}
+97
View File
@@ -0,0 +1,97 @@
/* Detailansicht eines Hustlers (HustlerDialog.jsx, Spec E10/E13/E14). Handgeschrieben, nicht erzeugt: die Inline-Stile des Aufbaus „offen“ in
pixelwelt-v2/entwurf/markup.html stehen hier als Klassen. Szene, Figur, Zusätze (hk-sz, hk-gr-*, hk-monitor-gross, hk-dampf, hk-zzz-gross,
hk-wirbel), Panel (hk-panel), Knöpfe (hk-knopf) und Symbole (hk-ic, hk-schliessen, hk-pfeil-*) kommen aus dem erzeugten hochhaus.css;
hk-gedimmt und hk-alarm aus hustler-ui.css. Alle Klassen beginnen mit hk- und stehen unter der Wurzel .hk-hochhaus.
Die Stylesheets hängt WorkersPage ein (Komponenten importieren kein CSS). */
/* ---------- Überlagerung: dunkler Hintergrund-Knopf und das Panel in der Mitte ---------- */
.hk-hochhaus .hk-dialog-ueber {
position: absolute; inset: 0; z-index: 20;
display: flex; align-items: center; justify-content: center;
box-sizing: border-box; padding: 16px;
font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 15px; line-height: 20px; color: #e6ebff;
}
.hk-hochhaus .hk-dialog-hinten {
position: absolute; inset: 0; margin: 0; padding: 0; border: 0; border-radius: 0;
background: rgba(5, 8, 26, 0.74); cursor: default;
-webkit-appearance: none; appearance: none; -webkit-tap-highlight-color: transparent;
}
/* Panel: Szene links, Daten rechts; wird es zu schmal, rutschen die Daten unter die Szene und das Panel scrollt */
.hk-hochhaus .hk-dialog {
position: relative; display: flex; flex-wrap: wrap; gap: 20px;
max-width: 100%; max-height: 100%; min-height: min(560px, 100%);
overflow: auto; padding: 4px; outline: none;
}
.hk-hochhaus .hk-dialog a:focus-visible { outline: 2px solid #ffd25a; outline-offset: 2px; }
/* ---------- Szene: Hülle und Schild kommen aus hochhaus.css (hk-sz) bzw. hier (Schild) ---------- */
.hk-hochhaus .hk-dialog-schild {
position: absolute; display: flex; align-items: center; justify-content: center;
color: #3a2f3d; font-size: 10px; line-height: 13px; text-align: center;
}
/* ---------- Rechte Spalte: Kopf, Fakten, Spur, Fußzeile ---------- */
.hk-hochhaus .hk-dialog-daten {
flex: 1 1 300px; min-width: 280px; max-width: 380px;
display: flex; flex-direction: column; gap: 14px; padding: 2px 2px 2px 0;
}
.hk-hochhaus .hk-dialog-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.hk-hochhaus .hk-dialog-name {
margin: 0; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 20px; line-height: 28px; font-weight: 400; color: #ffffff;
overflow-wrap: anywhere;
}
/* Farbe des Status kommt als --hk-status vom Dienst-Status (statusFarbe) */
.hk-hochhaus .hk-dialog-status { margin-top: 6px; display: inline-flex; align-items: center; gap: 8px; color: var(--hk-status, #9aa6d6); }
.hk-hochhaus .hk-dialog-punkt { flex: none; width: 10px; height: 10px; background: var(--hk-status, #9aa6d6); box-shadow: 0 0 0 2px #05081a; }
.hk-hochhaus .hk-dialog-dl { margin: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 14px; }
.hk-hochhaus .hk-dialog-dl dt { color: #9aa6d6; }
.hk-hochhaus .hk-dialog-dl dd { margin: 0; overflow-wrap: anywhere; }
.hk-hochhaus .hk-dialog-spurtitel {
margin: 0 0 8px; font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 11px; line-height: 14px; font-weight: 400; color: #9aa6d6;
}
.hk-hochhaus .hk-dialog-spurliste { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.hk-hochhaus .hk-dialog-spurzeile { display: flex; gap: 10px; }
/* Breite für „TT.MM. HH:MM“ (Ereignisse von früheren Tagen), nicht nur „HH:MM“ */
.hk-hochhaus .hk-dialog-zeit { flex: none; width: 96px; color: #ffd25a; white-space: nowrap; }
.hk-hochhaus .hk-dialog-spurtext { min-width: 0; overflow-wrap: anywhere; }
.hk-hochhaus .hk-dialog-hinweis { margin: 0; color: #9aa6d6; }
.hk-hochhaus .hk-dialog-fehler { margin: 0 0 8px; color: #ff6b6b; overflow-wrap: anywhere; }
.hk-hochhaus .hk-dialog-fuss { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.hk-hochhaus .hk-dialog-nummer { font-family: 'Press Start 2P', 'Pixelify Sans', monospace; font-size: 11px; line-height: 14px; color: #9aa6d6; }
/* ---------- Handy (unter 720 px, Spec E13): Vollbild, Szene oben (auf die Breite verkleinert), Daten darunter scrollbar ---------- */
@media (max-width: 719px) {
.hk-hochhaus .hk-dialog-ueber { padding: 0; }
.hk-hochhaus .hk-dialog {
/* Container für die Szenen-Skalierung (cqw = Innenbreite des Panels) */
container-type: inline-size;
display: flex; flex-direction: column; flex-wrap: nowrap; gap: 12px;
width: 100%; max-width: 100%; height: 100%; max-height: 100%; min-height: 0;
overflow: hidden;
}
/* Szene: 640 x 400 wird per zoom auf die Breite des Panels gebracht (Layoutgröße schrumpft mit, kein waagrechtes Scrollen) */
.hk-hochhaus .hk-dialog .hk-sz { flex: none; zoom: 0.5; }
@supports (zoom: tan(atan2(1px, 1px))) {
.hk-hochhaus .hk-dialog .hk-sz { zoom: tan(atan2(100cqw, 640px)); }
}
/* Daten: füllen den Rest und scrollen für sich; die Fußzeile ◂ n / 19 ▸ bleibt unten stehen */
.hk-hochhaus .hk-dialog-daten {
flex: 1 1 auto; min-width: 0; max-width: none; min-height: 0; overflow-x: hidden; overflow-y: auto; padding: 0 2px 0 0;
-webkit-overflow-scrolling: touch;
}
.hk-hochhaus .hk-dialog-fuss { position: sticky; bottom: 0; margin-top: 4px; padding: 6px 0 2px; background: #0e1430; }
}
/* Handy quer (niedrig): Szene links (45 % der Breite), Daten rechts daneben und scrollbar */
@media (max-width: 719px) and (max-height: 520px) {
.hk-hochhaus .hk-dialog { flex-direction: row; align-items: flex-start; }
.hk-hochhaus .hk-dialog .hk-sz { zoom: 0.4; }
@supports (zoom: tan(atan2(1px, 1px))) {
.hk-hochhaus .hk-dialog .hk-sz { zoom: tan(atan2(45cqw, 640px)); }
}
.hk-hochhaus .hk-dialog-daten { align-self: stretch; }
}