Build a skin
A skin is a whole look for your dashboards: how every light, sensor and lock is drawn, on a phone and a wall panel, in Home Assistant’s light and dark modes. You don’t need to write code to have one of your own. Show an AI coding agent a picture you like, or tell it what you want in a few words. It builds the skin, shows it to you, changes it until it’s right and puts it on your Home Assistant.
The agent only has to change how things look. Every card already talks to Home Assistant, and the skin comes with checks that keep it working on a phone and in dark mode, plus a guide written for agents.
Ask an agent to build it
Section titled “Ask an agent to build it”There are two parts. Making the skin needs an agent that can run commands: one on your computer, such as the Claude Code app, or one in your browser, such as Claude Code on the web. Putting it on Home Assistant needs either that agent on your computer, on the same network as Home Assistant, or about five minutes of clicks in Home Assistant yourself. An agent in the browser can’t reach your Home Assistant, so it hands you the skin as one file instead.
What you need
Section titled “What you need”- An AI coding agent that can run commands, on your computer (Mac, Windows or Linux) or in your browser. A chat that can only write text, with no way to run commands, can’t build it.
- A picture of a look you like (a screenshot, a photo of a device or a room, a poster), or a few words about it.
- To put it on Home Assistant: an administrator account in Home Assistant.
You don’t need to install anything else yourself. The agent installs what it needs and tells you what it’s doing.
Make it
Section titled “Make it”-
Give the agent somewhere to keep your skin. On your computer, make an empty folder, for example My dashboard in your Documents, and open it in your agent. In the browser, start a session on a new, empty repository, so the skin is kept between sessions.
-
Paste the prompt below. Replace the two lines in angle brackets with your own, and attach your picture if you have one.
Prompt Build me my own Home Assistant dashboard skin with Dashhaus. I'm not a developer: explain each step in plain words.Name: <a short name in lower case, like cosy-cabin>Look: <a few words about the look, or "like the picture I attached">1. In this folder, run `npm create dashhaus-skin@latest` with that name, then follow the AGENTS.md it writes. Install anything it needs first, such as Node.js 22 or newer, and tell me what you install.2. Restyle the skin to the look: palette, type, page and cards, at phone and desktop width, in light and dark mode. Keep `npm test` passing.3. Show me the snapshots, and change the skin until I say I like it.4. When I say I'm ready, put it on my Home Assistant. If you run on my computer, deploy it with `npm run deploy`. Ask me for my Home Assistant's address, walk me through creating a long-lived access token, and help me set up the Terminal & SSH add-on so you can copy files into /config/www: make an SSH key if there is none and give me only its public line to paste. Keep the token in .env. If you can't reach my Home Assistant, follow the AGENTS.md's Install by hand instead: give me the file and walk me through installing it. -
Look at what it shows you: pictures of your skin on a sample house, on a phone and a desktop, light and dark. Say what you’d change in your own words: “warmer colours”, “bigger numbers”, “rounder buttons”, “more like the picture”. Repeat until you like it.
Put it on Home Assistant
Section titled “Put it on Home Assistant”Tell the agent you’re ready. What happens next depends on where it runs.
It deploys the skin for you. It asks for three things and walks you through each:
- Your Home Assistant’s address, the one in your browser when you open it, such as
http://homeassistant.local:8123. - A long-lived access token. In Home Assistant, open your profile (your name at the bottom of the sidebar),
then Security. Under Long-lived access tokens, press Create token, call it
Dashhausand give the agent what it shows. It’s shown only once. - A way to copy the skin into Home Assistant. Open Settings → Add-ons → Add-on store, install
Terminal & SSH, and in its Configuration tab paste the line the agent gives you into
Authorized keys. Under Network, set the port to
22. Save, then press Start on its Info tab.
The agent keeps the token in a file on your computer, .env, which never goes into the skin.
The agent gives you your skin as one file, such as dashhaus-cosy-cabin.js, and the project itself to keep for
the next change. Then it walks you through three steps in Home Assistant. No token and no terminal:
- Copy the file in. Open Settings → Add-ons → Add-on store, install File editor, press Start,
then Open web UI. With its folder icon, make a folder
wwwif there is none, a folder named after your skin inside it, such asdashhaus-cosy-cabin, and upload the file into that. If you just madewww, restart Home Assistant once: it only serves that folder when it was there at startup. - Load it. Turn on Advanced mode in your profile, then open Settings → Dashboards → ⋮ → Resources and
add the line the agent gives you, such as
/local/dashhaus-cosy-cabin/dashhaus-cosy-cabin.js?v=1, as a JavaScript module. - Make the dashboard. Settings → Dashboards → Add dashboard → New dashboard from scratch, with the title and URL the agent gives you. Open it, press the pencil, then ⋮ → Raw configuration editor, and paste the text the agent gives you in place of what’s there. Save.
Reload Home Assistant in your browser and open your skin’s dashboard from the sidebar. It shows every light and room sensor you have, grouped by area, and every other kind of entity the agent added a card for.
Change it later
Section titled “Change it later”Open the same folder or repository in your agent (or give it back the project it handed you) and say what you want.
Then ask it to deploy again, or, by hand, upload the new file in place of the old one and raise the number at the end
of its line in Resources, ?v=2, then ?v=3, so your browser fetches the new file. Your dashboard’s layout is
kept.
- “Add my blinds and my thermostat”: it adds a card for any kind of entity on the Entities pages.
- “Show the brightness on the light card”, “make the alarm card stand out”.
- “Put a greeting and the date at the top of the page.”
To see what agents make, open Studies: skins agents built, kept so you can take ideas from one or start from it.
Build it yourself
Section titled “Build it yourself”The rest of this page is what the agent does, for you to do in a terminal or to follow along. create-dashhaus-skin
writes a working skin in a new folder, with a light you can switch and a room sensor you can read, both with details
that open from the card, on the real hooks. npm run add draws any other domain the same way. You change the drawing;
the Home Assistant side is already done. It starts from the same templates as the skins built inside the Dashhaus
repository. It also writes an AGENTS.md, the guide your agent follows: where the colours, type, parts and rules live,
and how to restyle the skin from a screenshot.
CI runs these steps on every change to Dashhaus: it packs the release, installs @dashhaus/core,
@dashhaus/dashboard, @dashhaus/deploy and the scaffolder into an empty folder, scaffolds my-skin, runs its
checks, builds it and deploys it into a simulated Home Assistant.
-
Scaffold the skin. The folder’s name is the skin’s: lower-case kebab-case, new or empty.
Terminal window npm create dashhaus-skin@latest my-skincd my-skinnpm installFrom the release tarballs, in the folder that holds them:
Terminal window npx --yes --package ./create-dashhaus-skin-*.tgz create-dashhaus-skin my-skin --tarballs .cd my-skinnpm install--tarballspoints the@dashhausdependencies at the tarballs instead of the registry;--dry-runlists the files instead of writing them.npm installbrings@dashhaus/coreand@dashhaus/dashboard, React andhome-assistant-js-websocketas peers, and TypeScript, Vite and@dashhaus/deployfor the checks, the build and the deploy. -
Look at it. The scaffold draws the real element on a sample house that already holds the worst cases: an unavailable light, a sensor with no reading and a long name.
Terminal window npm run preview # in the browser, at the URL it printsnpm run snapshot # or as PNGs in preview/snapshots/Both use the same shadow root and CSS as Home Assistant. The first snapshot prints the command that installs Playwright’s Chromium if it’s missing. Snapshot again after every change and compare with your reference.
-
Check the cards:
Terminal window npm testIt typechecks the skin, then runs the conformance check from
@dashhaus/dashboard/conformanceovercards/and the CSS rules: each card has its component and its stories under the skin’s title and classes under the skin’s prefix, no presentational card imports the Home Assistant layer, every colour is a token,:hoversits under the hover media query and motion avoids layout properties. Each finding names its file, line and fix. Run it after every card change.conformance: 2 cards and 6 stylesheets, no findings -
Build one file:
Terminal window npm run builddist/dashhaus-my-skin.jsis one ES module carrying React, the cards and their CSS: Home Assistant loads one resource and needs no stylesheet. Its name is the skin’s, so it lives beside the prebuilt bundle on one Home Assistant. -
Deploy it. Copy the scaffold’s
.env.exampleto.envand fill inHA_URL,HA_TOKENandHA_WWWas Deploy from the command line describes, then:Terminal window npm run deployIt builds, copies the bundle into Home Assistant, registers it as a resource and creates the Home (My Skin) dashboard the first time, in a panel view. The last step fetches the file back from Home Assistant, so a wrong
HA_WWWshows up here instead of as a blank dashboard.npx dashhaus-deploy --helpprints the options, a config to start from and the.envkeys.dashboard.config.mjs import { spawnSync } from "node:child_process";// The dashboard `npm run deploy` creates: it builds dist/dashhaus-my-skin.js, uploads it to Home Assistant,// registers it and creates the dashboard the first time. Settings live in .env beside this file (.env.example).// The title the card prints in its header, as the preview does; also the name of the dashboard's one view.const PAGE_TITLE = "Home";export default {pageTitle: PAGE_TITLE,bundle: "dist/dashhaus-my-skin.js",async build() {const result = spawnSync("npm", ["run", "build", "--silent"], { stdio: "inherit", shell: process.platform === "win32" });if (result.status !== 0) throw new Error("npm run build failed");},dashboards: [{path: "dashhaus-my-skin",title: "Home (My Skin)",icon: "mdi:view-dashboard-outline",async card() {// No `cards` or `rows`: the card lays out every light and sensor, a row per area, each time it draws.return { card: { type: "custom:dashhaus-my-skin", title: PAGE_TITLE }, made: "automatic" };},},],};
What it writes
Section titled “What it writes”Directorymy-skin/
Directorycards/
Directorylight/
- MySkinLightCard.tsx the control: the light’s model in the frame, with its Toggle action
- MySkinLightCard.css
- MySkinLightCard.stories.tsx story metadata, read by the conformance check
- icons.tsx its glyph
Directorysensor/
- MySkinSensorCard.tsx the reading: the sensor’s model, no actions
- …
Directoryha/
- HaMySkinLightCard.tsx useHaLight above the light card
- HaMySkinSensorCard.tsx useHaSensor above the sensor card
Directoryshared/
- ui.tsx the card frame, its details dialog and the action button
- ui.css
- surface.ts the theme attribute and the web font
Directorydashboards/
- layout.tsx the page, its titled rows and the card grid
- Header.tsx
- NavBar.tsx
- dashboard.css
Directorylovelace/
- card.tsx the Lovelace card
- config.ts the YAML reader for custom:dashhaus-my-skin
- auto.ts the automatic layout
- registry.tsx which card draws which entity
- MySkinDashboard.tsx the page drawn from the YAML
- MySkinActionStatus.tsx the rejected-command row
- entry.tsx the bundle’s entry
Directorystyles/
- tokens.css every colour, under the skin’s prefix
Directorypreview/
- house.ts your sample house, worst cases included
- main.ts the page that mounts the real element
- snapshot.js saves it as PNGs
- AGENTS.md where the colours, type, parts and rules live
- CLAUDE.md imports AGENTS.md
- conformance.js the card and CSS rules npm test runs
- tsconfig.json the TypeScript program
- dashboard.config.mjs the bundle to build and the dashboard to create
- .env.example the three deploy settings
- .gitignore keeps .env and the build out of git
- vite.config.js the one-file build
- package.json
A card and its connection
Section titled “A card and its connection”A card takes its domain’s normalized model as entity, today’s history, its footprint, and one callback per action
it shows. It draws inside the skin’s card frame, so its shape, its name that opens its details and the details dialog
are the frame’s, and it never imports the Home Assistant layer: a card can be drawn with any data. The neutral card
shows the name, the state, one headline reading and its actions; the model has every other fact the hook reads, typed
in @dashhaus/core/entities/<domain>/types.
import type { ReactNode } from "react";import type { LightEntity, LightHistory } from "@dashhaus/core/entities/light/types";import { MySkinAction, MySkinCardFrame } from "../../shared/ui";import type { MySkinFootprint } from "../../shared/ui";import { IconLight } from "./icons";import "./MySkinLightCard.css";
export interface MySkinLightCardProps { entity: LightEntity; history?: LightHistory; footprint?: MySkinFootprint; defaultOpen?: boolean; onToggle?: () => void;}
const stateWords = (state: string) => state.replace(/[_-]+/g, " ");
/** Neutral My Skin baseline for one light entity. Replace its visual language without changing its domain contract. */export function MySkinLightCard({ entity: e, history, footprint = "standard", defaultOpen = false, onToggle }: MySkinLightCardProps) { const unavailable = e.state === "unavailable"; const summary: ReactNode = e.brightness == null ? stateWords(e.state) : `${e.brightness}%`; const area = "area" in e && typeof e.area === "string" ? e.area : undefined; const lastSeen = "lastSeen" in e && typeof e.lastSeen === "string" ? e.lastSeen : undefined; const actions = ( <> {onToggle && <MySkinAction label="Toggle" pressed={e.state !== "off" && e.state !== "unavailable"} unavailable={unavailable} onPress={onToggle} />} </> ); // Left out: onBrightnessChange, onKelvinChange, onColorChange take a value a press can't give; draw them with this skin's own control. return ( <MySkinCardFrame card="light" name={e.name} area={area} state={e.state} summary={summary} icon={<IconLight />} footprint={footprint} actions={onToggle ? actions : undefined} defaultOpen={defaultOpen}> <dl className="my-skin-light-facts"> <div><dt>State</dt><dd>{stateWords(e.state)}</dd></div> <div><dt>Entity</dt><dd>{e.entityId}</dd></div> {lastSeen && <div><dt>Last seen</dt><dd>{lastSeen}</dd></div>} </dl> {history && <p className="my-skin-light-history">Today’s history is connected and ready for a domain-specific drawing.</p>} </MySkinCardFrame> );}The adapter in ha/ is the one place the card meets Home Assistant: the shared hook above it, passing its model and
actions to the card.
import { useHaLight } from "@dashhaus/core";import type { HaLightOptions } from "@dashhaus/core";import { MySkinLightCard } from "../cards/light/MySkinLightCard";import type { MySkinLightCardProps } from "../cards/light/MySkinLightCard";
export interface HaMySkinLightCardProps extends HaLightOptions, Pick<MySkinLightCardProps, "footprint" | "defaultOpen"> { entityId: string }
/** The shared useHaLight hook above My Skin's presentational Card. */export function HaMySkinLightCard({ entityId, footprint, defaultOpen, ...options }: HaMySkinLightCardProps) { const result = useHaLight(entityId, options); return <MySkinLightCard entity={result.entity} history={result.history} onToggle={result.actions.onToggle} footprint={footprint} defaultOpen={defaultOpen} />;}The shared parts are the card frame, on useCardFrame, detailsSheet and detailsTile from
@dashhaus/dashboard/card, which names each card’s article after its title and opens the details in a CardDialog
(open, close, focus and the page’s scroll lock), and the action, on the usePress primitive (press and hold,
aria-pressed, an unavailable state that stays focusable).
import { useEffect, useRef } from "react";import type { ReactNode } from "react";import { useKeypad, useGuardedAction, usePress } from "@dashhaus/core/primitives";import type { GuardedResult } from "@dashhaus/core/primitives";import { useSettled } from "@dashhaus/core/hooks";import { CardDialog, detailsSheet, detailsTile, useCardFrame } from "@dashhaus/dashboard/card";import type { ActionCardProps, CardSheetProps, TileProps } from "@dashhaus/dashboard/card";import "./ui.css";
export type MySkinFootprint = "compact" | "standard";
export interface MySkinActionProps { label: string; onPress: () => void; pressed?: boolean; unavailable?: boolean;}
/** The generated baseline's latching or one-shot action, on the shared press primitive. */export function MySkinAction({ label, onPress, pressed, unavailable }: MySkinActionProps) { const press = usePress({ onPress, pressed, unavailable }); return <button {...press} className="my-skin-action">{label}</button>;}
/** * An action that never runs on one tap without asking, or that takes a code: a lock's unlock, an alarm's disarm. The * Card draws its button; its answer, the question or the keypad, is in the Card's details, on `useGuardedAction`. */export interface MySkinGuardedAction { /** The action's key: what the details open on. */ key: string; /** Its button's words: "Unlock". */ label: string; /** * It asks first: a press opens the details on the question, or on the keypad when the entity wants a code. Without * it, a press runs it at once, and asks for the code only when the entity wants one. */ asks?: boolean; /** The entity wants a code for it: the keypad, not the question. */ code?: boolean; /** A code with letters as well as digits. */ text?: boolean; run: (code?: string) => GuardedResult;}
/** The question, in the details, for a guarded action that takes no code. Cancel is focused, and the action takes no press for a moment. */export function MySkinConfirm({ question, action, busy, failed, onConfirm, onCancel }: { question: string; action: string; busy?: boolean; failed?: boolean; onConfirm: () => void; onCancel: () => void }) { const settled = useSettled(); const cancel = useRef<HTMLButtonElement>(null); // A dialog opening on the question focuses Cancel through data-autofocus; one already open is focused here. useEffect(() => { if (cancel.current?.closest("dialog")?.open) cancel.current.focus(); }, []); return ( <div className="my-skin-ask" role="group" aria-label={question}> <p className="my-skin-ask-q">{question}</p> <p className="my-skin-ask-d" role="status">{busy ? "Waiting…" : failed ? "Didn’t go through. Try again." : "It asks first, so a stray tap never does it."}</p> <div className="my-skin-ask-b"> <button type="button" className="my-skin-action" ref={cancel} data-autofocus="" onClick={onCancel}>Cancel</button> <button type="button" className="my-skin-action" disabled={busy} onClick={() => { if (settled()) onConfirm(); }}>{action}</button> </div> </div> );}
const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
/** The code keypad, in the details, on `useKeypad`: typing the code is the confirmation. */export function MySkinKeypad({ prompt, text, onSubmit, onCancel }: { prompt: string; text?: boolean; onSubmit: (code: string) => boolean | Promise<boolean>; onCancel: () => void }) { const { code, error, busy, press, back, submit, rootProps } = useKeypad({ text, onSubmit }); return ( <div className="my-skin-ask" {...rootProps} role="group" aria-label={prompt}> <p className="my-skin-ask-q">{prompt}</p> <p className="my-skin-ask-d" role="status">{busy ? "Checking…" : error === "wrong" ? "Wrong code. Try again." : error === "failed" ? "Didn’t go through. Try again." : `${code.length} entered`}</p> <div className="my-skin-keys"> {DIGITS.map((d) => <button key={d} type="button" className="my-skin-action" disabled={busy} onClick={() => press(d)}>{d}</button>)} <button type="button" className="my-skin-action" disabled={busy || !code} onClick={back}>Delete</button> <button type="button" className="my-skin-action" disabled={busy || !code} aria-label="Enter code" onClick={() => void submit()}>Enter</button> </div> <button type="button" className="my-skin-action" onClick={onCancel}>Cancel</button> </div> );}
/** One Card's props: its entity's facts, and the frame's card props. */export interface MySkinCardProps extends Omit<ActionCardProps<string>, "footprint"> { /** Which card this is (its folder's name, e.g. `light`), on the article as `data-card` for styling and tests. */ card?: string; name: string; area?: string; state: string; summary: ReactNode; icon?: ReactNode; footprint?: MySkinFootprint; actions?: ReactNode; /** Actions that ask first or take a code, drawn after `actions` and answered in the details. */ guarded?: MySkinGuardedAction[]; children?: ReactNode;}
/** What a generated Card takes: the Card plus its details, opened from its name. */export type MySkinCardFrameProps = TileProps<MySkinCardProps>;
/** * The Card as it sits on the dashboard, or as the dialog's body while it is in its sheet. The frame names the article * after the Card's title (`frame.article` points at `frame.nameId`), and the title says it opens a dialog. */function MySkinCard({ card, name, area, state, summary, icon, footprint, actions, guarded = [], children, variant, onOpenDetails, onClose, action }: MySkinCardProps) { const frame = useCardFrame({ variant, footprint }); const unavailable = state === "unavailable"; // A guarded action is asked in the details: the Card opens them on it, the details hold its question or keypad. const find = (key: string) => guarded.find((g) => g.key === key); const guard = useGuardedAction<string>({ run: (key, code) => find(key)?.run(code), needsCode: (key) => !!find(key)?.code, settled: () => unavailable, initial: action, askElsewhere: frame.sheet ? undefined : (key) => onOpenDetails?.(key), }); const asking = guard.pending == null ? undefined : find(guard.pending); const buttons = guarded.map((g) => ( <MySkinAction key={g.key} label={g.asks ? `${g.label}…` : g.label} unavailable={unavailable} onPress={() => { if (g.asks) guard.ask(g.key); else void guard.attempt(g.key); }} /> )); if (frame.sheet) { return ( <> <div className="my-skin-dialog-head"> <div><strong>{name}</strong>{area && <span>{area}</span>}</div> <button type="button" data-autofocus onClick={onClose} aria-label={`Close ${name} details`}>Close</button> </div> <div className="my-skin-dialog-body"> <p className="my-skin-dialog-state">{unavailable ? "Unavailable" : state.replace(/[_-]+/g, " ")}</p> {asking ? (guard.code ? <MySkinKeypad key={asking.key} prompt={`Enter the code to ${asking.label.toLowerCase()}`} text={asking.text} onSubmit={guard.submit} onCancel={guard.close} /> : <MySkinConfirm key={asking.key} question={`${asking.label} ${name}?`} action={asking.label} busy={guard.busy} failed={guard.failed} onConfirm={() => void guard.confirm()} onCancel={guard.close} />) : <> {buttons.length > 0 && !unavailable && <div className="my-skin-ask-b">{buttons}</div>} {children ?? <p>This neutral detail surface is ready for the Card's domain-specific design.</p>} </>} </div> </> ); } return ( <article {...frame.article} className="my-skin-card" data-card={card} data-footprint={frame.footprint} data-state={state}> <div className="my-skin-identity" aria-hidden="true">{icon ?? name.slice(0, 1).toUpperCase()}</div> <div className="my-skin-copy"> <button type="button" id={frame.nameId} className="my-skin-title" aria-haspopup="dialog" onClick={() => onOpenDetails?.()}>{name}</button> <p className="my-skin-area">{area ?? "No area"}</p> <p className="my-skin-state">{unavailable ? "Unavailable" : state.replace(/[_-]+/g, " ")}</p> </div> <p className="my-skin-summary">{unavailable ? "—" : summary}</p> {(actions || buttons.length > 0) && <div className="my-skin-actions">{actions}{buttons}</div>} </article> );}
/** The generated skin's details wrapper: the frame's native dialog and the Card's sheet view inside it. */function MySkinSheet({ open, onClose, label, children }: CardSheetProps) { return ( <CardDialog open={open} onClose={onClose} label={label} className="my-skin-dialog" handle=".my-skin-dialog-head" closeMs={240} snapBack="translate 220ms var(--my-skin-ease-out)"> {children} </CardDialog> );}
const MySkinDetails = detailsSheet(MySkinCard, MySkinSheet, (props) => `${props.name} details`);
/** * Neutral Card frame for a generated skin: fixed footprints, explicit unavailable state, and a native details dialog * that opens on a guarded action's question or keypad. * Replace its visual language, not its entity or Home Assistant contracts, when giving the skin its own design. */export const MySkinCardFrame = detailsTile(MySkinCard, MySkinDetails);The Lovelace card
Section titled “The Lovelace card”registry.tsx routes an entity to its card by its Home Assistant domain. config.ts reads the YAML through the shared
Reader from @dashhaus/dashboard/schema, so every mistake is collected with its path and a suggestion, as in Orb
and Field; its card kinds are the registry’s.
import type { ReactNode } from "react";import type { CardKindSpec } from "@dashhaus/dashboard/schema";import type { MySkinFootprint } from "../shared/ui";import { HaMySkinLightCard } from "../ha/HaMySkinLightCard";import { HaMySkinSensorCard } from "../ha/HaMySkinSensorCard";// new-card: registry imports
/** Common YAML facts every generated Card receives. Domain-specific options stay in its Connected adapter. */export interface MySkinCardItem { entity: string; kind: string; name?: string; area?: string; footprint?: MySkinFootprint; history?: boolean; /** The companion entities its hook composes, by YAML key: a sensor's `humidity` and `battery`. */ companions?: Readonly<Record<string, string>>;}
/** `domains`, and `id` to pick this Card before its domain's default without an explicit `card:`. */export interface MySkinCardRegistration extends CardKindSpec { render: (item: MySkinCardItem, footprint: MySkinFootprint) => ReactNode;}
/** A Card's companion entities as YAML keys (`humidity: sensor.bedroom_humidity`), read into `item.companions`. */export const mySkinCompanions = (keys: readonly string[]): Pick<CardKindSpec, "keys" | "options"> => ({ keys, options: (read, raw, at) => { const companions = Object.fromEntries(keys.flatMap((key) => { const id = raw[key] == null ? undefined : read.entityId(raw[key], `${at}.${key}`); return id ? [[key, id]] : []; })); return Object.keys(companions).length ? { companions } : {}; },});
/** The skin's one card seam: the card generator adds an import and one registration here for each domain. */export const MY_SKIN_CARDS = { light: { domains: ["light"], render: (item, footprint) => <HaMySkinLightCard entityId={item.entity} name={item.name} area={item.area} loadHistory={item.history} footprint={footprint} /> }, sensor: { domains: ["sensor"], ...mySkinCompanions(["humidity", "battery"]), render: (item, footprint) => <HaMySkinSensorCard entityId={item.entity} name={item.name} area={item.area} loadHistory={item.history} humidityEntityId={item.companions?.humidity} batteryEntityId={item.companions?.battery} footprint={footprint} /> }, // new-card: registry entries} satisfies Record<string, MySkinCardRegistration>;
export type MySkinCardKind = keyof typeof MY_SKIN_CARDS;const registry = MY_SKIN_CARDS as Record<string, MySkinCardRegistration>;export const mySkinCardKinds = () => Object.keys(registry) as MySkinCardKind[];export const mySkinCardKindsFor = (entity: string) => { const domain = entity.slice(0, entity.indexOf(".")); return mySkinCardKinds().filter((kind) => registry[kind].domains.includes(domain));};export const mySkinKindOf = (entity: string): MySkinCardKind | undefined => { const kinds = mySkinCardKindsFor(entity); return kinds.find((kind) => registry[kind].id?.test(entity)) ?? kinds.find((kind) => !registry[kind].id);};export const renderMySkinCard = (item: MySkinCardItem, footprint: MySkinFootprint): ReactNode => registry[item.kind]?.render(item, item.footprint ?? footprint);import { BODY_KEYS, DashboardConfigError, PLACEMENT_KEYS, Reader, isRaw, readBody, readCard, rowTitles } from "@dashhaus/dashboard/schema";import type { CardKindSpec, CardReadSchema, LayoutSchema, Raw, RowConfig } from "@dashhaus/dashboard/schema";import type { NavEntry } from "@dashhaus/dashboard";import { MY_SKIN_CARDS } from "./registry";import type { MySkinCardItem } from "./registry";import type { MySkinFootprint } from "../shared/ui";
export type CardConfig = MySkinCardItem;/** A row of the page: its heading, its grid and its Cards, placed by the shared layout (`cards`, or `rows` of them). */export type RowLayoutConfig = RowConfig<CardConfig>;export interface PageConfig { id: string; title: string; rows: RowLayoutConfig[] }
export interface MySkinConfig { title?: string; subtitle?: string; showTime: boolean; hourCycle?: "h12" | "h23"; footprint: MySkinFootprint; /** The main page's rows (its `cards` are one untitled row); absent, a row per Home Assistant area. */ rows?: RowLayoutConfig[]; pages?: PageConfig[]; nav?: NavEntry[] | false;}
const TOP_KEYS = ["type", "title", "subtitle", "show_time", "hour_cycle", "footprint", ...BODY_KEYS, "pages", "nav", "view_layout", "grid_options", "visibility"];const CARD_KEYS = ["entity", "card", "name", "area", "footprint", ...PLACEMENT_KEYS, "history"];const FOOTPRINTS: readonly MySkinFootprint[] = ["standard", "compact"];/** The most columns a row may have. */const MAX_ACROSS = 4;
/** The generated skin's one Card schema: its registry supplies the kinds, the package reads the rest. */const CARD_SCHEMA: CardReadSchema<string, CardConfig> = { noun: "Card", common: CARD_KEYS, kinds: MY_SKIN_CARDS as unknown as Record<string, CardKindSpec>, base: (read, raw, at) => ({ name: read.string(raw, "name", at), area: read.string(raw, "area", at), footprint: read.choice(raw, "footprint", at, FOOTPRINTS), history: read.boolean(raw, "history", at), }), noKind: (entity) => `${entity}: its domain has no My Skin Card yet`,};
/** Parse and normalize `custom:dashhaus-my-skin` YAML, collecting every problem. */export function parseMySkinConfig(value: unknown): MySkinConfig { const reader = new Reader(); const raw: Raw = isRaw(value) ? value : {}; if (!isRaw(value)) reader.problem("", "the Lovelace card config must be a map"); reader.keys(raw, "", TOP_KEYS); const footprint = reader.choice(raw, "footprint", "", FOOTPRINTS) ?? "standard"; // One footprint per row: this skin's grid rows are one Card tall. const layout: LayoutSchema<CardConfig> = { noun: "Card", maxColumns: MAX_ACROSS, item: (read, item, path) => readCard(read, CARD_SCHEMA, item, path), footprints: { mix: "uniform", of: (card) => card.footprint ?? footprint }, }; const rows = readBody(reader, layout, raw); const pages = reader.pages(raw, (body, path) => readBody(reader, layout, body, path))?.map(({ id, title, body }) => ({ id, title, rows: body })); const config: MySkinConfig = { title: reader.string(raw, "title", ""), subtitle: reader.string(raw, "subtitle", ""), showTime: reader.boolean(raw, "show_time", "") ?? false, hourCycle: reader.choice(raw, "hour_cycle", "", ["h12", "h23"] as const), footprint, rows, pages, nav: reader.nav(raw, pages ?? [], rows && rowTitles(rows)), }; if (reader.problems.length) throw new DashboardConfigError(reader.problems); return config;}card.tsx is the element, on LovelaceDashboardElement from @dashhaus/dashboard/skin: it builds the store on Home
Assistant’s own connection, puts the CSS in its shadow root and draws the page. paintHost puts Home Assistant’s dark
mode on the host as data-my-skin-theme, the attribute styles/tokens.css reads: the skin’s own, so another skin on
the same page never re-colours yours. The skin’s web font is MY_SKIN_FONT_HREF in shared/surface.ts. The page ends
with MySkinActionStatus, the row that says when Home Assistant refused a command, with Retry and Dismiss. Without
cards in the YAML, auto.ts lays out every entity a card is registered for, by area, with
@dashhaus/dashboard/layout.
import { LovelaceDashboardElement, autoInput, registerCard, scopeToHost } from "@dashhaus/dashboard/skin";import type { LovelaceHass } from "@dashhaus/dashboard/skin";import { mapRowItems } from "@dashhaus/dashboard/schema";import { parseMySkinConfig } from "./config";import type { MySkinConfig, CardConfig, PageConfig, RowLayoutConfig } from "./config";import { areaName, areaRows, autoCards } from "./auto";import { MySkinDashboard } from "./MySkinDashboard";import { MY_SKIN_FONT_HREF, MY_SKIN_THEME_ATTRIBUTE } from "../shared/surface";
export const MY_SKIN_CARD_TYPE = "dashhaus-my-skin";const HOST_CSS = `:host { display: block; height: 100%; }.my-skin-surface { min-height: calc(100dvh - var(--header-height, 56px)); }.hd-config-error { margin: 16px; padding: 16px; border: 1px solid var(--my-skin-line); border-radius: 12px; background: var(--my-skin-card); color: var(--my-skin-ink); font: 500 13px/1.5 var(--my-skin-sans); white-space: pre-wrap; }`;
/** `type: custom:dashhaus-my-skin` on Home Assistant's own authenticated frontend connection. */export function createMySkinCard(css: string) { const styles = scopeToHost(css) + HOST_CSS; return class DashhausMySkinCard extends LovelaceDashboardElement<MySkinConfig> { protected readonly styles = styles; protected readonly mountClass = "my-skin-surface"; protected readonly fontHref = MY_SKIN_FONT_HREF; #layout?: { key: unknown[]; rows: RowLayoutConfig[]; pages?: PageConfig[] };
static getStubConfig() { return { type: `custom:${MY_SKIN_CARD_TYPE}` }; } protected parse(raw: unknown) { return parseMySkinConfig(raw); } protected changed(previous: LovelaceHass, next: LovelaceHass) { return previous.themes?.darkMode !== next.themes?.darkMode || previous.panels !== next.panels || this.#stale(next); } /** Home Assistant's dark mode, on the skin's own theme attribute (tokens.css). */ protected paintHost(_config: MySkinConfig, hass: LovelaceHass) { this.setAttribute(MY_SKIN_THEME_ATTRIBUTE, hass.themes?.darkMode ? "dark" : "light"); } protected draw(config: MySkinConfig, hass: LovelaceHass) { const layout = this.#layoutFor(config, hass); return <MySkinDashboard title={config.title} subtitle={config.subtitle} showTime={config.showTime} hourCycle={config.hourCycle} rows={layout.rows} pages={layout.pages} nav={config.nav} footprint={config.footprint} panels={hass.panels} />; } #key(hass: LovelaceHass): unknown[] { return [this.config, hass.entities, hass.devices, hass.areas, Object.keys(hass.states).length]; } #stale(hass: LovelaceHass) { const next = this.#key(hass); return !this.#layout || next.some((value, index) => value !== this.#layout!.key[index]); } #layoutFor(config: MySkinConfig, hass: LovelaceHass) { const key = this.#key(hass); if (this.#layout && !key.some((value, index) => value !== this.#layout!.key[index])) return this.#layout; const input = autoInput(hass); const place = (card: CardConfig): CardConfig => ({ ...card, area: card.area ?? areaName(input, card.entity) }); const rows = config.rows ? mapRowItems(config.rows, place) : areaRows(autoCards(input).map(place)); const pages = config.pages?.map((page) => ({ ...page, rows: mapRowItems(page.rows, place) })); return this.#layout = { key, rows, pages }; } };}
export function defineMySkinCard(css: string) { registerCard(MY_SKIN_CARD_TYPE, createMySkinCard(css), "Dashhaus My Skin", "The My Skin dashboard. Without cards or rows, it groups every entity it can draw by area.");}Add a domain
Section titled “Add a domain”A cover, in one command. Any entity on the Entities pages goes the same way.
npm run add -- coverIt writes the card in cards/cover/ with its stylesheet, glyph and story metadata, and its adapter on useHaCover.
A cover is a control, so its actions are its buttons; --kind reading draws a domain without them. An action that
asks first, as a lock’s unlock does, is never one tap: npm run add -- lock draws its button to open the details on
the question, or on the code keypad when the lock wants a code. An action that takes a value, a brightness or an
alarm’s mode, is left out for your own control, and the command says so.
import type { ReactNode } from "react";import type { CoverEntity, CoverHistory } from "@dashhaus/core/entities/cover/types";import { MySkinAction, MySkinCardFrame } from "../../shared/ui";import type { MySkinFootprint } from "../../shared/ui";import { IconCover } from "./icons";import "./MySkinCoverCard.css";
export interface MySkinCoverCardProps { entity: CoverEntity; history?: CoverHistory; footprint?: MySkinFootprint; defaultOpen?: boolean; onOpenCover?: () => void; onCloseCover?: () => void; onStopCover?: () => void;}
const stateWords = (state: string) => state.replace(/[_-]+/g, " ");
/** Neutral My Skin baseline for one cover entity. Replace its visual language without changing its domain contract. */export function MySkinCoverCard({ entity: e, history, footprint = "standard", defaultOpen = false, onOpenCover, onCloseCover, onStopCover }: MySkinCoverCardProps) { const unavailable = e.state === "unavailable"; const summary: ReactNode = e.position == null ? stateWords(e.state) : `${e.position}%`; const area = "area" in e && typeof e.area === "string" ? e.area : undefined; const lastSeen = "lastSeen" in e && typeof e.lastSeen === "string" ? e.lastSeen : undefined; const actions = ( <> {onOpenCover && <MySkinAction label="Open" unavailable={unavailable} onPress={onOpenCover} />} {onCloseCover && <MySkinAction label="Close" unavailable={unavailable} onPress={onCloseCover} />} {onStopCover && <MySkinAction label="Stop" unavailable={unavailable} onPress={onStopCover} />} </> ); // Left out: onSetPosition, onSetTilt take a value a press can't give; draw them with this skin's own control. return ( <MySkinCardFrame card="cover" name={e.name} area={area} state={e.state} summary={summary} icon={<IconCover />} footprint={footprint} actions={onOpenCover || onCloseCover || onStopCover ? actions : undefined} defaultOpen={defaultOpen}> <dl className="my-skin-cover-facts"> <div><dt>State</dt><dd>{stateWords(e.state)}</dd></div> <div><dt>Entity</dt><dd>{e.entityId}</dd></div> {lastSeen && <div><dt>Last seen</dt><dd>{lastSeen}</dd></div>} </dl> {history && <p className="my-skin-cover-history">Today’s history is connected and ready for a domain-specific drawing.</p>} </MySkinCardFrame> );}It registers the card for Home Assistant’s cover domain, so the YAML and the automatic layout pick it up, and the
preview already has a cover on its demo house.
import type { ReactNode } from "react";import type { CardKindSpec } from "@dashhaus/dashboard/schema";import type { MySkinFootprint } from "../shared/ui";import { HaMySkinLightCard } from "../ha/HaMySkinLightCard";import { HaMySkinSensorCard } from "../ha/HaMySkinSensorCard";import { HaMySkinCoverCard } from "../ha/HaMySkinCoverCard";// new-card: registry imports
/** Common YAML facts every generated Card receives. Domain-specific options stay in its Connected adapter. */export interface MySkinCardItem { entity: string; kind: string; name?: string; area?: string; footprint?: MySkinFootprint; history?: boolean; /** The companion entities its hook composes, by YAML key: a sensor's `humidity` and `battery`. */ companions?: Readonly<Record<string, string>>;}
/** `domains`, and `id` to pick this Card before its domain's default without an explicit `card:`. */export interface MySkinCardRegistration extends CardKindSpec { render: (item: MySkinCardItem, footprint: MySkinFootprint) => ReactNode;}
/** A Card's companion entities as YAML keys (`humidity: sensor.bedroom_humidity`), read into `item.companions`. */export const mySkinCompanions = (keys: readonly string[]): Pick<CardKindSpec, "keys" | "options"> => ({ keys, options: (read, raw, at) => { const companions = Object.fromEntries(keys.flatMap((key) => { const id = raw[key] == null ? undefined : read.entityId(raw[key], `${at}.${key}`); return id ? [[key, id]] : []; })); return Object.keys(companions).length ? { companions } : {}; },});
/** The skin's one card seam: the card generator adds an import and one registration here for each domain. */export const MY_SKIN_CARDS = { light: { domains: ["light"], render: (item, footprint) => <HaMySkinLightCard entityId={item.entity} name={item.name} area={item.area} loadHistory={item.history} footprint={footprint} /> }, sensor: { domains: ["sensor"], ...mySkinCompanions(["humidity", "battery"]), render: (item, footprint) => <HaMySkinSensorCard entityId={item.entity} name={item.name} area={item.area} loadHistory={item.history} humidityEntityId={item.companions?.humidity} batteryEntityId={item.companions?.battery} footprint={footprint} /> }, cover: { domains: ["cover"], render: (item, footprint) => <HaMySkinCoverCard entityId={item.entity} name={item.name} area={item.area} loadHistory={item.history} footprint={footprint} /> }, // new-card: registry entries} satisfies Record<string, MySkinCardRegistration>;
export type MySkinCardKind = keyof typeof MY_SKIN_CARDS;const registry = MY_SKIN_CARDS as Record<string, MySkinCardRegistration>;export const mySkinCardKinds = () => Object.keys(registry) as MySkinCardKind[];export const mySkinCardKindsFor = (entity: string) => { const domain = entity.slice(0, entity.indexOf(".")); return mySkinCardKinds().filter((kind) => registry[kind].domains.includes(domain));};export const mySkinKindOf = (entity: string): MySkinCardKind | undefined => { const kinds = mySkinCardKindsFor(entity); return kinds.find((kind) => registry[kind].id?.test(entity)) ?? kinds.find((kind) => !registry[kind].id);};export const renderMySkinCard = (item: MySkinCardItem, footprint: MySkinFootprint): ReactNode => registry[item.kind]?.render(item, item.footprint ?? footprint);Then npm test, npm run snapshot, and give it its own drawing. npm run build and npm run deploy put it in Home
Assistant.
npm run add draws alarm, climate, cover, fan, guestWifi, humidifier, light, lock, owlet, sensor, sound, toggle and weather. It is create-dashhaus-skin add, from the skin’s own copy of the
scaffolder; --help prints the same as this:
| Argument or option | What it does |
|---|---|
<directory> | a new, empty folder, or "." for the current one; its name is the skin's name |
--tarballs <dir> | install @dashhaus packages from the release tarballs in <dir>, not the registry |
add <domain> | run inside a skin: add the card for one more domain, registered and ready to restyle |
--kind | a control (its actions as buttons) or a reading (none); the domain's own kind by default |
--dry-run | print the files that would be written |
--help | this message |
The rules a skin keeps
Section titled “The rules a skin keeps”- One card, one model. A card draws one entity’s model, or that entity plus the companions its hook composes (a temperature sensor with the humidity sensor on its device), handed to it by its adapter, never from Home Assistant.
- A card may import
@dashhaus/core’s entity models, primitives and formatters, and@dashhaus/dashboard/card; never@dashhaus/coreitself,@dashhaus/core/websocket, its demo or testing subpaths, or@dashhaus/dashboard.npm testenforces it. - Every colour comes from
styles/tokens.css, under the skin’s prefix, so another skin on the same page can’t collide with it. - Dark mode keys on the skin’s own attribute,
data-my-skin-theme, never the shareddata-themeanother skin on the page may set.npm testenforces it. - A skin never imports another skin.
The scaffold’s AGENTS.md repeats these beside the files they govern, with the rest of the look’s rules.
Packages lists what each @dashhaus/dashboard subpath promises a skin.
For a skin drawn end to end with the generators, to take ideas from or build on, see Studies.
Orb and Field beside your skin
Section titled “Orb and Field beside your skin”Your skin’s bundle carries your skin only. To offer Orb and Field on the same Home Assistant, install
@dashhaus/bundle too and deploy it as on Deploy from the command line: it uploads to its own
folder, so the two bundles don’t overwrite each other.