Initialer Import: HA-Panel, Design-System, Companion-App

Drei zusammengehörige Teile in einem Repository:

- homeassistant/  Das fertige, im Einsatz befindliche Home-Assistant-Panel
  (panel_custom Custom Element + pyscript-Backend). Echte Fahrzeug- und
  Personendaten (fahrzeugprofil.json, fahrten.jsonl, tankvorgaenge.jsonl,
  Tankbelege) bleiben per .gitignore außen vor; die anonymisierte Vorlage
  fahrzeugprofil.example.json ist mit dabei.

- design-system/  Eigenständige React-Komponentenbibliothek (@audi-dash/ui),
  die die visuelle Sprache des Panels nachbildet - ohne Audi-Markenzeichen
  und ohne die lizenzierte Hausschrift. Dient als Grundlage für Claude
  Design. War bis hierher ein eigenes Repository und ist in dieses
  eingeschmolzen worden.

- companion-app/  Datenschicht der neuen App DataMetric360 (iOS/Android via
  Capacitor, zusätzlich als Iframe im HA-Dashboard). Noch ohne Oberfläche:
  REST- und WebSocket-Zugriff auf Home Assistant plus Warteschlange für
  Änderungen ohne Netz. Ersetzt das eingespritzte hass-Objekt, das nur
  innerhalb des HA-Frontends existiert.

Dazu die Projektdokumentation: SPECIFICATION.md (Ist-Stand des Panels),
COMPANION_APP_ARCHITECTURE.md (Architekturentscheidungen der neuen App),
AUDIT_2026-08-10.md, DESIGN_BRIEF_DATAMETRIC360.md und der ursprüngliche
Bauauftrag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 00:10:53 +02:00
commit e1d570992e
151 changed files with 18162 additions and 0 deletions
@@ -0,0 +1,55 @@
.ads-acc-h {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
background: none;
border: none;
font-family: inherit;
text-align: left;
padding: 12px 0;
cursor: pointer;
border-bottom: 1px solid var(--line);
}
.ads-acc-h__k {
flex: 1;
font-size: 15px;
color: var(--fg);
}
.ads-acc-h__v {
font-size: 11.5px;
color: var(--fg2);
text-align: right;
}
.ads-mark {
width: 6px;
height: 10px;
border-right: 1.4px solid var(--fg3);
border-bottom: 1.4px solid var(--fg3);
transform: rotate(-45deg) scaleY(1);
transition: transform .15s, border-color .15s;
}
.ads-acc-h[aria-expanded="true"] .ads-mark {
border-color: var(--red);
transform: rotate(45deg) scaleY(1);
}
.ads-acc-b {
display: none;
}
.ads-acc-b--on {
display: block;
}
.ads-accordion--lvl2 {
margin-left: 22px;
}
.ads-accordion--lvl2 .ads-acc-h__k {
font-size: 13px;
}
@@ -0,0 +1,50 @@
import * as React from "react";
export interface AccordionProps {
title: React.ReactNode;
/** Right-aligned header value shown next to the title. */
summary?: React.ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** 2 = nested, indented sub-accordion with smaller header font. */
level?: 1 | 2;
children: React.ReactNode;
className?: string;
}
export function Accordion({
title,
summary,
open,
defaultOpen = false,
onOpenChange,
level = 1,
children,
className,
}: AccordionProps) {
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const isControlled = open !== undefined;
const isOpen = isControlled ? open : internalOpen;
const toggle = () => {
const next = !isOpen;
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
};
const classes = ["ads-accordion"];
if (level === 2) classes.push("ads-accordion--lvl2");
if (className) classes.push(className);
return (
<div className={classes.join(" ")}>
<button type="button" className="ads-acc-h" onClick={toggle} aria-expanded={isOpen}>
<span className="ads-acc-h__k">{title}</span>
{summary !== undefined && <span className="ads-acc-h__v">{summary}</span>}
<span className="ads-mark" aria-hidden />
</button>
<div className={isOpen ? "ads-acc-b ads-acc-b--on" : "ads-acc-b"}>{children}</div>
</div>
);
}
@@ -0,0 +1,50 @@
.ads-leaf {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
width: 100%;
background: none;
border: none;
font-family: inherit;
text-align: left;
padding: 12px 0 12px 44px;
cursor: default;
border-bottom: 1px solid var(--line);
}
button.ads-leaf {
cursor: pointer;
}
.ads-leaf__k {
font-size: 13.5px;
color: var(--fg);
display: flex;
flex-direction: column;
}
.ads-leaf__k small {
font-size: 11px;
color: var(--fg3);
}
.ads-leaf__v {
font-size: 13px;
color: var(--fg2);
text-align: right;
display: flex;
flex-direction: column;
}
.ads-leaf__v small {
font-size: 11px;
color: var(--fg3);
}
.ads-chev {
width: 6px;
height: 10px;
color: var(--fg3);
flex: 0 0 auto;
}
@@ -0,0 +1,31 @@
import * as React from "react";
export interface LeafRowProps {
title: React.ReactNode;
subTitle?: React.ReactNode;
value?: React.ReactNode;
subValue?: React.ReactNode;
onClick?: () => void;
className?: string;
}
export function LeafRow({ title, subTitle, value, subValue, onClick, className }: LeafRowProps) {
const classes = ["ads-leaf"];
if (className) classes.push(className);
const Tag: React.ElementType = onClick ? "button" : "div";
return (
<Tag type={onClick ? "button" : undefined} className={classes.join(" ")} onClick={onClick}>
<span className="ads-leaf__k">
{title}
{subTitle !== undefined && <small>{subTitle}</small>}
</span>
<span className="ads-leaf__v">
{value}
{subValue !== undefined && <small>{subValue}</small>}
</span>
<svg className="ads-chev" viewBox="0 0 6 10" aria-hidden>
<path d="M1 1l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.4" />
</svg>
</Tag>
);
}
@@ -0,0 +1,4 @@
export { Accordion } from "./Accordion";
export type { AccordionProps } from "./Accordion";
export { LeafRow } from "./LeafRow";
export type { LeafRowProps } from "./LeafRow";
@@ -0,0 +1,34 @@
.ads-action {
display: block;
width: 100%;
font-family: inherit;
font-size: 11px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--fg);
background: none;
border: 1px solid var(--line-strong);
border-radius: var(--r-pill);
padding: 14px;
cursor: pointer;
}
.ads-action:hover:not(:disabled) {
border-color: var(--red);
}
.ads-action:disabled {
opacity: .4;
cursor: default;
}
.ads-action--destructive {
color: var(--bad);
border-color: var(--bad);
}
.ads-action--destructive:hover:not(:disabled) {
background: var(--bad);
color: var(--fg);
border-color: var(--bad);
}
@@ -0,0 +1,28 @@
import * as React from "react";
export interface ActionButtonProps {
children: React.ReactNode;
onClick?: () => void;
variant?: "default" | "destructive";
disabled?: boolean;
type?: "button" | "submit";
className?: string;
}
export function ActionButton({
children,
onClick,
variant = "default",
disabled,
type = "button",
className,
}: ActionButtonProps) {
const classes = ["ads-action"];
if (variant === "destructive") classes.push("ads-action--destructive");
if (className) classes.push(className);
return (
<button type={type} className={classes.join(" ")} onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
@@ -0,0 +1,2 @@
export { ActionButton } from "./ActionButton";
export type { ActionButtonProps } from "./ActionButton";
@@ -0,0 +1,46 @@
.ads-feld {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--sp-4);
padding: 12px 0;
border-bottom: 1px solid var(--line);
}
.ads-feld--last {
border-bottom: 0;
}
.ads-feld label {
font-size: 13px;
color: var(--fg2);
}
.ads-feld input,
.ads-feld select {
background: var(--tile-2);
border: 1px solid var(--line);
color: var(--fg);
font-family: inherit;
font-size: 13.5px;
padding: 8px 11px;
border-radius: var(--r-func);
text-align: right;
min-width: 132px;
}
.ads-mit-einheit {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
}
.ads-mit-einheit input {
min-width: 92px;
text-align: right;
}
.ads-mit-einheit span:last-child {
font-size: 13px;
color: var(--fg2);
}
@@ -0,0 +1,40 @@
import * as React from "react";
export interface FeldProps {
label: React.ReactNode;
/** Read-only display mode - mutually exclusive with `children`. */
value?: React.ReactNode;
/** Interactive control mode (input/select/Switch/etc.) - mutually exclusive with `value`. */
children?: React.ReactNode;
/** Wraps `children` with a trailing unit label, e.g. "Minuten" (the "MitEinheit" pattern). */
unit?: string;
/** Omit the bottom hairline - use on the last field in a group. */
last?: boolean;
className?: string;
}
export function Feld({ label, value, children, unit, last, className }: FeldProps) {
const classes = ["ads-feld"];
if (last) classes.push("ads-feld--last");
if (className) classes.push(className);
const control = children ? (
unit ? (
<span className="ads-mit-einheit">
{children}
<span>{unit}</span>
</span>
) : (
children
)
) : (
<span>{value}</span>
);
return (
<div className={classes.join(" ")}>
<label>{label}</label>
{control}
</div>
);
}
@@ -0,0 +1,2 @@
export { Feld } from "./Feld";
export type { FeldProps } from "./Feld";
+14
View File
@@ -0,0 +1,14 @@
.ads-fig {
font-weight: 300;
letter-spacing: -.02em;
line-height: .95;
color: var(--fg);
}
.ads-fig__unit {
font-size: .32em;
font-weight: 400;
letter-spacing: normal;
color: var(--fg2);
margin-left: .32em;
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
export interface FigProps {
value: React.ReactNode;
unit?: React.ReactNode;
/** Font-size override in px, e.g. 40 for a hero readout, smaller for compact contexts. */
size?: number | string;
className?: string;
}
export function Fig({ value, unit, size, className }: FigProps) {
const classes = ["ads-fig"];
if (className) classes.push(className);
const style: React.CSSProperties | undefined =
size !== undefined ? { fontSize: typeof size === "number" ? `${size}px` : size } : undefined;
return (
<span className={classes.join(" ")} style={style}>
{value}
{unit !== undefined && <span className="ads-fig__unit">{unit}</span>}
</span>
);
}
@@ -0,0 +1,2 @@
export { Fig } from "./Fig";
export type { FigProps } from "./Fig";
@@ -0,0 +1,28 @@
.ads-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: none;
border-radius: 50%;
background: none;
color: var(--fg3);
cursor: pointer;
padding: 0;
}
.ads-icon-button svg {
width: 18px;
height: 18px;
}
.ads-icon-button:hover:not(:disabled) {
background: var(--tile-2);
color: var(--fg);
}
.ads-icon-button:disabled {
opacity: .4;
cursor: default;
}
@@ -0,0 +1,33 @@
import * as React from "react";
export interface IconButtonProps {
onClick?: () => void;
/** SVG icon element rendered inside the circular hit area. */
children: React.ReactNode;
/** Required - this is an icon-only control with no visible text label. */
"aria-label": string;
disabled?: boolean;
className?: string;
}
export function IconButton({
onClick,
children,
disabled,
className,
...rest
}: IconButtonProps) {
const classes = ["ads-icon-button"];
if (className) classes.push(className);
return (
<button
type="button"
className={classes.join(" ")}
onClick={onClick}
disabled={disabled}
{...rest}
>
{children}
</button>
);
}
@@ -0,0 +1,2 @@
export { IconButton } from "./IconButton";
export type { IconButtonProps } from "./IconButton";
@@ -0,0 +1,58 @@
.ads-bildbox {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
border-radius: 12px;
overflow: hidden;
}
.ads-bildbox img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.ads-bildbox--mini {
width: 78px;
height: 78px;
aspect-ratio: auto;
border-radius: 12px;
}
.ads-bildbox--round {
border-radius: 50%;
}
.ads-platzhalter {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px dashed var(--line-strong);
border-radius: inherit;
color: var(--fg3);
text-align: center;
padding: var(--sp-2);
}
.ads-platzhalter__icon svg {
width: 26px;
height: 26px;
color: var(--fg3);
}
.ads-platzhalter__label {
font-size: 10.5px;
letter-spacing: .02em;
color: var(--fg3);
}
.ads-platzhalter__hint {
font-family: ui-monospace, monospace;
font-size: 9px;
color: var(--fg3);
}
@@ -0,0 +1,58 @@
import * as React from "react";
export interface ImagePlaceholderProps {
src?: string;
alt: string;
/** Caption shown on the load-error placeholder. */
label: React.ReactNode;
/** Small monospace hint under the label, e.g. an expected filename. */
hint?: React.ReactNode;
/** Placeholder icon override - defaults to a simple image glyph. */
icon?: React.ReactNode;
size?: "default" | "mini" | "round";
className?: string;
}
const DEFAULT_ICON = (
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden>
<path
fill="currentColor"
fillRule="evenodd"
d="M4 5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5Zm2 0v14h12V5H6Zm3 3a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm-1 8 3-3 2 2 3-4 3 5H8Z"
clipRule="evenodd"
/>
</svg>
);
export function ImagePlaceholder({
src,
alt,
label,
hint,
icon,
size = "default",
className,
}: ImagePlaceholderProps) {
const [failed, setFailed] = React.useState(false);
const classes = ["ads-bildbox"];
if (size === "mini") classes.push("ads-bildbox--mini");
if (size === "round") classes.push("ads-bildbox--round");
if (className) classes.push(className);
const showPlaceholder = !src || failed;
return (
<div className={classes.join(" ")}>
{!showPlaceholder && (
<img src={src} alt={alt} onError={() => setFailed(true)} />
)}
{showPlaceholder && (
<div className="ads-platzhalter">
<span className="ads-platzhalter__icon">{icon ?? DEFAULT_ICON}</span>
<span className="ads-platzhalter__label">{label}</span>
{hint !== undefined && <span className="ads-platzhalter__hint">{hint}</span>}
</div>
)}
</div>
);
}
@@ -0,0 +1,2 @@
export { ImagePlaceholder } from "./ImagePlaceholder";
export type { ImagePlaceholderProps } from "./ImagePlaceholder";
@@ -0,0 +1,16 @@
.ads-pill {
display: inline-flex;
align-items: center;
font-size: 10px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--fg2);
border: 1px solid var(--line-strong);
border-radius: var(--r-pill);
padding: 6px 12px;
}
.ads-pill--work {
color: var(--red);
border-color: var(--red);
}
@@ -0,0 +1,15 @@
import * as React from "react";
export interface PillProps {
children: React.ReactNode;
/** "work" recolors border+text to the accent red - use for a highlighted tag. */
variant?: "default" | "work";
className?: string;
}
export function Pill({ children, variant = "default", className }: PillProps) {
const classes = ["ads-pill"];
if (variant === "work") classes.push("ads-pill--work");
if (className) classes.push(className);
return <span className={classes.join(" ")}>{children}</span>;
}
@@ -0,0 +1,2 @@
export { Pill } from "./Pill";
export type { PillProps } from "./Pill";
@@ -0,0 +1,42 @@
.ads-popup-catcher {
position: fixed;
inset: 0;
z-index: 1000;
background: none;
border: none;
}
.ads-popup {
position: fixed;
z-index: 1001;
background: var(--tile-2);
border-radius: 14px;
padding: var(--sp-4);
}
.ads-popup--menu {
display: flex;
flex-direction: column;
min-width: 180px;
}
.ads-popup--form {
min-width: 260px;
}
.ads-popup-item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
color: var(--fg);
font-family: inherit;
font-size: 13.5px;
padding: 10px 4px;
cursor: pointer;
}
.ads-popup-item--destructive {
color: var(--bad);
}
@@ -0,0 +1,71 @@
import * as React from "react";
import { createPortal } from "react-dom";
export interface PopupAnchor {
top?: number;
left?: number;
right?: number;
bottom?: number;
}
export interface PopupProps {
open: boolean;
onClose: () => void;
/** "menu" = compact button-list card; "form" = wider inline form card. */
variant?: "menu" | "form";
anchor?: PopupAnchor | "center";
children: React.ReactNode;
className?: string;
/** Portal target - defaults to document.body. Override for iframe/canvas embedding. */
portalTarget?: Element;
}
export function Popup({
open,
onClose,
variant = "menu",
anchor = "center",
children,
className,
portalTarget,
}: PopupProps) {
if (!open) return null;
const classes = ["ads-popup", `ads-popup--${variant}`];
if (className) classes.push(className);
const style: React.CSSProperties =
anchor === "center"
? { top: "50%", left: "50%", transform: "translate(-50%, -50%)" }
: { ...anchor };
const content = (
<>
<div className="ads-popup-catcher" onClick={onClose} />
<div className={classes.join(" ")} style={style}>
{children}
</div>
</>
);
const target = portalTarget ?? (typeof document !== "undefined" ? document.body : null);
return target ? createPortal(content, target) : content;
}
export interface PopupMenuItemProps {
onClick?: () => void;
destructive?: boolean;
children: React.ReactNode;
}
export function PopupMenuItem({ onClick, destructive, children }: PopupMenuItemProps) {
return (
<button
type="button"
className={destructive ? "ads-popup-item ads-popup-item--destructive" : "ads-popup-item"}
onClick={onClick}
>
{children}
</button>
);
}
@@ -0,0 +1,2 @@
export { Popup, PopupMenuItem } from "./Popup";
export type { PopupProps, PopupMenuItemProps, PopupAnchor } from "./Popup";
@@ -0,0 +1,48 @@
.ads-balken {
height: 4px;
border-radius: var(--r-pill);
background: var(--line);
overflow: hidden;
}
.ads-balken i {
display: block;
height: 100%;
background: var(--fg);
border-radius: var(--r-pill);
}
.ads-bar {
display: flex;
height: 4px;
border-radius: var(--r-pill);
overflow: hidden;
gap: 2px;
}
.ads-bar i {
display: block;
border-radius: var(--r-pill);
}
.ads-legend {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
margin-top: var(--sp-2);
font-size: 11px;
color: var(--fg2);
}
.ads-legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.ads-legend i {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
@@ -0,0 +1,58 @@
import * as React from "react";
export interface ProgressBarSegment {
value: number;
color?: string;
label?: React.ReactNode;
}
export interface ProgressBarProps {
/** Simple single-fill mode (uses --fg). Mutually exclusive with `segments`. */
percent?: number;
/** Stacked multi-fill mode with an optional legend row. */
segments?: ProgressBarSegment[];
/** Defaults to true when more than one segment is given. */
showLegend?: boolean;
className?: string;
}
export function ProgressBar({ percent, segments, showLegend, className }: ProgressBarProps) {
const classes = ["ads-progress-bar"];
if (className) classes.push(className);
if (segments && segments.length > 0) {
const total = segments.reduce((sum, s) => sum + s.value, 0) || 1;
const legend = showLegend ?? segments.length > 1;
return (
<div className={classes.join(" ")}>
<div className="ads-bar">
{segments.map((seg, i) => (
<i
key={i}
style={{ flex: seg.value / total, background: seg.color ?? "var(--fg)" }}
/>
))}
</div>
{legend && (
<div className="ads-legend">
{segments.map((seg, i) => (
<span key={i}>
<i style={{ background: seg.color ?? "var(--fg)" }} />
{seg.label}
</span>
))}
</div>
)}
</div>
);
}
const clamped = Math.max(0, Math.min(100, percent ?? 0));
return (
<div className={classes.join(" ")}>
<div className="ads-balken">
<i style={{ width: `${clamped}%` }} />
</div>
</div>
);
}
@@ -0,0 +1,2 @@
export { ProgressBar } from "./ProgressBar";
export type { ProgressBarProps, ProgressBarSegment } from "./ProgressBar";
@@ -0,0 +1,32 @@
.ads-ring {
position: relative;
display: inline-block;
}
.ads-ring svg {
transform: rotate(-90deg);
}
.ads-ring__track {
fill: none;
stroke: var(--line);
stroke-width: 10;
}
.ads-ring__arc {
fill: none;
stroke: var(--red);
stroke-width: 10;
stroke-linecap: round;
transition: stroke-dashoffset .3s;
}
.ads-ring__mid {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
@@ -0,0 +1,50 @@
import * as React from "react";
import { Fig } from "../Fig/Fig";
export interface ProgressRingProps {
/** 0-100 */
percent: number;
/** Center figure - defaults to the rounded percent value. */
value?: React.ReactNode;
unit?: string;
label?: React.ReactNode;
size?: number;
className?: string;
}
const RADIUS = 64;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
export function ProgressRing({
percent,
value,
unit,
label,
size = 150,
className,
}: ProgressRingProps) {
const clamped = Math.max(0, Math.min(100, percent));
const offset = CIRCUMFERENCE * (1 - clamped / 100);
const classes = ["ads-ring"];
if (className) classes.push(className);
return (
<div className={classes.join(" ")} style={{ width: size, height: size }}>
<svg viewBox="0 0 150 150" width={size} height={size}>
<circle cx="75" cy="75" r={RADIUS} className="ads-ring__track" />
<circle
cx="75"
cy="75"
r={RADIUS}
className="ads-ring__arc"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={offset}
/>
</svg>
<span className="ads-ring__mid">
<Fig value={value ?? Math.round(clamped)} unit={unit} />
{label !== undefined && <span className="ads-label">{label}</span>}
</span>
</div>
);
}
@@ -0,0 +1,2 @@
export { ProgressRing } from "./ProgressRing";
export type { ProgressRingProps } from "./ProgressRing";
@@ -0,0 +1,34 @@
.ads-rows {
margin: 0;
}
.ads-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--sp-3);
padding: 13px 0;
border-bottom: 1px solid var(--line);
}
.ads-row:last-child {
border-bottom: 0;
}
.ads-row dt {
font-size: 13px;
color: var(--fg2);
}
.ads-row dd {
margin: 0;
font-size: 14px;
color: var(--fg);
text-align: right;
}
.ads-row dd small {
display: block;
font-size: 11px;
color: var(--fg3);
}
@@ -0,0 +1,31 @@
import * as React from "react";
export interface RowListItem {
key: React.Key;
label: React.ReactNode;
value: React.ReactNode;
subValue?: React.ReactNode;
}
export interface RowListProps {
items: RowListItem[];
className?: string;
}
export function RowList({ items, className }: RowListProps) {
const classes = ["ads-rows"];
if (className) classes.push(className);
return (
<dl className={classes.join(" ")}>
{items.map((item) => (
<div className="ads-row" key={item.key}>
<dt>{item.label}</dt>
<dd>
{item.value}
{item.subValue !== undefined && <small>{item.subValue}</small>}
</dd>
</div>
))}
</dl>
);
}
@@ -0,0 +1,2 @@
export { RowList } from "./RowList";
export type { RowListProps, RowListItem } from "./RowList";
+23
View File
@@ -0,0 +1,23 @@
.ads-seg {
display: flex;
gap: var(--sp-2);
}
.ads-seg button {
flex: 1;
border: 1px solid var(--line-strong);
background: none;
color: var(--fg2);
font-family: inherit;
font-size: 11px;
letter-spacing: .12em;
text-transform: uppercase;
padding: 11px 8px;
border-radius: var(--r-pill);
cursor: pointer;
}
.ads-seg button.ads-seg__on {
border-color: var(--red);
color: var(--fg);
}
+37
View File
@@ -0,0 +1,37 @@
import * as React from "react";
export interface SegOption<T extends string = string> {
value: T;
label: React.ReactNode;
}
export interface SegProps<T extends string = string> {
options: SegOption<T>[];
value: T;
onChange: (value: T) => void;
className?: string;
}
export function Seg<T extends string = string>({
options,
value,
onChange,
className,
}: SegProps<T>) {
const classes = ["ads-seg"];
if (className) classes.push(className);
return (
<div className={classes.join(" ")}>
{options.map((opt) => (
<button
key={opt.value}
type="button"
className={opt.value === value ? "ads-seg__on" : undefined}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
);
}
@@ -0,0 +1,2 @@
export { Seg } from "./Seg";
export type { SegProps, SegOption } from "./Seg";
@@ -0,0 +1,17 @@
.ads-quad {
display: grid;
gap: var(--sp-4);
}
.ads-quad__n {
display: block;
}
.ads-quad__l {
display: block;
margin-top: 4px;
font-size: 9px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--fg3);
}
@@ -0,0 +1,29 @@
import * as React from "react";
import { Fig } from "../Fig/Fig";
export interface StatGridItem {
value: React.ReactNode;
unit?: string;
caption: React.ReactNode;
}
export interface StatGridProps {
items: StatGridItem[];
columns?: number;
className?: string;
}
export function StatGrid({ items, columns = 4, className }: StatGridProps) {
const classes = ["ads-quad"];
if (className) classes.push(className);
return (
<div className={classes.join(" ")} style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{items.map((item, i) => (
<div key={i}>
<Fig className="ads-quad__n" value={item.value} unit={item.unit} size={17} />
<span className="ads-quad__l">{item.caption}</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,2 @@
export { StatGrid } from "./StatGrid";
export type { StatGridProps, StatGridItem } from "./StatGrid";
@@ -0,0 +1,42 @@
.ads-status {
display: flex;
align-items: center;
gap: var(--sp-3);
width: 100%;
background: none;
border: none;
padding: 0;
text-align: left;
font-family: inherit;
cursor: default;
}
button.ads-status {
cursor: pointer;
}
.ads-dot {
width: 11px;
height: 11px;
border-radius: 50%;
flex: 0 0 auto;
}
.ads-dot--ok { background: var(--ok); }
.ads-dot--warn { background: var(--warn); }
.ads-dot--bad { background: var(--bad); }
.ads-status__text {
display: flex;
flex-direction: column;
}
.ads-status__t {
font-size: 14px;
color: var(--fg);
}
.ads-status__s {
font-size: 11px;
color: var(--fg3);
}
@@ -0,0 +1,28 @@
import * as React from "react";
export interface StatusRowProps {
status: "ok" | "warn" | "bad";
title: React.ReactNode;
subtitle?: React.ReactNode;
onClick?: () => void;
className?: string;
}
export function StatusRow({ status, title, subtitle, onClick, className }: StatusRowProps) {
const classes = ["ads-status"];
if (className) classes.push(className);
const Tag: React.ElementType = onClick ? "button" : "div";
return (
<Tag
type={onClick ? "button" : undefined}
className={classes.join(" ")}
onClick={onClick}
>
<span className={`ads-dot ads-dot--${status}`} aria-hidden />
<span className="ads-status__text">
<span className="ads-status__t">{title}</span>
{subtitle !== undefined && <span className="ads-status__s">{subtitle}</span>}
</span>
</Tag>
);
}
@@ -0,0 +1,2 @@
export { StatusRow } from "./StatusRow";
export type { StatusRowProps } from "./StatusRow";
@@ -0,0 +1,25 @@
.ads-swipe-wrap {
position: relative;
overflow: hidden;
}
.ads-swipe-delete {
position: absolute;
inset: 0 0 0 auto;
width: 84px;
background: var(--bad);
color: #fff;
border: none;
font-family: inherit;
font-size: 11px;
letter-spacing: .08em;
text-transform: uppercase;
cursor: pointer;
}
.ads-swipe-content {
position: relative;
background: var(--tile);
touch-action: pan-y;
transition: transform .18s;
}
@@ -0,0 +1,95 @@
import * as React from "react";
const SWIPE_THRESHOLD = -40;
const SWIPE_OPEN = -84;
/** Module-wide pub/sub so opening one row closes every other open row (mirrors the source app's schliesseAlleSwipes()). */
const listeners = new Set<() => void>();
function closeAllExcept(keep: () => void) {
for (const close of listeners) {
if (close !== keep) close();
}
}
export interface SwipeRowProps {
children: React.ReactNode;
onDelete: () => void;
deleteLabel?: string;
/** Controlled swiped-open state. */
swiped?: boolean;
onSwipedChange?: (swiped: boolean) => void;
className?: string;
}
export function SwipeRow({
children,
onDelete,
deleteLabel = "Delete",
swiped,
onSwipedChange,
className,
}: SwipeRowProps) {
const [internalSwiped, setInternalSwiped] = React.useState(false);
const isControlled = swiped !== undefined;
const isSwiped = isControlled ? swiped : internalSwiped;
const setSwiped = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternalSwiped(next);
onSwipedChange?.(next);
},
[isControlled, onSwipedChange]
);
const close = React.useCallback(() => setSwiped(false), [setSwiped]);
React.useEffect(() => {
listeners.add(close);
return () => {
listeners.delete(close);
};
}, [close]);
const dragStartX = React.useRef<number | null>(null);
const onPointerDown = (e: React.PointerEvent) => {
dragStartX.current = e.clientX;
};
const onPointerMove = (e: React.PointerEvent) => {
if (dragStartX.current === null) return;
const delta = e.clientX - dragStartX.current;
if (delta < SWIPE_THRESHOLD) {
closeAllExcept(close);
setSwiped(true);
dragStartX.current = null;
} else if (delta > -SWIPE_THRESHOLD) {
setSwiped(false);
}
};
const onPointerUp = () => {
dragStartX.current = null;
};
const classes = ["ads-swipe-wrap"];
if (className) classes.push(className);
return (
<div className={classes.join(" ")}>
<button type="button" className="ads-swipe-delete" onClick={onDelete}>
{deleteLabel}
</button>
<div
className={isSwiped ? "ads-swipe-content ads-swiped" : "ads-swipe-content"}
style={{ transform: isSwiped ? `translateX(${SWIPE_OPEN}px)` : undefined }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,2 @@
export { SwipeRow } from "./SwipeRow";
export type { SwipeRowProps } from "./SwipeRow";
@@ -0,0 +1,51 @@
.ads-switch {
position: relative;
display: inline-block;
flex-shrink: 0;
width: 46px;
height: 27px;
}
.ads-switch input {
position: absolute;
inset: 0;
opacity: 0;
margin: 0;
cursor: pointer;
min-width: 0;
}
.ads-switch__track {
position: absolute;
inset: 0;
background: var(--tile-2);
border: 1px solid var(--line);
border-radius: var(--r-pill);
transition: background .18s;
pointer-events: none;
}
.ads-switch__track::before {
content: "";
position: absolute;
left: 2px;
top: 1px;
width: 23px;
height: 23px;
background: #fff;
border-radius: 50%;
transition: transform .18s;
}
.ads-switch input:checked + .ads-switch__track {
background: var(--red);
border-color: var(--red);
}
.ads-switch input:checked + .ads-switch__track::before {
transform: translateX(19px);
}
.ads-switch input:disabled + .ads-switch__track {
opacity: .4;
}
@@ -0,0 +1,36 @@
import * as React from "react";
export interface SwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
id?: string;
"aria-label"?: string;
className?: string;
}
export function Switch({
checked,
onChange,
disabled,
id,
className,
...rest
}: SwitchProps) {
const classes = ["ads-switch"];
if (className) classes.push(className);
return (
<span className={classes.join(" ")}>
<input
type="checkbox"
role="switch"
id={id}
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
{...rest}
/>
<span className="ads-switch__track" aria-hidden />
</span>
);
}
@@ -0,0 +1,2 @@
export { Switch } from "./Switch";
export type { SwitchProps } from "./Switch";
@@ -0,0 +1,40 @@
.ads-tabbar {
display: grid;
border-top: 1px solid var(--line);
}
.ads-tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 0 8px;
background: none;
border: none;
border-top: 2px solid transparent;
margin-top: -1px;
color: var(--fg3);
font-family: inherit;
cursor: pointer;
}
.ads-tab--on {
border-top-color: var(--red);
color: var(--fg);
}
.ads-tab__icon svg {
width: 21px;
height: 21px;
}
.ads-tabbar--ohne .ads-tab__icon svg {
width: 26px;
height: 26px;
}
.ads-tab__label {
font-size: 9px;
letter-spacing: .08em;
text-transform: uppercase;
}
@@ -0,0 +1,40 @@
import * as React from "react";
export interface TabBarItem {
key: string;
label: string;
icon: React.ReactNode;
}
export interface TabBarProps {
items: TabBarItem[];
activeKey: string;
onChange: (key: string) => void;
/** Hide labels, enlarge icons (the source app's ".ohne" variant). */
iconOnly?: boolean;
className?: string;
}
export function TabBar({ items, activeKey, onChange, iconOnly, className }: TabBarProps) {
const classes = ["ads-tabbar"];
if (iconOnly) classes.push("ads-tabbar--ohne");
if (className) classes.push(className);
return (
<nav
className={classes.join(" ")}
style={{ gridTemplateColumns: `repeat(${items.length}, 1fr)` }}
>
{items.map((item) => (
<button
key={item.key}
type="button"
className={item.key === activeKey ? "ads-tab ads-tab--on" : "ads-tab"}
onClick={() => onChange(item.key)}
>
<span className="ads-tab__icon">{item.icon}</span>
{!iconOnly && <span className="ads-tab__label">{item.label}</span>}
</button>
))}
</nav>
);
}
@@ -0,0 +1,2 @@
export { TabBar } from "./TabBar";
export type { TabBarProps, TabBarItem } from "./TabBar";
@@ -0,0 +1,39 @@
.ads-tile {
background: var(--tile);
border-radius: var(--r-tile);
padding: var(--sp-5);
margin-bottom: var(--sp-3);
}
.ads-tile--flat {
background: none;
padding: 0;
}
button.ads-tile--button {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
border: none;
font-family: inherit;
text-align: left;
cursor: pointer;
position: relative;
}
button.ads-tile--button:hover {
background: var(--tile-2);
}
.ads-tile__content {
flex: 1;
}
.ads-tile__go {
width: 6px;
height: 10px;
color: var(--fg3);
flex: 0 0 auto;
margin-left: var(--sp-3);
}
@@ -0,0 +1,33 @@
import * as React from "react";
export interface TileProps {
variant?: "default" | "flat" | "button";
/** Trailing chevron affordance - only meaningful with variant="button". */
chevron?: boolean;
/** Required when variant="button". */
onClick?: () => void;
className?: string;
children: React.ReactNode;
}
export function Tile({ variant = "default", chevron, onClick, className, children }: TileProps) {
const classes = ["ads-tile"];
if (variant === "flat") classes.push("ads-tile--flat");
if (variant === "button") classes.push("ads-tile--button");
if (className) classes.push(className);
if (variant === "button") {
return (
<button type="button" className={classes.join(" ")} onClick={onClick}>
<span className="ads-tile__content">{children}</span>
{chevron && (
<svg className="ads-tile__go" viewBox="0 0 6 10" aria-hidden>
<path d="M1 1l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.4" />
</svg>
)}
</button>
);
}
return <div className={classes.join(" ")}>{children}</div>;
}
@@ -0,0 +1,2 @@
export { Tile } from "./Tile";
export type { TileProps } from "./Tile";
+53
View File
@@ -0,0 +1,53 @@
export { Pill } from "./components/Pill";
export type { PillProps } from "./components/Pill";
export { IconButton } from "./components/IconButton";
export type { IconButtonProps } from "./components/IconButton";
export { Fig } from "./components/Fig";
export type { FigProps } from "./components/Fig";
export { StatusRow } from "./components/StatusRow";
export type { StatusRowProps } from "./components/StatusRow";
export { ActionButton } from "./components/ActionButton";
export type { ActionButtonProps } from "./components/ActionButton";
export { Switch } from "./components/Switch";
export type { SwitchProps } from "./components/Switch";
export { Seg } from "./components/Seg";
export type { SegProps, SegOption } from "./components/Seg";
export { RowList } from "./components/RowList";
export type { RowListProps, RowListItem } from "./components/RowList";
export { Tile } from "./components/Tile";
export type { TileProps } from "./components/Tile";
export { Feld } from "./components/Feld";
export type { FeldProps } from "./components/Feld";
export { ProgressRing } from "./components/ProgressRing";
export type { ProgressRingProps } from "./components/ProgressRing";
export { ProgressBar } from "./components/ProgressBar";
export type { ProgressBarProps, ProgressBarSegment } from "./components/ProgressBar";
export { StatGrid } from "./components/StatGrid";
export type { StatGridProps, StatGridItem } from "./components/StatGrid";
export { Accordion, LeafRow } from "./components/Accordion";
export type { AccordionProps, LeafRowProps } from "./components/Accordion";
export { SwipeRow } from "./components/SwipeRow";
export type { SwipeRowProps } from "./components/SwipeRow";
export { TabBar } from "./components/TabBar";
export type { TabBarProps, TabBarItem } from "./components/TabBar";
export { Popup, PopupMenuItem } from "./components/Popup";
export type { PopupProps, PopupMenuItemProps, PopupAnchor } from "./components/Popup";
export { ImagePlaceholder } from "./components/ImagePlaceholder";
export type { ImagePlaceholderProps } from "./components/ImagePlaceholder";
+22
View File
@@ -0,0 +1,22 @@
/* Einstiegspunkt: Tokens zuerst, danach jede Komponenten-CSS-Datei. */
@import "./tokens/tokens.css";
@import "./components/Pill/Pill.css";
@import "./components/IconButton/IconButton.css";
@import "./components/Fig/Fig.css";
@import "./components/StatusRow/StatusRow.css";
@import "./components/ActionButton/ActionButton.css";
@import "./components/Switch/Switch.css";
@import "./components/Seg/Seg.css";
@import "./components/RowList/RowList.css";
@import "./components/Accordion/LeafRow.css";
@import "./components/Tile/Tile.css";
@import "./components/Feld/Feld.css";
@import "./components/ProgressRing/ProgressRing.css";
@import "./components/ProgressBar/ProgressBar.css";
@import "./components/StatGrid/StatGrid.css";
@import "./components/Accordion/Accordion.css";
@import "./components/SwipeRow/SwipeRow.css";
@import "./components/TabBar/TabBar.css";
@import "./components/Popup/Popup.css";
@import "./components/ImagePlaceholder/ImagePlaceholder.css";
+98
View File
@@ -0,0 +1,98 @@
/*
* Design-Tokens, aus dem Audi-Dashboard (audi-dashboard.css) übernommen.
* Bewusst OHNE die dort eingebundene "Audi Type"-Schriftfamilie und ohne
* Audi-Marken-Assets (Ringe/Typenschild) - laut bauauftrag.md §"Rechtlich"
* ausschließlich für die private, nicht veröffentlichte HA-Installation
* freigegeben. Hier gilt ein neutraler Fallback-Font-Stack.
*/
:root {
/* Markenunabhängig */
--red: #F50537;
--r-tile: 20px;
--r-pill: 999px;
--r-func: 8px;
--rahmen-hg: #111;
/* Abstandsskala (aus den im Original wiederkehrenden Literalwerten abgeleitet) */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 22px;
/* Nacht (Default-Theme, entspricht dem :root-Block des Originals) */
--canvas: #161b23;
--tile: #1f2733;
--tile-2: #2a3341;
--line: rgba(255, 255, 255, .10);
--line-strong: rgba(255, 255, 255, .20);
--fg: #FFFFFF;
--fg2: #9aa1ad;
--fg3: #657081;
--ok: #15da15;
--warn: #ffaa00;
--bad: #fd2c4e;
--shade: rgba(255, 255, 255, .05);
--font-stack: Helvetica, Arial, sans-serif;
}
[data-theme="tag"] {
--canvas: #FFFFFF;
--tile: #f2f2f2;
--tile-2: #e5e5e5;
--line: rgba(0, 0, 0, .10);
--line-strong: rgba(0, 0, 0, .22);
--fg: #000000;
--fg2: #4c4c4c;
--fg3: #666666;
--ok: #0DA20D;
--warn: #ffaa00;
--bad: #eb0d3f;
--shade: rgba(0, 0, 0, .04);
}
/*
* Wurzel-Klasse: von konsumierenden Apps um den Baum zu legen, der die
* Bibliothek nutzt (ersetzt das :host-Scoping, das das Original per
* Shadow DOM kostenlos bekam). Setzt Schriftstack + Zahlendarstellung.
*/
.ads-root {
font-family: var(--font-stack);
font-variant-numeric: tabular-nums;
color: var(--fg);
background: var(--canvas);
}
/* Reine Typografie-Utility-Klassen (siehe README) - keine eigenen Komponenten */
.ads-eyebrow {
font-size: 10px;
letter-spacing: .2em;
text-transform: uppercase;
color: var(--fg3);
display: block;
}
.ads-title {
font-size: 23px;
letter-spacing: -.015em;
line-height: 1.15;
color: var(--fg);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ads-label {
font-size: 10px;
letter-spacing: .2em;
text-transform: uppercase;
color: var(--fg3);
display: block;
}
.ads-sport {
font-style: italic;
letter-spacing: .005em;
}