Files
dejanandClaude Opus 5 6a0b36ddcc Geviertstriche aus Text, Kommentaren und Doku entfernen
467 Stellen. Nicht durch einen anderen Strich ersetzt, sondern durch das
Satzzeichen, das an der jeweiligen Stelle richtig ist: Doppelpunkt, wo eine
Erlaeuterung folgt; Komma, wo der Satz weiterlaeuft; Punkt, wo zwei
Hauptsaetze aneinanderstiessen. Ein Halbgeviertstrich waere nur derselbe
Griff mit kuerzerem Strich gewesen.

Regelbasiert ersetzt und danach nachgeprueft, weil eine Regel allein nicht
reicht. Gefunden und einzeln korrigiert:

- Eingeschobene Saetze mit Strichpaar. Nur der oeffnende Strich passte ins
  Muster, der schliessende blieb als "—," stehen. Drei Stellen, jetzt mit
  Klammern oder als eigener Satz.

- Doppelte Doppelpunkte, wo beide Striche eines Paars zu Doppelpunkten wurden
  ("Globale Regeln: Schrift, Groessen, Abstaende: kommen aus HiveGrid").
  Sieben Stellen in Doku und Oberflaeche, jetzt in Klammern.

- Pfade, denen ein Komma statt eines Doppelpunkts folgte.

- Ausgerichtete Kommentartabellen, in denen der Strich eine Spalte war.

Nicht angefasst: drei Geviertstriche, die keine Prosa sind, sondern
Tabellenplatzhalter fuer "kein Wert" (Trichter, Auftraege, Kunden). Dort ist
der Strich die Angabe selbst; ihn zu entfernen liesse leere Zellen zurueck.

tsc, lint, build, verify:carbon und verify:layout laufen sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
2026-08-31 20:51:19 +02:00

315 lines
14 KiB
TypeScript

"use client";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { FeatherBell, FeatherHelpCircle, FeatherMenu, FeatherSearch } from "@subframe/core";
import { SidebarWithSections } from "@/ui/components/SidebarWithSections";
import { NAV_GRUPPEN, NAV_SEKUNDAER, ANALYSE_SEITEN, type NavEintrag } from "./navigation";
import { FeatherIcon } from "./FeatherIcon";
import { useIstSchmal } from "../../hooks/useIstSchmal";
import { DATENSATZ_INFO } from "../../data/seed";
import { formatDatum, formatZahl } from "../../utils/formatters";
import { HEUTE } from "../../utils/dateUtils";
/**
* Rahmen der Anwendung: Seitenleiste nach dem Vorbild von HiveGrid Cloud.
*
* Bewusst dieselbe Komponente wie dort (`~/ui/components/SidebarWithSections`)
* mit denselben Gruppen und derselben Reihenfolge. Wer den Prototyp neben der
* Software öffnet, soll dieselbe Navigation sehen; die Übergabe wird dadurch
* eine Frage des Einsetzens und nicht des Übersetzens.
*
* Jeder Eintrag führt auf eine echte Seite. „Dashboard" landet auf einer
* Platzhalterseite, weil es ein eigenes Feature ist und hier nicht gestaltet
* wird, aber ein Menüpunkt, der nur einen Hinweis auslöst, lässt offen, ob
* etwas fehlt oder etwas kaputt ist.
*
* ────────────────────────────────────────────────────────────────────────────
* BEIM EINBAU: Diese Datei wird NICHT portiert.
*
* Sie existiert, damit der Prototyp alleine lauffähig ist. In HiveGrid Cloud
* übernehmen `SubframeAppShell` und `AppSidebar`. Verbindlich ist aus dieser
* Datei allein die **Struktur**: zwei Einträge in der Seitenleiste, Dashboard
* und Analytics, mehr nicht, weil alles Übrige dort schon steht.
*
* Globale Regeln (Schrift, Größen, Abstände, Farben, Control-Höhen) kommen
* ausschließlich aus HiveGrid und werden von hier aus nicht überschrieben.
* Weicht ein Wert hier von dort ab, ist der Wert hier falsch.
* Rangfolge: docs/ZIELSYSTEM.md Abschnitt 0.
* ────────────────────────────────────────────────────────────────────────────
*/
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [eingeklappt, setEingeklappt] = React.useState(false);
// Unterhalb `md` fährt die Leiste über den Inhalt statt ihn zu verdrängen.
// Bei 375 px blieben sonst 135 px für den Inhalt, und die Seite lief um über
// 300 px seitlich über. Dasselbe Vorgehen wie in der Software.
const istSchmal = useIstSchmal();
const ausloeser = React.useRef<HTMLButtonElement>(null);
// Beim Seitenwechsel schließen, sonst bleibt die Leiste über dem Ziel
// liegen. Der Pfad wandert dafür in den State: Zustand beim Rendern
// anzupassen ist hier richtig, ein Effekt würde einen zweiten Durchlauf
// auslösen und die Leiste kurz über der neuen Seite zeigen.
const [schublade, setSchublade] = React.useState({ offen: false, pfad: pathname });
if (schublade.pfad !== pathname) setSchublade({ offen: false, pfad: pathname });
const mobilOffen = schublade.offen;
const leisteRef = React.useRef<HTMLDivElement>(null);
/** Schließen und den Fokus dorthin zurückgeben, wo er hergekommen ist. */
const schliesseSchublade = React.useCallback(() => {
setSchublade((aktuell) => ({ ...aktuell, offen: false }));
window.requestAnimationFrame(() => ausloeser.current?.focus());
}, []);
/**
* Fokusfalle der offenen Schublade.
*
* Nicht optional: Der Rahmen trägt `role="dialog"` und `aria-modal`, und
* damit ist einem Screenreader zugesagt, dass nichts dahinter erreichbar ist.
* Ohne Falle wandert der Tabulator hinter die Abdeckung in eine Seite, die
* optisch verdeckt ist, die Zusage wäre eine Unwahrheit.
*
* Übernommen aus `SubframeAppShell` im Zielsystem: Tab und Umschalt+Tab
* laufen zyklisch, Escape schließt und gibt den Fokus an den Auslöser
* zurück.
*
* Beim Öffnen bekommt der **Rahmen** den Fokus, nicht der erste Link. Das
* ist das übliche Vorgehen für einen Dialog und hat hier einen sichtbaren
* Grund: Ein per Skript fokussierter Link zeigt in Chrome seinen Fokusring,
* auch wenn die Schublade mit der Maus geöffnet wurde, dann liegt ein
* blauer Rahmen um „Dashboard", ohne dass jemand dorthin getippt hat. Der
* Rahmen selbst trägt `tabIndex={-1}` und keinen Ring; die Sprachausgabe
* liest trotzdem den Dialog an, und der erste Tabulatorschritt landet auf
* dem ersten Link.
*/
React.useEffect(() => {
if (!mobilOffen) return;
const leiste = leisteRef.current;
if (!leiste) return;
const bedienbar = () =>
Array.from(
leiste.querySelectorAll<HTMLElement>(
'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])'
)
).filter((el) => el.getClientRects().length > 0);
window.requestAnimationFrame(() => leiste.focus());
const taste = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
schliesseSchublade();
return;
}
if (event.key !== "Tab") return;
const elemente = bedienbar();
if (!elemente.length) return;
const erstes = elemente[0];
const letztes = elemente[elemente.length - 1];
const aktiv = document.activeElement;
// `aktiv === leiste` gehört dazu: Direkt nach dem Öffnen liegt der Fokus
// auf dem Rahmen selbst. Er steht im Dokument vor seinen Kindern, also
// muss Umschalt+Tab von dort ans Ende springen, sonst verlässt der
// Tabulator den Dialog rückwärts, und die Zusage von `aria-modal` wäre
// gebrochen.
if (event.shiftKey && (aktiv === erstes || aktiv === leiste || !leiste.contains(aktiv))) {
event.preventDefault();
letztes.focus();
} else if (!event.shiftKey && aktiv === letztes) {
event.preventDefault();
erstes.focus();
}
};
document.addEventListener("keydown", taste);
return () => document.removeEventListener("keydown", taste);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mobilOffen]);
/**
* Zuletzt benutzte Eingabeart am Wurzelelement vermerken.
*
* `:focus-visible` allein reicht nicht: Wenn ein Skript den Fokus setzt,
* beim Schließen der Schublade zurück auf den Hamburger: behandelt Chrome
* das wie Tastaturbedienung und zeigt den Ring. Nach einem Mausklick steht
* er dann sichtbar da, bis man woanders hinklickt.
*
* Die Regel dazu steht in `app/globals.css`. Sie unterdrückt den Ring nur,
* solange die letzte Eingabe ein Zeiger war, und nur an Knöpfen und Links,
* Eingabefelder behalten ihn immer, dort gehört er zum Zustand „hier wird
* gerade geschrieben". Der erste Tastendruck schaltet ihn sofort zurück.
*/
React.useEffect(() => {
const wurzel = document.documentElement;
const zeiger = () => {
wurzel.dataset.eingabe = "zeiger";
};
const tastatur = (event: KeyboardEvent) => {
if (event.metaKey || event.altKey || event.ctrlKey) return;
wurzel.dataset.eingabe = "tastatur";
};
window.addEventListener("pointerdown", zeiger, true);
window.addEventListener("keydown", tastatur, true);
return () => {
window.removeEventListener("pointerdown", zeiger, true);
window.removeEventListener("keydown", tastatur, true);
};
}, []);
const istAktiv = (eintrag: NavEintrag) =>
eintrag.ziel.href === "/analytics"
? pathname.startsWith("/analytics")
: pathname === eintrag.ziel.href;
const renderEintrag = (eintrag: NavEintrag) => (
<Link
key={eintrag.id}
href={eintrag.ziel.href}
title={eingeklappt ? eintrag.label : undefined}
className="w-full no-underline"
>
<SidebarWithSections.NavItem
icon={<FeatherIcon name={eintrag.icon} />}
selected={istAktiv(eintrag)}
variant={eingeklappt ? "icon-only" : "icon-and-text"}
>
{eintrag.label}
</SidebarWithSections.NavItem>
</Link>
);
const aktiveAnalyse = ANALYSE_SEITEN.find((s) =>
s.href === "/analytics" ? pathname === "/analytics" : pathname.startsWith(s.href)
);
const bereich = pathname.startsWith("/komponenten")
? "Komponenten"
: pathname.startsWith("/dashboard")
? "Dashboard"
: "Analytics";
return (
<div className="flex min-h-screen w-full bg-neutral-50">
{/* Abdeckung hinter der ausgefahrenen Leiste: Ein Klick daneben schließt. */}
{mobilOffen ? (
<div
className="fixed inset-0 z-40 bg-neutral-900/40 md:hidden"
onClick={schliesseSchublade}
aria-hidden="true"
/>
) : null}
{/* z-50 ist nötig, nicht dekorativ: `position: sticky` erzeugt einen
eigenen Stapelkontext. Ohne z-Wert am Rahmen bleibt das z-index des
Einklapp-Knopfes darin gefangen, und die Inhaltsspalte, ein späteres
Geschwisterelement: malt über den Teil, der aus der Leiste
herausragt. Die Software setzt an derselben Stelle ebenfalls z-50. */}
<div
ref={leisteRef}
data-shell-sidebar
tabIndex={istSchmal && mobilOffen ? -1 : undefined}
className={`dv-no-print z-50 outline-none h-screen flex-none max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:transition-transform md:sticky md:top-0 ${
mobilOffen ? "max-md:translate-x-0" : "max-md:-translate-x-full"
}`}
role={istSchmal ? "dialog" : undefined}
aria-modal={istSchmal && mobilOffen ? true : undefined}
aria-label={istSchmal ? "Navigation" : undefined}
aria-hidden={istSchmal && !mobilOffen ? true : undefined}
inert={istSchmal && !mobilOffen}
>
<SidebarWithSections
variant={eingeklappt ? "closed" : "open"}
onToggleCollapse={() => setEingeklappt((wert) => !wert)}
toggleLabel={eingeklappt ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
>
<div className="flex h-full min-h-0 w-full flex-col">
<div className="flex min-h-0 w-full grow flex-col gap-1 overflow-x-hidden overflow-y-auto">
{NAV_GRUPPEN.map((gruppe) => (
<SidebarWithSections.NavSection
key={gruppe.id}
label={gruppe.titel}
showLabel={!eingeklappt && !!gruppe.titel}
>
{gruppe.eintraege.map(renderEintrag)}
</SidebarWithSections.NavSection>
))}
</div>
<div className="sticky bottom-0 z-10 mt-auto flex w-full flex-none flex-col gap-1 bg-surface-neutral-secondary pt-2">
<SidebarWithSections.NavDivider />
{NAV_SEKUNDAER.map(renderEintrag)}
{/* Einzeilig statt als Kasten: Die Seitenleiste soll die der
Software spiegeln, und dort steht kein Demo-Hinweis. Die
Information bleibt, nimmt aber keinen Navigationsplatz weg. */}
{!eingeklappt ? (
<p className="px-3 pb-2 pt-1 text-[11px] leading-tight text-text-neutral-tertiary">
Demo-Daten · {formatZahl(DATENSATZ_INFO.auftraege)} Aufträge ·{" "}
{formatDatum(HEUTE.toISOString())}
</p>
) : null}
</div>
</div>
</SidebarWithSections>
</div>
<div className="flex min-w-0 grow flex-col">
<header className="dv-no-print sticky top-0 z-30 flex h-14 flex-none items-center gap-4 border-b border-solid border-border-default bg-white/95 px-6 backdrop-blur">
<button
ref={ausloeser}
type="button"
onClick={() =>
mobilOffen ? schliesseSchublade() : setSchublade({ offen: true, pfad: pathname })
}
aria-label="Navigation"
aria-expanded={mobilOffen}
className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-md text-text-neutral-secondary hover:bg-neutral-100 md:hidden"
>
<FeatherMenu className="text-[18px]" />
</button>
<div className="flex min-w-0 items-center gap-2 text-[13px]">
<span className="text-text-neutral-tertiary">{bereich}</span>
{aktiveAnalyse ? (
<>
<span className="text-text-neutral-subtle">/</span>
<span className="truncate font-semibold text-text-neutral-primary">
{aktiveAnalyse.label}
</span>
</>
) : null}
</div>
<div className="ml-auto flex items-center gap-2">
<div className="hidden h-9 items-center gap-2 rounded-md border border-solid border-border-default px-3 text-[13px] text-text-neutral-subtle lg:flex">
<FeatherSearch className="text-[15px]" />
<span>Suchen</span>
<kbd className="ml-4 rounded border border-solid border-border-default bg-neutral-50 px-1.5 py-0.5 text-[11px] font-medium text-text-neutral-tertiary">
⌘K
</kbd>
</div>
<button
className="flex h-9 w-9 items-center justify-center rounded-md text-text-neutral-tertiary hover:bg-neutral-100"
aria-label="Benachrichtigungen"
>
<FeatherBell className="text-[17px]" />
</button>
<button
className="flex h-9 w-9 items-center justify-center rounded-md text-text-neutral-tertiary hover:bg-neutral-100"
aria-label="Hilfe"
>
<FeatherHelpCircle className="text-[17px]" />
</button>
<span className="ml-1 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-800 text-[12px] font-semibold text-white">
MK
</span>
</div>
</header>
<main className="min-w-0 grow">{children}</main>
</div>
</div>
);
}