Skip to content

Architecture

github-actions[bot] edited this page Oct 13, 2026 · 18 revisions

This page is made from docs/architecture.md on every change to main; edit it there.

Architecture

How MNML is put together: a Home Assistant integration that serves the cards, their editors and their templates and installs the theme, all built from this repository and released as one mnml.zip. For how the cards behave, see How the cards work; for how they look, Design; for the theme, The theme.

The parts

Path What
src/contract/ The contract (cards.ts, entities.ts, templates.ts) and what the cards and their users both read
src/cards/ The cards, their base class, the known-key schemas, the registration and the loader; parts/ holds what several cards share, and the pieces a large card's file is split into
src/ha/ Home Assistant in the browser: the typed hass, formatting, names, rules, conditions, navigation, motion, and the template parts that draw Home Assistant's own elements
src/store/ The integration's template store in the browser, and the shipped families fetched on demand
src/editors/ The card editors and their description vocabulary
src/panel/ The MNML panel
src/templates/ The template engine, discovery, the shipped templates' loader, the dashboard expander
templates/ The shipped templates, one YAML file per family, and shapes.yaml, the slot shapes they share
src/home/ The home generator: Home and its parts, build(), drawn() and problems()
src/builder/ The dashboard builder: a plan's defaults from the home's registries, a person's slots, and the dashboard a plan makes
demo/ The demo home, Joe's and Jane's, its YAML, and the throwaway instance's entities for it
src/build/ buildBundle(), and the command line that runs it
src/theme/ The theme's model, its mapping onto Home Assistant's variables, its contrast claims, its YAML
src/test/ The jsdom helpers and the fake Home Assistant connection the element tests use
tools/ The throwaway Home Assistant, its look, walk and screenshot passes, the discovery corpus's snapshot of it (corpus.ts, snapshot.ts), the integration's icons, the wiki's pages, the release zip, and the interface's part of the release notes (notes.ts)
custom_components/mnml/ The integration, and in www/ (built, git-ignored) everything it serves
tests/ The integration's tests

src/contract/: what the cards and their users both read

File Holds
entities.ts The id types: EntityId<Domain> and one alias per domain (LightId, SensorId, ...). Also MdiIcon, PopupHash, Subnet, ServiceName (a closed union of the services the cards call) and Corners<T> (front left, front right, rear left, rear right)
builder.ts Plan, what the panel's builder keeps for a dashboard it made: its title and icon, its rooms by area, its people by person entity, its cars by key, its system templates, each with the slots it overrides, how its pop-ups open, and optionally each section's look (SectionLook: a title, an icon, and but for System a template; lookOf() gives a section's look with the defaults, SECTION_LOOKS), and optionally the sections' order, each once (orderOf() puts any left out after them, in SECTION_ORDER); optionally the Glances strip (glances, discovered, with overrides, or spelled); a room, person or system choice may be spelled, its slots alone with nothing discovered; isPlan(), which refuses an unknown key at every level, isPersonId(), and SYSTEM_TEMPLATES, the templates the builder offers for the System section. tests/plans.json holds example plans, valid and not, that isPlan() and the integration's schema both judge the same way
cards.ts The configuration of every card: the public interface of MNML; see below
structure.ts Every card's structure and every part's, once: CARD_STRUCTURES, by card type, and the parts (STATE_ITEM, CONTROL, LIST_ROW and the parts only a card holds). A structure names its keys in the order a form places them, each with its kind (text, texts, number, share, flag, a choice of values, entity and entities with their domains, icon, popup, service, color, words, corners, a rule, summary, a part or parts of another structure, and the cards and slots of the cards without a form), whether it is required, and its default where a form needs it. A part that comes in kinds is tagged() by its type, or marked() by a marker key, with a last kind for a value with no marker. plain<T>(), tagged<T>() and marked<T>() hold each structure to its interface in cards.ts: a key missing or extra, or required where the interface makes it optional, does not compile. chooseVariant() is the one choice of a kind for a value, for the cards' key check and for the editors. A structure holds no label: the cards derive their known keys from it (src/cards/keys.ts), and the editors their forms, with the labels of src/editors/labels.ts
interface.ts surface(), MNML's public interface as plain sorted JSON, of the record format FORMAT (1), which it writes first, as format. It holds every card type and its keys, from CARD_STRUCTURES (cardPaths()): each key by its path, with its kind, ! when it is required, its values in parentheses (a choice's or words's options, or what a flag writes when it is not true, flag(device)) and = its default where the structure gives one (text = "data"). A part's keys follow its key after a . (fields.name), a list's after [] (rows[].entity), and each kind of a part that comes in kinds is a path of its own, tagged or marked, its name in angle brackets (controls[]<indicator>, then controls[]<indicator>.color; <otherwise> for the kind with no marker; a header's <name>.icon); a card's type is the card's name in the record. It holds every shipped template and its slots (each its kind, with ! when it is required, an object's fields as dotted paths, lights.group), every slot shape of templates/shapes.yaml, resolved, as dotted paths with kinds (meter, meter.power), the theme's name and the mnml-* variables it writes, the integration's domain, its options (the CONF_* of const.py) and its websocket commands (the type of each in websocket.py, read by integrationOf(), which fails when it reads fewer than websocket.py has @websocket_api.websocket_command decorators), and the keys of Home, Room, Person, Car, System and SectionTitles (the key tables of src/home/types.ts). interface.json, at the root, is the interface as last recorded: interface.test.ts fails while the two differ, naming each entry removed, changed (a key's or a slot's kind, required flag, values or default) or added, until pnpm run fix:interface records it. compare() makes that diff between two records, and notes() writes it as the Markdown of the release notes from the record a tag holds; readSurface() reads a record, and gives nothing for one of another format, which notes() then says to compare by hand
interface-main.ts pnpm run fix:interface: writes interface.json from surface(), formatted by oxfmt. It is deliberately not part of pnpm fix, so that a change to the interface is recorded on purpose
subnet.ts ipv4() and subnetRange(), by which the clients card groups and validates
templates.ts The template format's types (Template, with its retired slot paths, SlotSpec, Rule, Kind, Value) with isTemplate(), isMapping() and isStorable(), the shape the integration stores (a mapping card, popups a list of mappings); see the template engine
yaml.ts toYaml(), the YAML writer: YAML 1.1, with every string Home Assistant's loader or the panel would read as something else quoted (The YAML writer)

The contract, cards.ts

  • Cards: TileCard, EntityCard, HeaderCard, HeadingCard, ListCard (with ListRow), AgendaCard (with AgendaSource), MessagesCard, ClientsCard (with ClientNetwork and ClientFields), ReportCard, WeatherCard, GlancesCard (with Glance), ButtonCard, SelectCard, SliderCard, MediaCard, CarPlanCard, PopupsCard (with Popup and LovelaceCardConfig, any card a pop-up holds) and TemplateCard.
  • A tile's or header's subject is one of two variants: a literal name and icon, with an optional entity; or an entity whose name (its device's, with label) and icon the card shows, where an icon may still override the entity's.
  • Item: an entity with its name, state line, popup and controls. EntityCard is an Item with a type and optional nested items: the rows its expander reveals. TileCard.item is an Item too.
  • Controls: toggle, slider, select, service, nav, indicator, status, tyres, scenes.
  • StateItem: one entry of a state line. It is one of seven variants: an entity's state, an attribute, a literal text, the minutes of a seconds counter, a timestamp as a relative time, the serial number of the entity's device, or the entity's name. icon: true puts the state icon in front.
  • Rules: StateRule (is / not); EntityRule, which adds the entity it reads; and a status control's rules, which add the entities they watch, how the tooltip names and words them (label, words), and the color they give.
  • Condition: Lovelace's state condition, as state_not on an entity: the one form a card's visibility takes inside a pop-up, which the pop-up shell evaluates.

A card reads nothing the types don't declare, and a key it does not declare is an error (src/cards/keys.ts, below). Leave an optional key out to get its default, so the YAML reads as intent: state defaults to the entity's own state line on an entity card, and when to { is: ['on'] } on a coloured control, pill or header. A list row is the exception: without when it is coloured whenever it is shown, so a flag's condition lives in its show, and a coloured value row spells its when.

A threshold that is a share of something says so in its name. low_share and warn_share on the tyres control, and tyre_low_share and tyre_warn_share on the car plan, are ratios of a tyre's target (0.9, 0.95). A list row's low and critical are absolute values in the entity's unit (20, 5 for a battery's percentage).

cards.ts is part of the public interface that interface.json records: a new key or card is a minor release; while MNML is 0.x a minor release may also rename or remove one, or change its meaning, with a line in the release notes on how to migrate (Releases), and from 1.0 that is a major release.

src/cards/: the cards

  • register.ts is the bundle's entry. It defines twenty elements, the seventeen cards, the pop-up shell, mnml-slider (the slider of the slider card and of a slider control's overlay) and mnml-tooltip, and lists the cards and the shell in window.customCards. Each of those gets its getStubConfig() from stubs.ts, and each but the pop-ups card its getConfigElement() from edit.ts. It skips a tag that is already defined, so a second copy of the bundle on one dashboard defines nothing and throws nothing.
  • One file per card, named after it in kebab-case; parts/ holds what several cards share, and the pieces a large card's file is split into.
  • keys.ts and schemas.ts: a card's known keys are a KeySchema, which keysOf() derives from a structure of src/contract/structure.ts, a key that holds a part, a list of parts or a rule with the schema of that structure nested, and cardKeysOf() with the card's type too. schemas.ts holds them all: one constant per card (HEADING_CARD, TILE_CARD, ...) that the card imports, and the parts' STATE_ITEMS, CONTROLS and LIST_ROW. The structures are typed against cards.ts, so the schemas cannot drift from it. A union is checked against the one kind a value picks (chooseVariant()), which names only the keys that kind allows (not those it spells never): by its type for a control, and by a marker key for a state item, a glance, the select card, a header's or tile's subject and a message source. So a toggle with a slider, or a text with an attribute, is an unknown key too. MnmlCard.setConfig() checks a configuration against its card's schema before anything else, at every depth the schema describes, and allows the layout keys Home Assistant and card-mod add (grid_options, visibility, view_layout, layout_options, card_mod) at the top.
Module Holds
base.ts MnmlCard<C>, the base class of every card but the pop-up shell and the template card: the key check, configuration, updates only for what the configuration names, holds and teardown, notify(), and the default grid size; Host, what a part a card draws is given; requireString(), requireNumber(), requireList(), requireOneOf() for a card's validate(). See How the cards work
styles.ts The shared styles, as css values: MOTION_STYLE, BASE_STYLE (its :host holds the shared colours as custom properties, --m-pill, --m-hover, --m-edge, --m-edge-strong), HEADING_STYLE, ROW_STYLE, CONTROL_STYLE
edit.ts editorOf(tag), each card's getConfigElement(): it imports the editors' file by the URL src/editors/where.ts gives, and after a failed import asks for it again under a new URL (?attempt=<n>), since a browser keeps a failed dynamic import for the page
stubs.ts STUBS, each card's getStubConfig(), with a fitting entity of the home where the card takes one
loader.ts, started.ts, iconset.ts The loader, its own entry, and the mnml icon set it registers; see Why a loader
template.ts MnmlTemplateCard, a template expanded in place (Templates)
popups.ts MnmlPopupsCard, the pop-up shell (How the cards work)
parts/popup-unfold.ts The unfold geometry: placed(), folded(), foldTo(), unfolded(), settled(), reveal(), and following(), the per-frame loop that keeps an unfolded pop-up on its origin
parts/popup-swipe.ts swipeToClose(), the swipe down that closes a pop-up, on its panel in every opening
parts/item.ts itemRow(), an item's row; itemLine(), the same item minified into one line, as a tile draws it; and linkTo(), a way into a pop-up, with ITEM_STYLE; used by the entity, tile, header and media cards
parts/section.ts heading() and section(), with SECTION_STYLE. The list, agenda, clients and car plan cards use section() so that their title stands above their surface, like a heading card above a card
parts/controls.ts A lane of controls: renderControls(), controlsOf(), and requireControls() for a card's validate()
parts/slider.ts The slider: MnmlSlider (mnml-slider), openSliderOverlay(), OVERLAY_STYLE
parts/slider-spec.ts sliderSpec() per SliderKind: the range, the value, the commit and the gradient of each kind, as SliderSpec
parts/menu.ts The dropdown: modeChoice(), optionChoice(), sceneChoice(), openMenu(), MENU_STYLE
parts/overlay.ts dismissable(), which holds a card while its menu or slider overlay is open, and closes it on a tap outside or on Escape
parts/tooltip.ts MnmlTooltip (mnml-tooltip), the one tooltip of the cards, and the tip() directive that puts it on an element, in place of the browser's title
parts/popup-registry.ts announce(), withdraw(), announced(), onAnnounce(): the pop-ups template cards give the shell; checkPopups()

src/ha/ and src/store/: Home Assistant in the browser

Module Holds
ha/hass.ts The typed subset of hass the cards use (a device's registry entry with its maker, model, entry_type and via_device_id, which discovery reads), state readers (stateOf, hasValue, isUnavailable, isOn, numericState, ...) and callService(), toggleEntity(), run() and settled(), which turn a failed call into Home Assistant's toast; callApi() is the one REST call, the agenda's calendar events; connection reads and follows the store
ha/registries.ts registriesOf(), the areas, devices, entities and states of a hass, what discovery reads
ha/templates.ts The template parts: icon(), stateIcon(), attributeIcon() and relativeTime(), Home Assistant's own elements; colorVar() and colorStyle(), a Color as --m-color; onPress(), Enter and Space as a press; quietly(), a handler that stops its event
ha/rules.ts normalise(), matches(), shown(), active(), tint(), tyreBand(), and UNAVAILABLE, the colour of an offline entity
ha/names.ts entityName(), nameOf(), cardName() (a card's name, else its entity's), deviceSerial()
ha/format.ts formatState(), formatAttribute(), stateLine(), and unavailableValue(), the orange dash
ha/conditions.ts visible(), the state conditions a pop-up's cards and a template card's drawn card carry, and isCondition()
ha/navigation.ts navigate(), closePopup(), back(), prebuild()
ha/motion.ts reduced(), narrow(), leave()
ha/card-helpers.ts ChildCard, a card Home Assistant's loadCardHelpers().createCardElement() makes, and the type of window.loadCardHelpers
ha/field.ts field(), one key of a value of unknown shape, without a cast
store/store.ts The integration's store in the browser: sharedTemplates(connection) subscribes to mnml/templates/subscribe once per connection, without the library's automatic resubscribe, and again after each reconnect, trying again every 5 seconds for 5 minutes while the integration loads (an unknown command on a first subscription, or a store the integration could not read, is an empty store); an entry with a change of an unknown shape is left out whole. Its ready state holds the store's own templates, with the shipped shapes they name resolved (one that names an unknown shape is kept as written, and reported on the console), and its changes. resolvedTemplates() gives the order a card, its editor and the panel read templates in: the home's own, a shipped one with its changes, the shipped. knownShared() and onShared(). Its ready state holds the home's own templates with the shapes they name resolved (one that cannot be resolved, for any reason, is kept as written and reported on the console, so only the cards that draw it fail), written, the own templates as stored, for the panel, and the changes
store/shipped.ts The shipped templates in the browser: createShipped(load, owner, common), and SHIPPED_TEMPLATES, the page's own: templates() (common and what is loaded), later(), need(names) (each family fetched once, in parallel) and failure(names); a family that is missing (404, or 410) stays failed for the page, and one that failed otherwise may be fetched again after 30 seconds

src/editors/: the card editors

The card editors are a file of their own, loaded the first time a card is edited (Editing in the UI). Editors and panel parts are LitElements; the rest draws html templates.

Module Holds
main.ts The editors' entry: it loads ha-form, then defines mnml-card-editor, mnml-template-card-editor, mnml-template-gallery, mnml-row-menu, mnml-words and mnml-related
shape.ts The vocabulary of a form: Field, PlainShape (its fields, sections, labels and help) and ChoiceShape (its kinds), with checkValue(), cleanValue(), place(), switchVariant(), variantOf() and starter()
cards.ts describer(), which makes the description of a form (a Shape of shape.ts) from a structure of src/contract/structure.ts and a table of labels: a field of each key, of the key's kind, a colour or service field with its list, a part with a description of its own; DESCRIPTIONS, every card with a form, which is every card but the pop-ups and template cards
labels.ts LABELS, by the name of each structure the editors draw: the label of the card or part and of each of its kinds, the label of a key whose humanised name reads badly, their help under the field, and its sections, the panels of its form (one, under its label, when it gives none)
lists.ts The choices of a field the structure leaves to the editors: the services, held to ServiceName, the corner names, and the contract's colours
form.ts Home Assistant's form and back: schemaOf(), formData(), fromForm(), which writes only the fields the form changed, slotsFromForm(), which writes null for an emptied slot that has a default
draw.ts The drawing: objectForm(), a part or card in its sections or whole, with the kind switch; keysForm(), its keys, the simple ones in one form and each list or part in its own; formOf(), the simple keys in one form, with an optional helper line and reader, which the template card's slots use; adder(), an Add button
ha-form.ts haForm(), Home Assistant's ha-form, and loadHaForm(), which asks a built-in card for its editor so that ha-form is defined
panel.ts sectionPanel(), a section as Home Assistant's expansion panel or a look-alike, drawn when first opened
row-menu.ts MnmlRowMenu (mnml-row-menu), a part's three-dot menu, and rowMenu(), which draws one
words.ts MnmlWords (mnml-words), the pairs of a state and its word, or of a state and its colour, which keeps the boxes as typed while two states clash
related.ts MnmlRelated (mnml-related), around the fields of a template card's object slot: given a device (Related: the device's own entities, its via_device relatives and its area, the three tiers the pickers rank by), it answers the context-request of Home Assistant's entity pickers inside it for their related context, the one Home Assistant's frontend orders a picker's entities by, and tells them again when the device changes, a value holding the same ids being no change; without a device, and for any other context, the request goes on to Home Assistant
summary.ts summarize(), a panel's summary; one part counts as "1 control", by singular()
icons.ts, clipboard.ts, style.ts A panel's icon by its title; the part last copied, in the browser's storage; EDITOR_STYLE
card-editor.ts MnmlCardEditor, the editor of every card with keys
template-card.ts MnmlTemplateCardEditor, the template card's editor: where the values come from (an area, the whole home or the card) and the slot forms, the ties of a slot's own rule, and for an object slot filled from a device the device, Use another (fromDevice()) and its fields inside mnml-related; it keeps the gallery cached across Change
gallery.ts MnmlTemplateGallery (mnml-template-gallery), the gallery: the filter, the fold, the home's own templates first, and inert live previews drawn as they scroll into view, each given every new hass; it fires template-picked, and holds its own styles
fill.ts Where a template card's values come from (an area, the whole home or the card), its notes ("Found in Living", "Found: Roborock S8 in Living", "Not set"), deviceIn(), the device of those that fit that holds most of a value's entities, and its panels
slots.ts A template's slots as a shape, and its preview
leave.ts openPanel(), which closes Home Assistant's card dialog, asking first when the card has changes, and opens the MNML panel
where.ts EDITORS, the URL of the editors' file, which the build fills in

src/panel/: the MNML panel

The panel is a file of its own, mnml-cards-panel.js, which the integration registers (The MNML panel).

Module Holds
main.ts MnmlPanel (mnml-panel): the routing, the state, the websocket reads and the page: the toolbar and the Dashboards and Templates tabs, then the dashboards (at /mnml and /mnml/dashboards), a dashboard's builder, the library (at /mnml/templates) or a template's builder; PAGE_STYLE
library-actions.ts The library's flows over a LibraryHost (call, the dialogs, the state, refresh, save, download): new, duplicate, history, export, import (a file, or the dashboards' templates), delete or reset, and rename with its references (drawFates(), saveTemplate())
dashboard-actions.ts The dashboards' flows over a DashboardHost (the library's host, go and visit): quick start, create or rebuild, undo, forget, share, from a template, and the offer to open
message.ts messageOf(), an error as a sentence
building.ts The dashboards MNML built and Home Assistant's own, read (builtOf(), boardsOf()); a new dashboard's address (addressFor()); and each change as the messages it sends, in order: makeDashboard() (keep the plan, create, save the cards; a create Home Assistant refuses lets the plan go again), rebuildDashboard() (read the cards it replaces, stopping on anything but no cards yet; keep the plan with the replaced version; save the cards; retitle), undoDashboard() and forgetDashboard()
dashboards.ts MnmlDashboards, the Dashboards tab: the list, each row with Open, Undo, Share and Forget, or the offer to build one, by Quick start, Step by step or From a template
share.ts What a shared dashboard template makes here (previewOf(), summary()), and the names its templates come in under (settled(), over the library's settleImport())
plan-editor.ts MnmlPlanEditor, a dashboard's builder: the plan as a draft, its sections, Customize on each row with the check that its card still draws, Add a car, and the check before a save; its Glances section: the Glances strip, shown or not
plan-edits.ts The plan editor's pure edits: movedBy(), withLook(), withDeclared(), withOrder(), withSystem(), trimmed(), templatesOf(), isIcon(), withGlances()
live-card.ts MnmlLiveCard, one card drawn with Home Assistant's card helpers, drawn again only when its configuration changes
customize.ts customize(), the template card's editor on one card in a dialog, loaded as Home Assistant loads it (the card's getConfigElement()); Done runs the caller's check and keeps the dialog open with its reason
data.ts The library's rows (rowsOf(), usesOf(), describe()), a template's draft (draftOf()), what a template is kept as (Entry, entryOf(), roundTrips(), storedAs()) and the panel's state (PanelState); a home template as written and readable: shapedOf() resolves a written template's slot shapes, readableOf() a draft for discovery and the pickers, slotOf() one written slot
library.ts MnmlLibrary
builder.ts MnmlBuilder: the header, the draft with undo and redo, the tabs and the save; it lists the style sheets of its parts
conflicts.ts, detached.ts The box of a release's conflicts; a shared piece written out into the template (detachedOf(), withDetached())
outline.ts A card's or the pop-ups' tree of parts (partOf(), groupOf(), drawOutline()) and the parts a draft changed (changedParts())
simple.ts simpleOf(), a template's parts as rows (switchable when in a list, with their conditions and their literal looks, a fragment's slots included), what switching one off and on keeps, and drawLook(), one look as a field
stage-panel.ts The Design tab's panel: the crumbs, the part's looks, its switch, its moves, what can be added to it, and the inspector under All settings
binding.ts The entities a part reads (dataFields()), what a slot is called and whether it finds itself, and the edits behind the panel's pickers: the entity of a part, a preview value in the example, a slot made from the entity picked (automateField()), an entity written in place of a slot
picks.ts What was picked in the Design tab for the preview of a template, kept in the browser, template by template
starters.ts The starting points of a new template: a tile, a list, a strip of statuses, a pop-up, each with entities of the home, and the screen that offers them (drawStarters())
inspector.ts drawInspector(), a part's fields, bindings and condition
binder.ts MnmlBinder, which binds a field to a slot of a kind that fits it (fittingSlots())
preview.ts MnmlPreview, a draft expanded (tryDraft()) and drawn with Home Assistant's card helpers. In the Design tab it is the stage: the expansion is marked, a click picks the part under the pointer, and boxes outline the hovered and the picked parts
slots-tab.ts drawSlots(), the slots and their rules as sentences
yaml.ts MnmlYaml, the YAML tab, and readYaml()
check.ts problemOf(), the check before a save
transfer.ts Import, export, the dashboards' mnml_templates: (dashboardTemplates(), notKept()) and renames
tree.ts Paths of keys and #<id>s in a template (valueAt(), withValue(), listAt()), movedTo(), uniqueId(), which gives a part a my- id, and adoptIds()
dialog.ts, form.ts, lovelace.ts, style.ts dialogsOn(), the dialogs (ask, confirm, a name, a file or pasted YAML); a small ha-form; Home Assistant's Lovelace module, loaded on the panel's page for its card helpers; PANEL_STYLE, the controls several elements share (each element adds its own)

src/templates/: the template engine

Templates is the language; this is how it is built.

  • expand.ts: expand(templates, instance, discovered, later) returns { card, popups } and touches nothing else. A name in later, a shipped template not loaded yet, is drawn as nothing and listed in missing, so one pass names every family a card still needs; a name in neither is an error. It checks each template's slots once (known kinds, lower-case names, no shape left unresolved), resolves each slot (the instance's value, else the discovered one, else the default), checks its kind and its fields, and renders the template's tree: [[path]], with *, ?field and !field projecting a path over a list, each/as, if/unless, nested template: with slots:, keys ending in ?, lists spliced. A mistake is a TemplateError naming the template and the path. toValue() copies JSON-like data into a Value, so a TypeScript caller converts its interfaces once, without a cast. A fifth argument, a marking ({ singles }), makes the expansion write _part, the path of the part (card/chips/#window, the same path the outline and valueAt() use) on every item of a list with an id, on the card, and on each single part the marking names, never inside a fragment, and never where it would be added to a list. The Design tab's stage reads it; the cards carry it as data-part on what they draw (ha/part.ts) and take it as a key of every part, drawing nothing different. It checks a template's retired paths once (dotted slot paths, none of them declared), takes a value given for a retired path and ignores it, and allows ?field and !field only over a list of objects.
  • changes.ts: applyChanges(template, changes) lays the home's changes over a shipped template (set, remove, insert, move, by a path of keys and #<id>s) and says which are changed or gone, judged against the shipped template; isChange() checks a change's shape for its op; baseOf() is the hash a change's base holds, of what it touches; nodeHash() is the stable hash under it; changesOf(base, edited) works out the changes from a shipped template to an edit of it. An id: on a list item is a control key to expand(), so no card sees it; ids.test.ts holds every part of a shipped card or pop-up to one, unique in its list.
  • ids.ts, ids.json, ids-main.ts: every released part path, and the retired ones; pnpm run fix:ids (part of pnpm fix) adds new parts, and ids.test.ts fails on one gone without being retired, and on a shipped id that starts with my-, the prefix of the ids the panel gives.
  • discover.ts: discover(template, area, registries) fills the slots that have a rule, from the areas, devices, entities and states it is given; with no area, only the scope: all rules. It is pure too; the template card hands it hass's registries.
  • discover.ts also holds the filters (matches() reads the entry, the state's attributes and the device), the label override (discoverSlot() looks for mnml_<slot> before the rules), fromDevice(spec, device, area, registries), an object slot filled from a device given by the slot's field rules, as its anchor or its label fills it, which the card editor's Use another writes, and relativesOf() and deviceName(), a device's via_device relatives and its name as Home Assistant shows it.
  • explain.ts: explain(template, area, registries) returns, per slot with a rule, the value and the steps taken (Step: the slot's field it is for, '' for the slot itself, the rule in words, the entities that matched, best first, the ones picked, the ones tied with the best, for an anchored rule the devices that hold a match and the ones taken, the near misses and why they missed), and for an empty slot a reason. For an object slot, devices are the devices (DeviceFit: id, name, area's name) that the slot's own anchor or label found and that fill it, and device the one chosen; a step inside the slot offers none. It runs discover()'s own code with a recorder, so the two cannot disagree.
  • corpus.ts, corpus/, corpus-main.ts: the discovery corpus. corpus/*.json are registries (areas, devices, entities, states): tidy, messy, labelled, systems and devices, written by hand to hold what real homes do (two matches for one slot, a hidden entity, a device with no area, an entity whose own area beats its device's; in labelled, the mnml_ labels a user puts on entities; in systems, no areas and the devices of the system integrations, Proxmox, UniFi, Tailscale and the media servers; in devices, a room's richer devices: a robot vacuum and its dock, an air conditioner, a radiator's thermostat and a heater with no device, metered and plain plugs, a 3D printer, a speaker and a washer), and demo, a snapshot of the throwaway Home Assistant (pnpm run corpus:demo, which only ever reads a local instance; tools/snapshot.ts keeps what discovery and the builder read: the attributes the rules filter on, hvac_modes among them, and a person's device_trackers, a device's maker, model, entry type, via_device link and labels, an entity's own name). corpus/expected.json is what every shipped template discovers in every area of every home; corpus.test.ts fails when that changes, and pnpm run accept:corpus accepts the change, to be read in the diff; it is not part of pnpm fix, so a change in what discovery finds is always a decision. tools/snapshot.ts keeps what discovery reads of a state: hvac_modes, source_type, device_trackers, and the devices' maker, model, entry_type, via_device_id and labels.
  • shipped.ts: readTemplates() reads templates/*.yaml with yaml, refusing a name defined twice; SHIPPED is what it read, for the generator and the tests. familyNames() and readFamily() read one file, a family, at a time, its shapes resolved; readShapes() reads templates/shapes.yaml, which is no family.
  • slot-shapes.ts: withShapes(name, template, shapes) writes out every slot and field that names a shape (Shapes): the shape's spec with the slot's own keys over it, a list of it for kind: objects, and the fields it changes. It leaves a template that names no shape as it is, and a mistake is a TemplateError naming the template and the slot. The template types as written, before their shapes are resolved, are WrittenSlot and WrittenTemplate in src/contract/templates.ts. It leaves a template that names no shape as it is, and a slot or field that is not a mapping to the engine's own checks; a mistake names the template, the slot and the shapes it goes through.
  • families.ts: the families as the browser gets them: COMMON, the shared fragments of common.yaml; OWNER, the family of every other name; SHAPES, the literal card type and grid_options of each family template, for sizing a card before its family arrives; SLOT_SHAPES, the shipped slot shapes, which the store resolves the home's own templates with; and loadFamily(family). In Node it reads the YAML; in the bundle a rolldown plugin in src/build/bundle.ts replaces it, with COMMON, OWNER, SHAPES and SLOT_SHAPES as data, every family's JSON file written with its shapes resolved, and a loadFamily() that fetches the family's JSON file next to the bundle, so the browser carries no YAML parser.
  • detach.ts: detach(use, templates, taken) writes a template use out: what the used template draws, its slots replaced by what the use gave them (a reference to a slot of the template around stays one), its conditions and loops renamed to match, its pop-ups returned. It gives up on what cannot be written out, and detach.test.ts writes out every use of every shipped template and checks that the demo home draws as before.
  • roles.ts: rolesOf(templates), each template a tile, a pop-up or a part (Roles), for the library and the gallery.
  • dashboard.ts: expandDashboard(templates, config) expands every template card of a dashboard and gathers their pop-ups into its popups card, in document order: what a dashboard of templates draws, without a browser. An instance's layout keys (grid_options, visibility, view_layout, layout_options, card_mod) stay on the card it expands to; its area is not read, so the card has only what the instance spells.
  • overrides.ts: overridesOf(found, values), what a card's values change of what discovery found: the editor and the builder both write it.

The template format's types are src/contract/templates.ts, part of the public interface beside cards.ts.

src/home/: the home generator

A whole dashboard from one typed object: The home dashboard says what it draws.

  • types.ts: Home and its parts (Room, Lights, Heating, Ac, Person, Car, System and the rest), every entity spelled with its domain's id type, and the frame (Dashboard, Card). It is public interface, as the contract is. HOME_KEYS, ROOM_KEYS, PERSON_KEYS, CAR_KEYS, SYSTEM_KEYS and SECTION_TITLES_KEYS name the keys of Home, Room, Person, Car, System and SectionTitles, whose sections, like order, are the builder's SectionKeys, each a Record<keyof T, true>, so the compiler rejects a table that misses a key or names one the type lacks; interface.json records them. It records the keys of those six types only, not of the types inside them (Lights, Vacuum, Proxmox, ...): a change to one of those fails a home's type check where the home spells it, and the release notes name it by hand.
  • slots.ts: one function per subject from its part of the home to its template's slots, in snake_case: roomSlots(), proxmoxSlots(), systemSlots(), glancesSlots(), headingSlots() and snake(), which the others, a person, a car and the systems, take as they are. It computes only what a template cannot: the room's joined, sorted batteries, the server's message sources, the system's firmware per room, and the home-wide lists of the Glances strip. A list that only gathers a field of another slot's list is a projection in the template ([[devices.*.update]]), not a slot.
  • plan.ts: homePlan(home), the home as a builder's Plan with every choice spelled (the Glances strip, each room, person and system tile by its slots, the cars by key, the sections' titles as their looks, popupOpen as open), and systemChoices(home), the system tiles in their order, which problems() reads too.
  • view.ts: build(home), homePlan(home) drawn by dashboardOf() with no registries, so nothing is discovered: one sections view of custom:mnml-template-card instances, every slot spelled and no area, the Glances strip and the sections one under another, and the popups card; drawn(home), the same dashboard as the templates expand it, which is what a tool that checks entity ids reads. It expands with the shipped templates only; a home's shared templates are seen by the cards, not by drawn().
  • problems.ts: problems(home), what would break the dashboard without failing its build: a hash opened that no pop-up defines, a hash defined or producible twice, a key that is no URL fragment, a subnet that is no IPv4 range. A home's tests assert it is empty.

src/builder/: the dashboard builder

What the panel's builder makes a dashboard from. It is pure: it reads the home's registries and the templates it is given, so it runs in the panel and in Node alike.

  • plan.ts: planFrom(recipe, registries, templates) refills a dashboard template in a home: the areas that are rooms where the rooms section's template draws, every person, the system section's templates whose entities discovery finds, no car, and the sections' looks; DEFAULT_RECIPE is the shipped one, and defaultPlan(registries, templates), the quick start, is it refilled: the areas that are rooms where the room template draws something beyond the area's name and icon, in the order Home Assistant lists them; every person entity; each of SYSTEM_TEMPLATES whose required slots discovery fills over the whole home; pop-ups that unfold on tablet and desktop. areaKinds(registries) tells each area's kind from one table of marks on its entities (platform, domain, device class, entity category, and for a temperature or humidity sensor that a device measures it, a device that is no service, or that it is the area's own): room when it holds something a room shows, otherwise phones, system or empty, by its devices; unplaced(registries) counts the devices in no area that hold something a room shows (Building a dashboard). The room test (MARKS, areaKinds(), unplaced()) reads each area's entities against one table of marks; a device_tracker moves only with source_type: gps.
  • person.ts: personOf(registries, entity), a person's slots: the key from the entity id, and one device per tracker in the person's device_trackers that the companion app (mobile_app) made, with the app's sensors on that device by their entity ids' endings (_battery_level, _battery_state, _connection_type, _ssid, _storage, _focus, _activity, _location_permission). A tracker that lacks one of them is left out, and a person with none is the person entity alone.
  • customized.ts: from a card its editor changed back to the plan: roomFrom() (the area picked, the overrides), personFrom() and systemFrom() (only what differs from what MNML finds, by overridesOf()), and carFrom(), a spelled row staying spelled (the key out of the slots, or no car without one that is a name).
  • recipe.ts: recipeOf(plan, templates, shipped), a dashboard template from a plan: the sections it has (one with something, or with a look), their looks, the system templates chosen, the openings, and every template it uses, inside others too, that is not the shipped one as shipped, whole; recipeText() writes it under mnml_dashboard:; readRecipe(text, shipped) reads one, refusing a dashboard's configuration, a set of templates, an unknown section, or a section template neither shipped nor bundled, with the reason; renamedRecipe() follows the names its templates come in under.
  • dashboard.ts: dashboardOf(plan, registries, templates), one sections view with a heading and the cards of each part that has any (Rooms, People, Garage, System, in the plan's order), each heading and template from the section's look, and the popups card. The Glances strip comes first, across the view, then the sections, one under another. A spelled choice is its slots alone. A room is its area, discovered live, with its overrides; a person is personOf() with the overrides over it; a car is its slots and key. roomCard(), personCard(), carCard() and systemCard() make each card alone, for the builder's rows. A system template without overrides is the template alone and discovers live; with them, it holds what discovery found when the plan was built, and the overrides over it, since a card that spells slots does not discover. Every card holds only the slots and fields its template declares, a retired one dropped too; an area room left with none has no slots. roomCard(), personCard() and carCard() take the templates as an optional last argument.

custom_components/mnml/: the integration

One config entry (single_config_entry), no devices and no entities. Its flow asks the two options, the glass design and MNML as the default theme, and the options form asks them again.

File Does
__init__.py async_setup_entry(): the stores, then the websocket commands, the frontend, the panel, the theme and the repairs, the default theme, the options listener; async_unload_entry() takes the bundle and the panel off the pages and clears the repairs; async_remove_entry() deletes the theme and gives the default theme back. MnmlData (whether the glass design is on) is the entry's runtime data; the options listener swaps the theme when the design changes. The default theme option is what is wanted, and the entry's data keeps what was last applied (applied_default_theme): at setup and on every change of the options, async_sync_default() makes MNML the default theme, or gives the default back, when the two differ, and makes it the default only while Home Assistant loads MNML's theme. So an answer of the setup form, or an option turned on before the theme loads, takes effect at the first setup that finds the theme, and a start with nothing changed leaves a default chosen since in Home Assistant alone
frontend.py loader_url(www), /mnml-files/mnml-cards-loader.js?v=<first 8 of the sha256 of the loader and the bundle>, which fails setup with ConfigEntryError, naming what is missing, when the loader, the bundle, the panel or one of the two themes (mnml.yaml, mnml-flat.yaml) is not built; async_register_frontend() serves www/ at /mnml-files/ with long cache headers (once per Home Assistant run) and adds the loader to every page with add_extra_js_url. The URL comes off the pages on unload, and when a later step of setup fails
theme.py write_theme() writes a built theme to themes/mnml-integration/mnml.yaml, atomically, when it differs; async_install_theme(glass=) chooses www/mnml.yaml or, flat, www/mnml-flat.yaml, and then reloads themes, and when Home Assistant does not load a theme named MNML from it (no themes include, an include of another folder, or !include_dir_named, which cannot hold it) takes the folder away again, so the configuration stays valid. A reload that fails is tried again without the folder: when that one succeeds, the file was the cause and stays away; when it fails too, the cause is elsewhere (an error in configuration.yaml), so the file is written back, the failure is logged and no repair is raised, and the theme loads at Home Assistant's next reload or restart; theme_loaded() says whether Home Assistant holds a theme named MNML; other_theme_files() finds another file under themes/ that defines MNML, the standalone theme from HACS in themes/mnml/ included; async_apply_default() sets MNML as the default theme in light and dark mode, or, turned off, gives back each mode MNML still holds (none), and logs a change Home Assistant refuses rather than raising it; async_remove_theme() deletes the folder and reloads themes, which also gives back a default that named MNML
conflicts.py async_hand_loaded(): the URL of a Lovelace resource, stored in the UI or in YAML, that loads mnml-cards.js from anywhere but /mnml-files/. It reads Lovelace's resource collection, which is not a promised API, and answers nothing when that collection is not what it expects
dashboards.py DashboardStore: the dashboards the panel's builder made, in .storage/mnml.dashboards, by their url_path: each one's plan, its last save's time and user, and the version a rebuild replaced (its plan and its Lovelace configuration), which an undo restores. DASHBOARD_STORE is its key in hass.data, loaded beside the templates' store; one that cannot be read is logged and left out, and its commands answer not_loaded
issues.py The three repairs, raised or cleared on every setup: theme_not_loaded, second_theme, hand_loaded_bundle
store.py TemplateStore: the home's templates in .storage/mnml.templates, through Home Assistant's storage helper, each with its last save's time and user and its previous 10 versions; listeners are told after each write. STORE is its key in hass.data, loaded on the first setup of a run, before the cards are served, so a page that loads them finds the store ready. A store that cannot be read is logged and left out: setup goes on, the websocket answers not_loaded, and the cards draw the shipped templates
panel.py async_register_panel(), the MNML panel at /mnml for admins, with the icon mnml:mnml, from www/mnml-cards-panel.js named by its content; async_remove_panel() on unload
websocket.py mnml/templates/subscribe (every user: the set, then the set again after each save and delete), save, delete and history (admins), and mnml/dashboards/list, save and delete (admins: a plan of the shape of isPlan(), and the version it replaces, at a url_path of lower case letters and digits with a hyphen between them, as Home Assistant's own dashboards have, at most 512 kB each and 100 in all), registered once per run, with the shape checks of Templates and at most 500 templates
config_flow.py The flow, whose one form asks Use the glass design (on) and Use MNML as the default theme (off), and the options form, which asks the same two
translations/en.json Every string Home Assistant shows: the flow, the option, the repairs
brand/ The icons, drawn as icon-light.svg and icon-dark.svg; pnpm icons renders each to a PNG of 256 and 512 px with transparent corners: icon.png and icon@2x.png from the light one, dark_icon.png and dark_icon@2x.png from the dark one. Home Assistant serves them for the integration, the logo names falling back to the icon of the same theme, and HACS's brand check finds icon.png

Why a loader. Home Assistant imports an extra module as soon as its page starts, before its app has installed the scoped custom-element registry it runs on. A card class declared before that extends the browser's own HTMLElement and cannot be constructed afterwards. So the page gets mnml-cards-loader.js (src/cards/loader.ts), which waits until home-assistant is defined (src/cards/started.ts) and then imports mnml-cards.js with its own ?v=; on a page where Home Assistant already runs it imports at once. Before it waits, the loader registers the mnml icon set (src/cards/iconset.ts) in window.customIcons, as Home Assistant's frontend reads it: mnml:mnml is the logo's frame made square, with M and N on the first row and M and the corner on the second, one 24 px path in the colour of wherever it is drawn. Being early, it is there before the sidebar draws the panel's icon.

/mnml-files/ and not /mnml/: the panel takes /mnml. themes/mnml-integration/ and not themes/mnml/: HACS installs the standalone MNML Theme there. An update of the integration takes effect after a restart, as for any integration; a reload of the entry serves a rebuilt www/ under a new ?v=.

demo/: the demo

  • home.ts: DEMO, a home of nine rooms, Joe and Jane, two cars and the six systems, which draws every template. The demo integration supplies its climates, media players, calendars, camera, vacuum, weather, lock and the kitchen's lights, under their own ids.
  • main.ts writes out/demo.yaml; tools/dashboard.ts saves the same dashboard as mnml-demo on the throwaway instance.
  • traits.ts: each demo entity's name, icon (by state where it changes), unit, device class and reading, taken once from the home the demo was made from, with the same names and words scrubbed as in home.ts.
  • states.ts: what makes the demo lived in: the rooms whose lights are off, the rooms heating, who is away, and a few states set outright (an open window, a low and a critical battery, an unlocked car, a stopped guest, a print running).
  • entities.ts: packageOf(home), a Home Assistant package of template entities, scenes, scripts, helpers and generic thermostats for every id the home draws that the demo integration does not supply, from the traits and the states, and a customize block that names and icons the demo integration's entities, the scenes and the thermostats (customizeOf()), which tools/demo.ts also writes to the registry of those that have an entry, since a card reads a registered entity's name from the registry; run, it writes .ha/packages/demo.yaml and copies the QR, map and 3D printer images to .ha/www/. created() lists the ids a package makes, and its test holds the two together. tools/demo.ts adds the people and their device trackers, home or away, the 3D printer's camera from its image, and to each numeric template sensor a display precision of as many decimals as its reading, renames what Home Assistant kept from an earlier run, removes the demo's entities the package does not make, and fails on an id another integration took first, which belongs in SUPPLIED.

src/build/: the build

  • buildBundle(file) is one rolldown build for the browser, of minified ES modules written beside file. Three entries keep fixed names: mnml-cards.js, the bundle (src/cards/register.ts); mnml-cards-loader.js (src/cards/loader.ts); and mnml-cards-panel.js (src/panel/main.ts), which the integration registers, and whose yaml package reaches no dashboard.
  • The card editors (src/editors/main.ts) are a chunk of their own, mnml-cards-editors-<hash>.js, which nothing imports statically. The build fills the generated module src/editors/where.ts with its URL, and a card imports it from its getConfigElement() (src/cards/edit.ts). The URL is a module of its own on purpose: a browser keeps a failed dynamic import for the page, so a retry asks for the same file under a fresh URL, ?attempt=<n>. The editors carry no YAML parser, since Home Assistant's own YAML tab covers YAML.
  • Shared chunks. What several entries share is a chunk named by its content, mnml-cards-<name>-<hash>.js.
  • The literals are minified. A plugin runs minify-literals (lightningcss for the styles, html-minifier-next for the markup) over every file under src/ that holds a css, html or svg template.
  • The families. common.yaml goes in as data. Every other family is written beside the bundle as mnml-cards-<family>.json, and the bundle names each by its content (?v=<hash>), since the integration serves them with long cache headers.
  • A build starts clean. It first removes every file of the folder whose name starts with the bundle's stem (mnml-cards), so no chunk of an earlier build stays beside the new ones.
  • Each map is named by its content. Every file has its source map beside it, sources included, so a browser's developer tools show the TypeScript and a stack trace names it. The file's last line is //# sourceMappingURL=<file>.map?v=<hash>, the map's own hash. The files are served with long cache headers, so a fixed name could pair a new file with an older map; a new map is a new URL. Its paths are relative to this repository (src/...), and a dependency's start at its package (node_modules/lit-html/...) wherever the package manager keeps it, so a build inside a parent workspace writes the maps a checkout of its own does.
  • node src/build/main.ts [out] writes them into out (resolved from the working directory), the bundle under the name src/build/names.ts gives; pnpm run build:frontend gives it custom_components/mnml/www/, and a project that holds MNML as a submodule builds into its own folder.
  • node src/theme/main.ts [out] writes mnml.yaml (glass) and mnml-flat.yaml there, after every contrast claim of both holds; pnpm run build:theme gives it the same folder. pnpm build runs both.

Releases

.github/workflows/check.yml runs on every pull request and every push to main, as jobs that run side by side, each on its own runner: lint (oxfmt, oxlint, markdownlint, knip), typescript (vitest), python (ruff, ruff format, mypy), pytest, hassfest, floor (the Python tests on the oldest Home Assistant supported) and hacs (HACS's validation). Together they run every step of pnpm check, and build.test.ts fails when a step of it is in no job. A tag v<major>.<minor>.<patch> runs .github/workflows/release.yml, one release at a time: the same checks, by calling check.yml, and beside them the build job, a check that the tag is v and the version in manifest.json, a check that the tag is annotated, the release notes, pnpm build and pnpm zip; once both pass, a GitHub release with mnml.zip attached. hacs.json sets zip_release, so HACS unpacks mnml.zip into custom_components/mnml/; a hand install does the same.

HACS's validation runs every check, its brand check satisfied by brand/icon.png; hassfest checks the manifest and build.test.ts holds hacs.json. Every action is pinned to a commit, so a moved tag cannot change what CI runs; the build job reads the repository only, without credentials left on disk, and only the publish job, which runs no repository code, may write. A tag with a - (v0.2.0-rc.1) makes a prerelease, which HACS offers only to those who ask for it. The publishing step skips a release that already exists, so a release that failed half way is re-run as it is.

The release notes are written by hand and completed by the release. The maintainer writes them as the annotated tag's message: what changed, and for each part of the public interface removed, renamed or changed in meaning, a line on how to migrate. The tag is made with git tag -a --cleanup=verbatim vX.Y.Z (with -F for a message in a file): by default git tag drops every line that starts with #, and a Markdown heading with it. The build job writes notes.md: that message, then node tools/notes.ts <previous tag>, the entries of interface.json removed, changed (a key's or a slot's kind, required flag, values or default) and added since the previous tag, read with git show <tag>:interface.json. The previous tag of a release is the last v* tag before it, and for a stable release the last stable one, a prerelease skipped (git describe --tags --abbrev=0 --match 'v*' --exclude '*-*' "$TAG^"), so that its notes cover every change since the release before it rather than since its last release candidate. A tag that records no interface.json is said to have none, a tag whose record is of another format is said to need comparing by hand, with nothing listed, and the first tag has no diff. Its checkout fetches the whole history and every tag for this. The publish job receives notes.md as an artifact and the previous tag as the build job's output, and creates the release with --notes-file notes.md --generate-notes --notes-start-tag <previous tag>, so the pull requests GitHub lists, since that same tag, follow the notes.

Dependabot (.github/dependabot.yml) opens a pull request each week for the Python tools (uv), the Node packages (pnpm) and the pinned actions; pytest-homeassistant-custom-component and home-assistant-frontend move together in one. Its security updates bump only the vulnerable package itself, so a package Home Assistant pins exactly, such as PyJWT or cryptography, is fixed by the weekly bump of pytest-homeassistant-custom-component, which brings the Home Assistant that pins a patched version. Those packages serve the tests only; the integration requires none. .github/workflows/dependabot.yml turns on auto-merge for Dependabot's patch and minor updates, so they squash into main once the checks pass. It names the two kinds it merges rather than the one it skips, so an update whose kind it cannot read waits too, as a major one does; and it acts only when Dependabot both opened the pull request and pushed to it.

The repository

  • main takes changes only through pull requests. A ruleset requires a pull request whose checks, the jobs of check.yml, pass, merges it by squashing, keeps the history linear, and refuses a force-push or a deletion of main. A merged branch is deleted.
  • Release tags stay put. A second ruleset refuses to move or delete a v* tag, so a release always names the commit CI built.
  • The wiki is made from docs/. .github/workflows/wiki.yml runs tools/wiki.ts on every push to main that changes README.md or docs/, and pushes the result to the wiki: each doc becomes a page named after its title and the README the Home page; a link between docs becomes a link between pages, and a link to any other file an absolute link to GitHub; a sidebar lists the pages, and each page names the file it is made from. The docs stay the source, written and reviewed with the code; the wiki is for reading.
  • Reports. SECURITY.md sends a vulnerability to GitHub's private advisories. An issue opens from a bug or a feature form (.github/ISSUE_TEMPLATE/), and a pull request from a checklist (.github/pull_request_template.md).

Tests

  • The integration's tests are in tests/, beside plans.json, the example plans both src/contract/builder.test.ts and tests/test_dashboards.py judge, on pytest-homeassistant-custom-component, which runs a real Home Assistant in each test, with home-assistant-frontend for the frontend it depends on. The fixtures in conftest.py give each test a www/ of its own (const.WWW is patched), a config folder with the themes include, setup_mnml() and themes() (the frontend's frontend/get_themes). pnpm test runs them, after vitest, on the Home Assistant that pytest-homeassistant-custom-component brings; pnpm run test:python-floor on the oldest supported (2026.10.0b0, the first with probatio, the validation library that replaces voluptuous), with its own harness and frontend.
  • The TypeScript tests, *.test.ts, sit next to what they cover and run on vitest, in the two projects of vitest.config.ts: node, for the contract, the engine, the generator, the builder, the theme, the build, the tools and the demo; and browser, on jsdom, for the cards, src/ha/, the store, the editors, the panel and src/test/. test comes from vitest, assertions from node:assert/strict. vitest runs each file in a module state of its own, so a test that needs an empty store or registry is a file of its own; template-failure.test.ts and template-card-failure.test.ts replace families.ts with vi.mock(), so a card and the template card's gallery meet a family that cannot be loaded.
  • Elements are mounted with src/test/render.ts: define() defines a tag once, mounted() puts an element in the document and waits for its update, then gives its shadow root, text() reads the text a user sees, and drawn() renders a template into a box of its own, and styled() renders one under a set of styles, for a test that reads a computed style, and removes its hosts after each test. A test queries the shadow root and sends real DOM events.
  • src/test/fake-hass.ts is a fake connection for the store: fakeStore() answers mnml/templates/subscribe with kept (or as a home without the integration when it is unset), refuses with refuse, sends a save with keep(), records each message and each subscription's options, and reconnects.
  • styles.test.ts asserts that every --mnml-* variable a card reads has a fallback, so the cards work under any theme.
  • editors/cards.test.ts and editors/form.test.ts send every card the demo draws, and every example card, through its editor's description and through Home Assistant's form data, section by section and part by part, and want each back unchanged, key order included; the type checker holds each card's and part's structure to every key of its contract type. editors/labels.test.ts fails while a key of a structure the editors draw has no label, or the table labels a structure, a key, a help or a kind that is not there.
  • src/cards/templates.test.ts expands every shipped template from its example, and again with only its required slots, and gives each MNML card it draws, at any depth, to that card's setConfig(): a key a card does not know fails wherever an example reaches it, and a branch no example fills goes unchecked. It also holds every object slot to its declared fields:, and every path into one to a declared field. src/templates/docs.test.ts fails when docs/templates.md misses a template, a slot or a field.
  • examples/cards.yaml is a hand-written configuration of every card, on the demo integration's entities and the template entities of examples/fixtures.yaml. tools/ installs it on a throwaway Home Assistant and looks at it under both themes. examples/templates.yaml is a second view, rooms placed by area, which tools/areas.ts fills by moving demo entities into two areas.

The throwaway Home Assistant

It runs in Docker, from .ha/ (git-ignored), with custom_components/mnml/ of this tree mounted read-only, and has no tie to a home. Then:

pnpm demo

tools/up.ts does the whole round, and runs again as often as wanted:

  1. writes .ha/configuration.yaml the first time (the demo integration, the themes folder, the packages), and copies recipes/messages.yaml and examples/fixtures.yaml into .ha/packages/;
  2. writes the demo's package with node demo/entities.ts, and builds custom_components/mnml/www/ (pnpm build);
  3. starts ghcr.io/home-assistant/home-assistant:beta afresh (the beta channel, while the oldest Home Assistant supported is a beta) as the container mnml-ha, published on 127.0.0.1:8124 only (pnpm demo takes a name and a port, node tools/up.ts mnml-ha-glass 8125, for a second one beside it), and waits until Home Assistant answers;
  4. on a .ha/ with no owner yet, onboards it: the owner demo, password demo, and a long-lived token written to .env (git-ignored) with HA_URL=http://localhost:8124 (or the port given); then waits until Home Assistant is running;
  5. runs tools/demo.ts (the demo's people, device trackers, camera, names, icons and display precisions), tools/areas.ts (the two areas the examples' templates view places rooms in), tools/ids.ts (every id in the examples and the demo exists), and tools/dashboard.ts, which removes what an older install put in .ha/ (the /local/mnml resources, www/mnml-cards*, themes/mnml.yaml), adds the MNML entry or reloads it, saves the templates of examples/home-templates.yaml to the store, removes the mnml-templates dashboard an older install made, saves the mnml-examples and mnml-demo dashboards, and reloads the themes.

The loader's address carries a digest of the files, taken when the integration starts, so a build the running demo has not seen is served under the old address: restart the demo (pnpm demo) or reload MNML's entry after a build, then reload the page. A page opened before an update asks for an editor file that is gone, and says to reload the page.

The tools reach it over Home Assistant's websocket through home-assistant-js-websocket (tools/socket.ts), and refuse an HA_URL that is not local (tools/env.ts), so one run from another project's folder cannot reach a home with that project's token. docker stop mnml-ha stops it; deleting .ha/ and .env starts over. Then, by hand, each in Playwright's Chromium (tools/browser.ts, installed once with pnpm exec playwright install chromium), signed in with the token:

pnpm look           # out/look/: the default theme and MNML, light and dark, phone and desktop
pnpm walk           # the panel, walked through: edit, save, reset, new, duplicate, delete, build a dashboard
pnpm videos         # out/videos/: five silent .webm tutorials, with captions, a visible cursor and click rings
pnpm screenshots    # docs/screenshots/: the demo for the README in the glass design, light and dark, six pop-ups and the panel, then the desktop in the flat design (through the options); tools/split.ts joins each desktop's light and dark diagonally into desktop.png and flat-desktop.png

pnpm look fails on any error card on the examples' cards and templates views, on a card of the examples whose editor cannot be opened or opens without a form (it mounts each inside home-assistant, whose context Home Assistant's pickers read), on a panel that lists no template or a shipped template whose builder does not open or shows a problem, and on a demo pop-up that opens nothing, has no header or shows a blank body. The panel and the demo it checks under MNML, light and dark, opening each of the demo's 51 pop-ups by its hash. The screenshots are read by eye: under the default theme each card keeps its hairline edge and its pills.

pnpm videos records the demo and the panel with Playwright's video recorder: a tour of the dashboard, building a dashboard, editing a shipped template, making a template and the phone layout. It needs the demo running (pnpm demo), draws a cursor and captions over the page since the videos have no sound, and leaves the demo as it found it. The videos stay in out/, which git ignores.

pnpm walk drives the panel on a desktop: it opens room, picks a part, edits a slot's label, sees the slot marked changed and the change counted in the header, saves, and finds room customised in the library; resets it to shipped and saves; makes a new template and saves it; duplicates it and sees the copy open under its own name; leaves another new template unsaved and finds its name free again; and deletes the first. Then it forgets every dashboard MNML built, finds the offer to build one, makes one by Quick start and opens it (template cards, no error card), rebuilds it with a new title and a room fewer, undoes the rebuild, shares it (no area or person in the text), forgets it with Delete it too, and makes it again from the shared text, which draws its rooms with no error card. It fails on a step that does not hold, and on a page error from MNML's files.

Tooling

Oxc for TypeScript, ruff and mypy for Python.

  • oxlint, with typeAware and typeCheck through oxlint-tsgolint, lints and reports the TypeScript diagnostics. There is no tsc and no typescript package.
  • oxfmt formats; knip finds dead code; rolldown bundles the elements, and minify-literals minifies their templates; vitest runs the TypeScript tests, the elements' on jsdom.
  • ruff lints and formats the Python, mypy --strict type checks it, and hassfest, Home Assistant's own validator, checks the integration in Docker (ghcr.io/home-assistant/hassfest, linux/amd64). HACS's validation reads the GitHub repository, so it runs in CI only.
  • There are no git hooks. The bundle's one runtime dependency is Lit, which the build bundles; the integration has none; home-assistant-js-websocket is the tools' client for the throwaway Home Assistant.
Tool Configuration Rules of the house
oxlint oxlint.config.ts Every rule is an error (denyWarnings), and so is an unused disable directive. A rule that fights a convention is turned off in the config, with the reason written here, never in an inline directive. no-console and no-await-in-loop are off for src/*/main.ts, demo/main.ts, demo/entities.ts and tools/*.ts, the command lines, which print and run one step after another on purpose. no-console is off for src/cards/parts/popup-registry.ts, which reports a hash announced twice on the console, where a dashboard's author looks, for src/store/store.ts, which reports there a card that threw on a change of the store, changes kept in a shape it does not know, and a template of the home's own whose slot shapes could not be resolved, for src/store/shipped.ts, which reports a family that could not be loaded, and for their tests, template-failure.test.ts and template-card-failure.test.ts, which replace console.error to see those reports or keep them quiet
oxfmt oxfmt.config.ts The defaults (100 columns, semicolons, trailing commas, sorted package.json) plus single quotes and sorted imports
markdownlint .markdownlint-cli2.jsonc Every rule but MD013, line length: a paragraph is one line. CLAUDE.md, an @AGENTS.md include, is skipped, and so is .github/pull_request_template.md, which starts a pull request's description and so opens with a second-level heading
vitest vitest.config.ts Two projects: node, and browser on jsdom, each with the folders its tests run in (Tests). test comes from vitest, assertions from node:assert/strict
knip knip.jsonc Fails on unused files, dependencies and exports. Don't export what only the same file uses. Entries: the four the browser loads (src/cards/register.ts, src/cards/loader.ts, src/editors/main.ts, src/panel/main.ts), the contract (cards.ts, entities.ts, templates.ts) and the home's types (src/home/types.ts), whose exports are for the projects that use MNML, the tests, tools/*.ts and demo/*.ts. uv is a binary it leaves alone
ruff pyproject.toml Every rule but D (the code has no comments, so no docstrings), COM812 (it fights ruff's formatter) and CPY001 (no copyright headers); in tests/ also S101 (pytest asserts), PLR2004 (expected values are literals), SLF001 and ARG001 (fixtures, and Home Assistant's test internals). 100 columns, Python 3.14 syntax
mypy pyproject.toml strict, over custom_components/ and tests/, with explicit_package_bases since custom_components/ has no __init__.py. Two overrides: pytest_homeassistant_custom_component ships no types, and homeassistant.components.http re-exports StaticPathConfig, which the oldest Home Assistant supported defines there itself
hassfest manifest.json, translations/en.json Home Assistant's rules for an integration: the manifest's keys, the config flow, the translations
tsconfig tsconfig.json src/, demo/, tools/ and vitest.config.ts. Strict, with noUncheckedIndexedAccess but without exactOptionalPropertyTypes: an optional key may hold undefined, which a card reads as absent. experimentalDecorators and useDefineForClassFields: false are Lit's decorators as Home Assistant writes them: a class field defined by the standard would shadow the accessor a decorator puts on the class. rolldown and vitest compile them; Node, which only strips types, runs no module that imports an element
  • Markdown too. oxfmt formats the docs and realigns a table's columns to their display width. Leave a table to pnpm fix rather than padding it by hand.
  • Ignored paths. ignorePatterns keeps oxfmt off uv.lock, .ha/, out/, .venv/ and custom_components/mnml/www/. All the tools honour .gitignore.
  • Dependencies. pnpm-lock.yaml and uv.lock hold the exact versions; pnpm install and uv sync install them, and CI's pnpm install --frozen-lockfile and uv sync --locked refuse a lock that has drifted. packageManager in package.json names the pnpm. pnpm runs a dependency's install script only when the project allows it, and this one allows none, so no dependency may rely on one.

Clone this wiki locally