Skip to content

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.

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.

  • 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.

  1. 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.

  2. 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.
  3. 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.

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 Dashhaus and 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.

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.

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.

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.

  1. 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-skin
    cd my-skin
    npm install

    From 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-skin
    npm install

    --tarballs points the @dashhaus dependencies at the tarballs instead of the registry; --dry-run lists the files instead of writing them. npm install brings @dashhaus/core and @dashhaus/dashboard, React and home-assistant-js-websocket as peers, and TypeScript, Vite and @dashhaus/deploy for the checks, the build and the deploy.

  2. 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 prints
    npm 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.

  3. Check the cards:

    Terminal window
    npm test

    It typechecks the skin, then runs the conformance check from @dashhaus/dashboard/conformance over cards/ 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, :hover sits 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
  4. Build one file:

    Terminal window
    npm run build

    dist/dashhaus-my-skin.js is 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.

  5. Deploy it. Copy the scaffold’s .env.example to .env and fill in HA_URL, HA_TOKEN and HA_WWW as Deploy from the command line describes, then:

    Terminal window
    npm run deploy

    It 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_WWW shows up here instead of as a blank dashboard. npx dashhaus-deploy --help prints the options, a config to start from and the .env keys.

    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" };
    },
    },
    ],
    };
  • 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 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.

cards/light/MySkinLightCard.tsx
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.

ha/HaMySkinLightCard.tsx
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).

shared/ui.tsx
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);

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.

lovelace/registry.tsx
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);

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.

lovelace/card.tsx
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.");
}

A cover, in one command. Any entity on the Entities pages goes the same way.

Terminal window
npm run add -- cover

It 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.

cards/cover/MySkinCoverCard.tsx
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.

lovelace/registry.tsx
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 optionWhat 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
--kinda control (its actions as buttons) or a reading (none); the domain's own kind by default
--dry-runprint the files that would be written
--helpthis message
  • 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/core itself, @dashhaus/core/websocket, its demo or testing subpaths, or @dashhaus/dashboard. npm test enforces 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 shared data-theme another skin on the page may set. npm test enforces 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.

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.