feat(t5): Bereiche mit eigenem Symbol und Ton, BrandMark, Verläufe raus (T5a A3)

AREAS (37 Bereiche, je Symbol genau einmal; neu compliance, medicines,
voice, hive-systems, system) als einzige Quelle für Kopfzeilen, Menü
und Schnellanlage; PageHeader, DetailHeader und EntityCard nehmen
area statt icon/gradient (Codemod über 72 Dateien). Doppelte Symbole
aufgelöst. BrandMark (Wabe) ersetzt das Verlaufs-Sechseck;
Honig-Banner auf der Startseite durch Überschrift mit Datum ersetzt;
Verläufe aus 33 Verbrauchern entfernt (Token folgen in A4).

Refs #26

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 16:47:56 +02:00
1 parent cf53cf35ac
commit 258e90828f
97 files changed
+732 -395

No files matched your search

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { AreaIcon } from "./area-icon";
describe("AreaIcon", () => {
it("draws the icon inside a hexagon tinted with the area tone and is aria-hidden", () => {
const { container } = render(<AreaIcon area="harvests" />);
const root = container.firstElementChild as HTMLElement;
expect(root).toHaveAttribute("aria-hidden", "true");
expect(root.querySelector("svg polygon")).not.toBeNull();
expect(root.querySelectorAll("svg").length).toBe(2);
expect(root.getAttribute("data-tone")).toBe("ernte");
expect(root.style.getPropertyValue("--tone")).toBe("var(--area-ernte)");
});
it("is 40 px by default and 32 px small", () => {
const { container, rerender } = render(<AreaIcon area="colonies" />);
expect((container.firstElementChild as HTMLElement).className).toContain("size-10");
rerender(<AreaIcon area="colonies" size="sm" />);
expect((container.firstElementChild as HTMLElement).className).toContain("size-8");
});
it("uses a card fill and a 2 px edge in sun mode", () => {
const { container } = render(<AreaIcon area="tasks" />);
const polygon = container.querySelector("polygon") as SVGElement;
expect(polygon.getAttribute("class")).toContain("sun:fill-card");
expect(polygon.getAttribute("class")).toContain("sun:stroke-2");
});
});
@@ -0,0 +1,44 @@
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
import { AREAS, type AreaKey } from "@/lib/areas";
interface AreaIconProps {
area: AreaKey;
size?: "md" | "sm";
className?: string;
}
// Pointy-top hexagon in a 100 x 100 box.
const HEXAGON_POINTS = "50,3 91,26.5 91,73.5 50,97 9,73.5 9,26.5";
/**
* The icon of an area inside a honeycomb cell. The tone only groups; the
* cell is decoration (aria-hidden), the heading next to it names the area.
* Sun mode: card fill and a 2 px edge instead of the 14 % tint.
*/
export function AreaIcon({ area, size = "md", className }: AreaIconProps) {
const { icon: Icon, tone } = AREAS[area];
const style = { "--tone": `var(--area-${tone})` } as CSSProperties;
return (
<span
aria-hidden="true"
data-tone={tone}
style={style}
className={cn(
"relative inline-flex shrink-0 items-center justify-center text-(--tone)",
size === "md" ? "size-10" : "size-8",
className,
)}
>
<svg viewBox="0 0 100 100" className="absolute inset-0 size-full overflow-visible">
<polygon
points={HEXAGON_POINTS}
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
className="fill-[color-mix(in_oklch,var(--tone)_14%,transparent)] stroke-(--tone) stroke-1 sun:fill-card sun:stroke-2"
/>
</svg>
<Icon className={size === "md" ? "relative size-5" : "relative size-4"} />
</span>
);
}
@@ -0,0 +1,70 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const SRC = join(__dirname, "..");
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) {
return sourceFiles(path);
}
return /\.(tsx?|css)$/.test(name) && !/\.spec\./.test(name) ? [path] : [];
});
}
function read(relative: string): string {
return readFileSync(join(SRC, relative), "utf8");
}
describe("area migration", () => {
it("no file passes a gradient prop any more", () => {
const offenders = sourceFiles(SRC).filter((file) => {
const text = readFileSync(file, "utf8");
return /gradient=|var\(--gradient-|\[background-image:var\(--gradient/.test(text);
});
expect(offenders).toEqual([]);
});
it("no source other than globals.css uses the --gradient tokens", () => {
const offenders = sourceFiles(SRC)
.filter((file) => !file.endsWith("globals.css"))
.filter((file) => /--gradient-(honey|sage|sky)/.test(readFileSync(file, "utf8")));
expect(offenders).toEqual([]);
});
it("harvest detail uses Scissors, extraction create FlaskConical and storage only Warehouse", () => {
expect(read("app/(app)/harvests/[id]/page.tsx")).toContain('area="harvests"');
expect(read("app/(app)/extractions/new/page.tsx")).toContain('area="extractions"');
const storage = read("app/(app)/storage/page.tsx");
expect(storage).toContain('area="storage"');
expect(storage).not.toMatch(/icon=\{Package\}/);
});
it("medicine packs, hive systems and voice settings use their own area icon", () => {
expect(read("app/(app)/medicines/[id]/page.tsx")).not.toMatch(/icon=\{Package\}/);
expect(read("app/(app)/medicines/lots/[id]/page.tsx")).toContain('area="medicines"');
expect(read("app/(app)/settings/hive-systems/page.tsx")).toContain('area="hive-systems"');
expect(read("app/(app)/settings/voice/page.tsx")).toContain('area="voice"');
});
it("no component passes a raw icon to the headers any more", () => {
const offenders = sourceFiles(SRC).filter((file) => {
const text = readFileSync(file, "utf8");
return /<(PageHeader|DetailHeader|EntityCard)\b[^>]*\bicon=/.test(text);
});
expect(offenders).toEqual([]);
});
it("no white text outside the hive stage", () => {
const offenders = sourceFiles(SRC)
.filter((file) => !file.replaceAll("\\", "/").includes("/components/hive/"))
.filter((file) => /\btext-white\b/.test(readFileSync(file, "utf8")));
expect(offenders).toEqual([]);
});
it("the stage overlay on the hive stage uses the holo label colour instead of white", () => {
expect(read("components/hive/editor/editor-stage-overlay.tsx")).not.toMatch(/\btext-white\b/);
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { BRAND_CELL_PATHS, BrandMark } from "./brand-mark";
describe("BrandMark", () => {
it("renders the capped cell and the ApiCore wordmark", () => {
const { container } = render(<BrandMark withWordmark />);
const paths = container.querySelectorAll("svg path");
expect(paths).toHaveLength(2);
expect(paths[0]).toHaveAttribute("d", BRAND_CELL_PATHS.cell);
expect(paths[1]).toHaveAttribute("d", BRAND_CELL_PATHS.lid);
expect(screen.getByText("ApiCore")).toBeInTheDocument();
});
it("is decorative without a wordmark and sized 24 or 32 px", () => {
const { container, rerender } = render(<BrandMark />);
const svg = container.querySelector("svg") as SVGElement;
expect(svg).toHaveAttribute("aria-hidden", "true");
expect(svg).toHaveAttribute("width", "24");
expect(screen.queryByText("ApiCore")).not.toBeInTheDocument();
rerender(<BrandMark size={32} />);
expect(container.querySelector("svg")).toHaveAttribute("width", "32");
});
});
@@ -0,0 +1,48 @@
import { cn } from "@/lib/utils";
/**
* Geometry of the capped honeycomb cell (64 x 64 box): the cell and a smaller
* wax lid lifted a little. Shared with the icon script (spec 12.2).
*/
export const BRAND_CELL_PATHS = {
cell: "M32 4 L56 18 V46 L32 60 L8 46 V18 Z",
lid: "M32 13.6 L45.2 21.3 V36.7 L32 44.4 L19.8 36.7 V21.3 Z",
} as const;
interface BrandMarkProps {
size?: 24 | 32;
withWordmark?: boolean;
className?: string;
}
/** The ApiCore mark: a capped cell, optionally followed by the wordmark. */
export function BrandMark({ size = 24, withWordmark = false, className }: BrandMarkProps) {
const mark = (
<svg
viewBox="0 0 64 64"
width={size}
height={size}
aria-hidden="true"
focusable="false"
className="shrink-0"
>
<path
d={BRAND_CELL_PATHS.cell}
fill="var(--primary)"
stroke="var(--foreground)"
strokeWidth="3.84"
strokeLinejoin="round"
/>
<path d={BRAND_CELL_PATHS.lid} fill="oklch(0.88 0.11 85)" />
</svg>
);
if (!withWordmark) {
return mark;
}
return (
<span className={cn("inline-flex items-center gap-2 font-heading font-semibold", className)}>
{mark}
<span>ApiCore</span>
</span>
);
}
@@ -1,14 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Layers } from "lucide-react";
import { DetailHeader, DetailStatus } from "./detail-header";
describe("DetailHeader", () => {
it("shows the title as heading, a back link and the status as a word", () => {
render(
<DetailHeader
icon={Layers}
area="colonies"
title="V-01"
status={<DetailStatus status="ACTIVE" label="Aktiv" />}
backHref="/colonies"
@@ -29,7 +28,7 @@ describe("DetailHeader", () => {
const onSelect = vi.fn();
render(
<DetailHeader
icon={Layers}
area="colonies"
title="V-01"
primaryAction={<button type="button">Durchsicht erfassen</button>}
moreActions={[
@@ -11,6 +11,7 @@ import {
XCircle,
type LucideIcon,
} from "lucide-react";
import { AreaIcon } from "@/components/area-icon";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -20,6 +21,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ErrorState, LoadingState } from "@/components/query-state";
import type { AreaKey } from "@/lib/areas";
import { statusTone, type StatusTone } from "@/lib/status-tone";
export interface MoreAction {
@@ -33,7 +35,7 @@ export interface MoreAction {
}
interface DetailHeaderProps {
icon: LucideIcon;
area: AreaKey;
title: string;
subtitle?: ReactNode;
/** Icon plus word, never colour alone - see DetailStatus. */
@@ -52,7 +54,7 @@ interface DetailHeaderProps {
* in "Mehr" so the page does not ask for a decision on arrival.
*/
export function DetailHeader({
icon: Icon,
area,
title,
subtitle,
status,
@@ -67,12 +69,7 @@ export function DetailHeader({
<BackLink href={backHref} label={backLabel} />
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<span
className="flex size-10 shrink-0 items-center justify-center rounded-xl text-white shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Icon className="size-5" aria-hidden />
</span>
<AreaIcon area={area} />
<div className="flex min-w-0 flex-col gap-1">
<h1 className="font-heading text-2xl font-semibold tracking-tight break-words">
{title}
@@ -1,7 +1,8 @@
import { ReactNode } from "react";
import Link from "next/link";
import { type LucideIcon } from "lucide-react";
import { AreaIcon } from "@/components/area-icon";
import { Card, CardContent } from "@/components/ui/card";
import type { AreaKey } from "@/lib/areas";
interface EntityCardField {
label: string;
@@ -10,8 +11,7 @@ interface EntityCardField {
interface EntityCardProps {
href?: string;
icon: LucideIcon;
gradient?: string;
area: AreaKey;
title: ReactNode;
subtitle?: ReactNode;
badge?: ReactNode;
@@ -22,8 +22,7 @@ interface EntityCardProps {
export function EntityCard({
href,
icon: Icon,
gradient = "var(--gradient-honey)",
area,
title,
subtitle,
badge,
@@ -36,12 +35,7 @@ export function EntityCard({
<CardContent className="flex h-full flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-3">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-xl text-white shadow-[var(--shadow-soft)]"
style={{ background: gradient }}
>
<Icon className="size-5" />
</div>
<AreaIcon area={area} />
<div className="min-w-0">
<p className="truncate font-medium">{title}</p>
{subtitle ? (
@@ -52,7 +52,7 @@ export function RemoveZone({ zoneRef, active }: { zoneRef: RefObject<HTMLDivElem
<div
ref={zoneRef}
className={`pointer-events-none absolute inset-x-0 bottom-3 mx-auto flex w-fit items-center gap-2 rounded-2xl border-2 border-dashed px-6 py-4 text-sm font-medium transition-colors duration-150 ${
active ? "border-red-300 bg-red-500/35 text-white" : `border-white/30 ${GLASS_SURFACE}`
active ? "border-red-300 bg-red-500/35 text-holo-label-fg" : `border-white/30 ${GLASS_SURFACE}`
}`}
>
<Trash2 aria-hidden className="size-5" />
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { PageHeader } from "./page-header";
describe("PageHeader", () => {
it("renders the AreaIcon of its area and has no gradient prop", () => {
const { container } = render(<PageHeader area="queens" title="Königinnen" />);
expect(screen.getByRole("heading", { level: 1, name: "Königinnen" })).toBeInTheDocument();
expect(container.querySelector("[data-tone='betrieb']")).not.toBeNull();
// @ts-expect-error the gradient prop is gone
render(<PageHeader area="queens" title="x" gradient="red" />);
});
});
@@ -1,30 +1,19 @@
import { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { AreaIcon } from "@/components/area-icon";
import type { AreaKey } from "@/lib/areas";
interface PageHeaderProps {
icon: LucideIcon;
area: AreaKey;
title: string;
description?: string;
gradient?: string;
actions?: ReactNode;
}
export function PageHeader({
icon: Icon,
title,
description,
gradient = "var(--gradient-honey)",
actions,
}: PageHeaderProps) {
export function PageHeader({ area, title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span
className="flex size-10 shrink-0 items-center justify-center rounded-xl text-white shadow-[var(--shadow-soft)]"
style={{ background: gradient }}
>
<Icon className="size-5" />
</span>
<AreaIcon area={area} />
<div>
<h1 className="font-heading text-2xl font-semibold tracking-tight">{title}</h1>
{description ? (
@@ -9,7 +9,7 @@ const buttonVariants = cva(
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-[var(--shadow-soft)] [background-image:var(--gradient-honey)] hover:shadow-[var(--shadow-elevated)] hover:brightness-105 active:brightness-95",
"bg-primary text-primary-foreground shadow-[var(--shadow-soft)] hover:shadow-[var(--shadow-elevated)] hover:brightness-105 active:brightness-95",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: