Hinweis: Der Beitrag wurde mit KI-Unterstützung (Claude Code) verfasst und manuell nachbearbeitet.
HA Widgets — alle Teile: Teil 1: Überblick · Teil 2: Wie fängt man an? · Teil 3: Architektur · Teil 4: Architektur-Checks · Teil 5: HA-Infrastruktur · Teil 6: LCARS-Design · Teil 7: Phone und Desktop · Teil 8: Claude Code richtig briefen
Teil 6 hat sich komplett mit der Tablet-App beschäftigt — LCARS, CSS-Tricks, wiederverwendbare Komponenten. Jetzt die anderen beiden: die Handy-App für unterwegs und die Desktop-App als Verwaltungs-Interface.
Handy: eine andere Ästhetik
Das Tablet hat einen eigenwilligen Star-Trek-Stil. Das Handy nicht — es soll sich wie eine normale Android-App anfühlen. Material 3 Dark war die naheliegende Wahl: das aktuelle Android-Designsystem, dunkel, vertraut.
Das CSS-System der Handy-App orientiert sich an Material-3-Tokens. Keine Bibliothek — einfach CSS-Variablen, die die Semantik abbilden:
:root {
--md-bg: #121212;
--md-surface: #1E1E1E;
--md-surface-2: #242424;
--md-primary: #82B1FF;
--md-on-bg: #E1E1E1;
--md-secondary: #9E9E9E;
--md-error: #CF6679;
--md-success: #66BB6A;
--md-divider: #2E2E2E;
}
Dazu semantische Aliasse — --success, --warning, --error, --accent — damit Composables aus shared/ (die beide Apps verwenden) nicht wissen müssen ob sie auf Material oder LCARS laufen.
Bottom Navigation
Drei Tabs: Übersicht, Vorräte, Kochen. Sidebar gibt es nicht — auf dem Handy wäre sie falsch platziert.
const tabs: Tab[] = [
{ path: '/uebersicht', label: 'Übersicht', icon: ICON_HOME },
{ path: '/einkaufen', label: 'Vorräte', icon: ICON_VORRAT },
{ path: '/rezepte', label: 'Kochen', icon: ICON_FORK },
]
Die SVG-Icons sind als Path-Daten direkt in App.vue inline — keine Icon-Bibliothek, kein zusätzliches Bundle. Drei Strings, keine Dependency.
<keep-alive include="ViewRezepte"> erhält die Rezeptbuch-Instanz beim Tab-Wechsel: wer ein Rezept aufklappt, dann kurz die Vorräte checkt, findet das Rezept noch offen vor.

Touch-Feinheiten
Drei CSS-Details, die auf dem Handy spürbar sind:
Scrollbar ausblenden. Auf einem Touchscreen ist keine Scrollbar nötig — sie würde nur Platz wegnehmen.
* { scrollbar-width: none; }
*::-webkit-scrollbar { display: none; }
Global in style.css. Funktioniert in Firefox und Webkit.
Tap-Highlight unterdrücken. Android zeigt beim Antippen von Buttons standardmäßig einen grauem Schimmer. Bei Custom-Buttons stört das:
-webkit-tap-highlight-color: transparent;
Steht auf allen .md-btn, .md-toggle, .md-chip — die Farbgebung beim Antippen kommt dann explizit über :active { opacity: 0.7 }.
Number-Inputs ohne Spinner. Browser zeigen bei <input type="number"> Auf/Ab-Pfeile. Im Layout sehen die schief aus:
.md-input[type=number]::-webkit-inner-spin-button,
.md-input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; }
.md-input[type=number] { -moz-appearance: textfield; }
md-toggle: das Haupt-Button-Muster
Auf dem Handy gibt es viele Toggles — Lichter, Schalter, Aktionen. Alle sehen gleich aus:
.md-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 11px 14px;
border-radius: 10px;
background: var(--md-surface-2);
width: 100%;
}
.md-toggle.on { background: var(--md-primary-dim); color: var(--md-primary); }
Rechts im Button ein kleiner LED-Dot:
<button class="md-toggle" :class="{ on: lightOn }" @click="toggle(E.lights.wohnzimmer)">
<span>Wohnzimmer</span>
<div class="md-led" :class="{ on: lightOn }"></div>
</button>
Der LED-Dot (9×9px, Vollkreis) leuchtet bei on mit box-shadow in der Akzentfarbe — minimales visuelles Feedback, kein Icon-Swap.
PC-Button: Tap und Hold
Der PC-Button hat zwei Funktionen: kurz tippen schaltet den Switch, lang halten (600 ms) bricht ein laufendes Herunterfahren ab. Das steckt im shared Composable usePCVater.
function startHold(): void {
holdTimer = setTimeout(() => {
runScript(E.scripts.pcAbbrechen)
holdTimer = null
}, 600)
}
function cancelHold(): void {
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
}
Im Template:
<button class="md-toggle"
@click="togglePC"
@pointerdown="startHold"
@pointerup="cancelHold"
@pointerleave="cancelHold">
@pointerdown/@pointerup statt Touch-Events — funktioniert auf Touch und Mouse gleich, kein preventDefault-Konflikt.
Außerdem gibt es einen pcPending-State: nach dem Klick dreht ein kleiner Spinner im Button, bis HA die Zustandsänderung zurückmeldet. Kein Timeout-Blindwait — ein watch auf den Entity-State löst pcPending = false aus sobald sich etwas ändert:
watch(() => getState(E.switches.pcVater), (newVal) => {
if (pcPending.value && newVal !== pcStateAtClick) {
pcPending.value = false
}
})
Standorte via Nominatim
Die Übersicht zeigt wo sich Personen gerade befinden. Die HA Companion-App sendet GPS-Koordinaten; daraus soll eine lesbare Adresse werden.
usePersonLocation watcht Koordinaten-Attribute der Person-Entities und ruft bei Änderung den Nominatim Reverse-Geocoding-Endpunkt auf:
const res = await fetch(
`${NOMINATIM_REVERSE_URL}?format=json&lat=${lat}&lon=${lon}&accept-language=de`
)
Die URL steht in shared/config/apiConfig.ts — nicht im Composable.
Drei Fälle:
- State home → direkt "Zuhause", kein API-Aufruf
- State ist ein bekannter Zone-Name (z.B. "Schule") → direkt anzeigen
- not_home mit Koordinaten → Geocoding
Damit das nicht bei jedem GPS-Update eine API-Anfrage auslöst, gibt es einen Modul-Level-Cache:
const geocodeCache = new Map<string, string>()
const key = `${lat.toFixed(3)},${lon.toFixed(3)}`
if (geocodeCache.has(key)) return geocodeCache.get(key)!
Koordinaten werden auf drei Dezimalstellen gerundet — das entspricht ungefähr 100 Meter. Feinere Änderungen lösen keinen neuen Geocoding-Aufruf aus.
Desktop: Verwaltung am großen Bildschirm
Das Tablet bedient, das Handy schaut nach — der Desktop verwaltet. Rezepte anlegen und bearbeiten, Lagerkatalog pflegen, Artikel verschieben. Das sind Aufgaben für Maus und großes Display, nicht für einen Touchscreen.
Die Desktop-App hat einen Hash-Router mit fünf Views:
| Route | View | Funktion |
|---|---|---|
/ |
Dashboard | Übersicht SmartHome, Wetter, Gauges |
/lager |
Lager | Vorrats-Kategorien + Einkaufsliste |
/katalog |
Katalog | Alle Artikel als sortierbare Tabelle |
/smarthome |
SmartHome | Lichter, Heizung, Geräte |
/rezepte |
Rezeptbuch | Rezeptliste, Editor, KI-Rezept |
Die Topbar zeigt Breadcrumb-Navigation: auf der Startseite "Smart|Home", auf allen anderen Views "← Übersicht / Seitenname". Der meta.title der Route bestimmt den angezeigten Namen.
Theme-Toggle
Die Desktop-App hat einen Light/Dark-Toggle in der Topbar. Der Mechanismus ist simpel:
function toggleTheme(): void {
isDark.value = !isDark.value
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
}
CSS-Variablen werden im Light-Modus mit [data-theme=light] überschrieben. Default ist dunkel — weil das Entwickler so wollen. 😅
Rezept-Editor
Der Rezepteditor öffnet sich als Modal über BaseModal. Titel, Hauptkategorie, Filter-Tags, Beschreibung, URL, Zutaten, Schritte — alles bearbeitbar.
Der interessanteste Teil: der ≈-Button für die kcal-Berechnung.
async function berechneKcal() {
const km = Object.keys(kcalMap.value).length
? kcalMap.value
: getKcalMap(await loadKatalog())
let total = 0, treffer = 0
for (const z of form.zutaten) {
if (!z.name.trim()) continue
const info = km[z.name.trim().toLowerCase()]
const beitrag = kalkuliereKcal(z.menge, info)
if (beitrag === null) continue
total += beitrag
treffer++
}
if (treffer > 0) form.kcal = Math.round(total / (form.portionen || 2))
}
kalkuliereKcal() kommt aus shared/utils/kcalUtils.ts und wertet den Mengeneintrag ("200g", "3 Stück", "100ml") gegen den Nährwert im Katalog aus. Pro Zutat ein Beitrag, Summe durch Portionen — Näherungswert, kein Labor.
Der Wert kann überschrieben und mit ! suffixiert werden ("540!") — das markiert ihn als manuell fixiert und kalkuliereKcal überspringt ihn beim nächsten Aufruf.
Ein weiterer Button im Detail: "Auf Tablet anzeigen". Er sendet ein IPC-Event und weckt gleichzeitig den Tablet-Bildschirm:
function zeigeAufTablet(slug: string, portionen?: number): void {
ipcSend('navigate_rezept', { slug, portionen })
callAction('notify', E.notify.tablet, { message: 'command_screen_on' })
}
Das Tablet empfängt navigate_rezept in seiner App.vue — unabhängig von der gerade aktiven View — und navigiert direkt zum Rezept. Beschrieben in Teil 6.
Lagerverwaltung: Drag&Drop
Das Lager zeigt alle Vorratskategorien als Karten, jeweils mit den enthaltenen Artikeln. Artikel können per Drag&Drop zwischen Kategorien verschoben werden.
Die Logik steckt in useLagerDragDrop. Standardmäßiges HTML5-Drag-API:
function onDragStart(itemName: string): void {
dragItem.value = itemName
setTimeout(() => { isDragging.value = true }, 0)
}
async function onDrop(targetName: string): Promise<void> {
const name = dragItem.value
if (!name) return
// Item aus der alten Kategorie entfernen, in die neue eintragen
const newKat = JSON.parse(JSON.stringify(katalog.value)) as VorratKatalog
const katEntry = Object.entries(newKat).find(([, k]) => name in (k.items ?? {}))
if (!katEntry) return
const [currentKat] = katEntry
const meta = newKat[currentKat].items[name]
delete newKat[currentKat].items[name]
newKat[targetName].items[name] = meta
katalog.value = newKat
await saveKatalog(newKat)
}
Das setTimeout(() => isDragging.value = true, 0) im onDragStart ist ein kleines Detail: der isDragging-State setzt eine CSS-Klasse .drag-mode auf dem Grid, die alle Karten leicht abdunkelt außer der Drop-Target-Karte. Würde man das synchron setzen, könnte der Browser die initiale Ghost-Darstellung falsch berechnen — ein Frame Verzögerung verhindert das.
Es gibt zwei Ansichten: Kategorie-View (Standardsicht) und Abteilungs-View. In der Abteilungs-View sind Artikel nach Einkaufsgängen (Kühlregal, Gemüseabteilung, …) gruppiert — ein Drag verschiebt dort nicht die Kategorie, sondern setzt das abteilung-Feld am Item.
ViewKatalog: die Tabellen-Ansicht
Direkt neben der Lager-View gibt es den Katalog — eine sortierbare Tabelle aller Vorrats-Artikel mit Name, Kategorie, kcal/Einheit, Einheit und Flags.
Jede Spalte ist klickbar für Sortierung. Inline-Editing direkt in der Tabelle: ein Klick auf die Aktions-Schaltfläche versetzt die Zeile in den Edit-Modus, alle Felder werden zu Eingabefeldern. Speichern schreibt per wsCommand zurück auf den HA-Server.
function toggleSort(key: string): void {
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
else { sortKey.value = key; sortDir.value = 'asc' }
}
useKatalogTable in der Desktop-eigenen composables/ kapselt Sortier- und Filterzustand — nicht in shared/, weil nur der Desktop diese Ansicht hat.
GaugeRing: eine Prop, zwei Verwendungsarten
GaugeRing.vue aus shared/components zeigt einen SVG-Kreis-Gauge mit Wert und Label. Es gibt eine optionale size-Prop:
defineProps<{
val: string
pct: number
color: string
label?: string
size?: number // px — wenn nicht gesetzt: responsive (100% des Containers)
}>()
Ohne size füllt der Gauge seinen Container aus — gut für das Handy, wo die Gauges in einem Flex-Grid gleichmäßig verteilt sind. Mit size bekommt der Gauge eine feste Pixelgröße — gut für den Desktop, wo mehrere Gauges nebeneinander im Dashboard stehen und nicht auf Containerbreite reagieren sollen.
<GaugeRing v-bind="g" />
<GaugeRing v-bind="g" :size="48" />
Gleiche Komponente, dasselbe Template, eine Prop steuert das Verhalten.
rangeColor: Schwellenwerte die in beide Richtungen funktionieren
Der naheliegende Ansatz für Farb-Schwellenwerte: "über 80% ist schlecht, über 60% ist eine Warnung". Das stimmt für CPU-Auslastung — aber nicht für Luftfeuchtigkeit (zu trocken ist auch schlecht) oder Filterlebensdauer (niedrig ist schlecht).
rangeColor() aus shared/utils/systemUtils.ts löst das mit expliziten Bereichen:
rangeColor(value, [
{ from: 0, to: 12, color: 'var(--success)' },
{ from: 13, to: 35, color: 'var(--warning)' },
], 'var(--error)') // Fallback: über 35 → Fehler
Für den Luftreiniger-Filter (niedrig = schlecht):
rangeColor(filterReplace, [
{ from: 0, to: 20, color: 'var(--error)' },
{ from: 21, to: 50, color: 'var(--warning)' },
], 'var(--success)') // Fallback: über 50 → gut
Dasselbe Schema, andere Bereiche, andere Fallback-Farbe. Kein if/else-Wald in den Views.
useVorratEinkaufBase: nachträgliche Konsolidierung
In Teil 4 hat jscpd Template-Klone aufgedeckt. TypeScript-Logik-Klone findet es nicht — die mussten manuell auffallen.
An drei Stellen gab es fast identische init()-Funktionen: ViewLager (Desktop), ViewEinkauf (Phone), ViewEinkauf (Tablet). Alle drei luden nacheinander Einkaufsliste, Vorratskatalog und Vorrat-State — immer in derselben Reihenfolge, immer mit denselben try/finally-Blöcken.
Das Ergebnis: useVorratEinkaufBase in shared/composables/data/. Alle drei Views rufen jetzt einfach init() auf und bekommen katalog, inVorrat, loading und die ganzen Lade-/Speicher-Funktionen zurück. Die eigentliche Logik existiert genau einmal.
Das ist kein Refactoring das jscpd erzwungen hätte — aber nach dem Muster aus Teil 4 war die Frage "haben wir das schon dreimal?" leichter zu stellen.
Ein neues Feature als Beispiel: useAirPurifier
Irgendwann kam ein Luftreiniger hinzu. Typischer Fall: neue Hardware, in alle drei Apps integrieren.
Der Weg war: Composable in shared/composables/ui/useAirPurifier.ts schreiben, dann in drei Views einbauen. Das Composable liest fünf Entities (Fan-State, Lüftergeschwindigkeit, PM2.5-Wert, Allergen-Index, Filterzustand), berechnet GaugeConfig-Objekte für Lüfter, PM2.5 und Allergen-Gauge — und gibt Steuer-Funktionen zurück:
const { airGauges, turnOn, turnOff, setMode } = useAirPurifier()
function airTurboEin(): void { setMode('Turbo'); turnOn() }
function airAus(): void { turnOff() }
function airAutoEin(): void { setMode('Auto'); turnOn() }
Die Views bekommen fertige GaugeConfig[] und Buttons — keine Entity-IDs, keine Logik. Die Farben kommen über rangeColor():
const pm25Gauge = computed((): GaugeConfig => ({
label: 'PM2.5',
val: pm25.value != null ? String(pm25.value) : '--',
pct: (pm25.value ?? 0) / 75,
color: rangeColor(pm25.value ?? 0, [
{ from: 0, to: 12, color: 'var(--success)' },
{ from: 13, to: 35, color: 'var(--warning)' },
], 'var(--error)'),
}))
Drei Apps, ein Composable, keine doppelte Entity-ID. Das ist shared-first in der Praxis.
Wie geht es weiter?
Teil 8 schaut auf Claude Code selbst — wie man ein Projekt mit dem Tool startet, was in eine CLAUDE.md gehört, und wie der Pair-Programming-Ansatz in der Praxis aussieht.
Fazit
- Material 3 Dark: CSS-Variablen spiegeln Material-Tokens; semantische Aliasse (
--accent,--error, …) erlaubenshared/-Composables ohne Design-Kenntnis - Bottom Navigation: 3 Tabs, SVG-Icons als Inline-Path-Data,
keep-alivenur für die Rezept-View - Touch-Feinheiten: Scrollbar global ausgeblendet,
tap-highlight-color: transparent, Number-Inputs ohne Spinner - usePCVater: Tap = Toggle, Hold 600ms = Skript;
@pointerdown/@pointerupstatt Touch-Events;pcPendingmit Watch auf State-Change statt Timeout - usePersonLocation: Nominatim Reverse-Geocoding mit Modul-Level-Cache auf 100m-Raster; Home/Zone/Koordinaten-Fallback
- Desktop Hash-Router: 5 Views, Topbar mit Breadcrumb, Light/Dark-Toggle via
data-theme-Attribut - Rezept-Editor: kcal-Schätzung via
kalkuliereKcal()+≈-Button; "Auf Tablet anzeigen" kombiniert IPC und Bildschirm-Aufwecken - Drag&Drop:
useLagerDragDropmit HTML5-API;isDraggingmit einem Frame Verzögerung für korrektes Ghost-Rendering - GaugeRing
size-Prop: ohnesizeresponsiv (Phone), mitsizefixe Pixelgröße (Desktop) — gleiche Komponente, zwei Verhaltensmodi - rangeColor(): explizite
{ from, to, color }-Bereiche mit Fallback — funktioniert für Metriken die in beide Richtungen kippen können - useVorratEinkaufBase: identische
init()-Logik aus drei Views inshared/konsolidiert - useAirPurifier: neues Feature direkt als shared Composable — ein Ort, drei Apps


