Repository navigation
Architecture
This page is made from docs/architecture.md on every change to
main; edit it there.
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.
| 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 |
| 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) |
-
Cards:
TileCard,EntityCard,HeaderCard,HeadingCard,ListCard(withListRow),AgendaCard(withAgendaSource),MessagesCard,ClientsCard(withClientNetworkandClientFields),ReportCard,WeatherCard,GlancesCard(withGlance),ButtonCard,SelectCard,SliderCard,MediaCard,CarPlanCard,PopupsCard(withPopupandLovelaceCardConfig, any card a pop-up holds) andTemplateCard. -
A tile's or header's subject is one of two variants: a literal
nameandicon, with an optionalentity; or anentitywhose name (its device's, withlabel) and icon the card shows, where aniconmay still override the entity's. -
Item: an entity with its name, state line, popup and controls.EntityCardis anItemwith atypeand optional nesteditems: the rows its expander reveals.TileCard.itemis anItemtoo. -
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, anattribute, a literaltext, theminutesof a seconds counter, a timestamp as arelativetime, theserialnumber of the entity's device, or the entity'sname.icon: trueputs the state icon in front. -
Rules:
StateRule(is/not);EntityRule, which adds theentityit reads; and astatuscontrol's rules, which add theentitiesthey watch, how the tooltip names and words them (label,words), and thecolorthey give. -
Condition: Lovelace's state condition, asstate_noton anentity: the one form a card'svisibilitytakes 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.
-
register.tsis 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) andmnml-tooltip, and lists the cards and the shell inwindow.customCards. Each of those gets itsgetStubConfig()fromstubs.ts, and each but the pop-ups card itsgetConfigElement()fromedit.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.tsandschemas.ts: a card's known keys are aKeySchema, whichkeysOf()derives from a structure ofsrc/contract/structure.ts, a key that holds a part, a list of parts or a rule with the schema of that structure nested, andcardKeysOf()with the card'stypetoo.schemas.tsholds them all: one constant per card (HEADING_CARD,TILE_CARD, ...) that the card imports, and the parts'STATE_ITEMS,CONTROLSandLIST_ROW. The structures are typed againstcards.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 spellsnever): by itstypefor 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 aslider, or a text with anattribute, 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()
|
| 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 |
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 |
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) |
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 inlater, a shipped template not loaded yet, is drawn as nothing and listed inmissing, 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*,?fieldand!fieldprojecting a path over a list,each/as,if/unless, nestedtemplate:withslots:, keys ending in?, lists spliced. A mistake is aTemplateErrornaming the template and the path.toValue()copies JSON-like data into aValue, 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 andvalueAt()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 asdata-parton what they draw (ha/part.ts) and take it as a key of every part, drawing nothing different. It checks a template'sretiredpaths once (dotted slot paths, none of them declared), takes a value given for a retired path and ignores it, and allows?fieldand!fieldonly 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'sbaseholds, 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. Anid:on a list item is a control key toexpand(), so no card sees it;ids.test.tsholds 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 ofpnpm fix) adds new parts, andids.test.tsfails on one gone without being retired, and on a shipped id that starts withmy-, 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 thescope: allrules. It is pure too; the template card hands ithass's registries. -
discover.tsalso holds the filters (matches()reads the entry, the state's attributes and the device), the label override (discoverSlot()looks formnml_<slot>before the rules),fromDevice(spec, device, area, registries), anobjectslot 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, andrelativesOf()anddeviceName(), a device'svia_devicerelatives 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 anobjectslot,devicesare the devices (DeviceFit: id, name, area's name) that the slot's own anchor or label found and that fill it, anddevicethe one chosen; a step inside the slot offers none. It runsdiscover()'s own code with a recorder, so the two cannot disagree. -
corpus.ts,corpus/,corpus-main.ts: the discovery corpus.corpus/*.jsonare registries (areas, devices, entities, states):tidy,messy,labelled,systemsanddevices, 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; inlabelled, themnml_labels a user puts on entities; insystems, no areas and the devices of the system integrations, Proxmox, UniFi, Tailscale and the media servers; indevices, 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), anddemo, a snapshot of the throwaway Home Assistant (pnpm run corpus:demo, which only ever reads a local instance;tools/snapshot.tskeeps what discovery and the builder read: the attributes the rules filter on,hvac_modesamong them, and a person'sdevice_trackers, a device's maker, model, entry type,via_devicelink and labels, an entity's own name).corpus/expected.jsonis what every shipped template discovers in every area of every home;corpus.test.tsfails when that changes, andpnpm run accept:corpusaccepts the change, to be read in the diff; it is not part ofpnpm fix, so a change in what discovery finds is always a decision.tools/snapshot.tskeeps what discovery reads of a state:hvac_modes,source_type,device_trackers, and the devices' maker, model,entry_type,via_device_idand labels. -
shipped.ts:readTemplates()readstemplates/*.yamlwithyaml, refusing a name defined twice;SHIPPEDis what it read, for the generator and the tests.familyNames()andreadFamily()read one file, a family, at a time, its shapes resolved;readShapes()readstemplates/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 forkind: objects, and the fields it changes. It leaves a template that names no shape as it is, and a mistake is aTemplateErrornaming the template and the slot. The template types as written, before their shapes are resolved, areWrittenSlotandWrittenTemplateinsrc/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 ofcommon.yaml;OWNER, the family of every other name;SHAPES, the literal card type andgrid_optionsof 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; andloadFamily(family). In Node it reads the YAML; in the bundle a rolldown plugin insrc/build/bundle.tsreplaces it, withCOMMON,OWNER,SHAPESandSLOT_SHAPESas data, every family's JSON file written with its shapes resolved, and aloadFamily()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, anddetach.test.tswrites 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; itsareais 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.
A whole dashboard from one typed object: The home dashboard says what it draws.
-
types.ts:Homeand its parts (Room,Lights,Heating,Ac,Person,Car,Systemand 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_KEYSandSECTION_TITLES_KEYSname the keys ofHome,Room,Person,Car,SystemandSectionTitles, whose sections, likeorder, are the builder'sSectionKeys, each aRecord<keyof T, true>, so the compiler rejects a table that misses a key or names one the type lacks;interface.jsonrecords 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()andsnake(), 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'sPlanwith 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,popupOpenasopen), andsystemChoices(home), the system tiles in their order, whichproblems()reads too. -
view.ts:build(home),homePlan(home)drawn bydashboardOf()with no registries, so nothing is discovered: onesectionsview ofcustom:mnml-template-cardinstances, every slot spelled and noarea, 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 bydrawn(). -
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.
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_RECIPEis the shipped one, anddefaultPlan(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; everypersonentity; each ofSYSTEM_TEMPLATESwhose 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):roomwhen it holds something a room shows, otherwisephones,systemorempty, 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; adevice_trackermoves only withsource_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'sdevice_trackersthat 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()andsystemFrom()(only what differs from what MNML finds, byoverridesOf()), andcarFrom(), 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 undermnml_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), onesectionsview with a heading and the cards of each part that has any (Rooms, People, Garage, System, in the plan'sorder), 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 itsarea, discovered live, with its overrides; a person ispersonOf()with the overrides over it; a car is its slots and key.roomCard(),personCard(),carCard()andsystemCard()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 noslots.roomCard(),personCard()andcarCard()take the templates as an optional last argument.
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=.
-
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.tswritesout/demo.yaml;tools/dashboard.tssaves the same dashboard asmnml-demoon 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 inhome.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 acustomizeblock that names and icons the demo integration's entities, the scenes and the thermostats (customizeOf()), whichtools/demo.tsalso 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.yamland 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.tsadds 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 inSUPPLIED.
-
buildBundle(file)is one rolldown build for the browser, of minified ES modules written besidefile. Three entries keep fixed names:mnml-cards.js, the bundle (src/cards/register.ts);mnml-cards-loader.js(src/cards/loader.ts); andmnml-cards-panel.js(src/panel/main.ts), which the integration registers, and whoseyamlpackage 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 modulesrc/editors/where.tswith its URL, and a card imports it from itsgetConfigElement()(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 undersrc/that holds acss,htmlorsvgtemplate. -
The families.
common.yamlgoes in as data. Every other family is written beside the bundle asmnml-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 intoout(resolved from the working directory), the bundle under the namesrc/build/names.tsgives;pnpm run build:frontendgives itcustom_components/mnml/www/, and a project that holds MNML as a submodule builds into its own folder. -
node src/theme/main.ts [out]writesmnml.yaml(glass) andmnml-flat.yamlthere, after every contrast claim of both holds;pnpm run build:themegives it the same folder.pnpm buildruns both.
.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.
-
maintakes changes only through pull requests. A ruleset requires a pull request whose checks, the jobs ofcheck.yml, pass, merges it by squashing, keeps the history linear, and refuses a force-push or a deletion ofmain. 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.ymlrunstools/wiki.tson every push tomainthat changesREADME.mdordocs/, 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.mdsends 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).
-
The integration's tests are in
tests/, besideplans.json, the example plans bothsrc/contract/builder.test.tsandtests/test_dashboards.pyjudge, onpytest-homeassistant-custom-component, which runs a real Home Assistant in each test, withhome-assistant-frontendfor the frontend it depends on. The fixtures inconftest.pygive each test awww/of its own (const.WWWis patched), a config folder with the themes include,setup_mnml()andthemes()(the frontend'sfrontend/get_themes).pnpm testruns them, after vitest, on the Home Assistant thatpytest-homeassistant-custom-componentbrings;pnpm run test:python-flooron 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 ofvitest.config.ts:node, for the contract, the engine, the generator, the builder, the theme, the build, the tools and the demo; andbrowser, on jsdom, for the cards,src/ha/, the store, the editors, the panel andsrc/test/.testcomes from vitest, assertions fromnode: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.tsandtemplate-card-failure.test.tsreplacefamilies.tswithvi.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, anddrawn()renders a template into a box of its own, andstyled()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.tsis a fake connection for the store:fakeStore()answersmnml/templates/subscribewithkept(or as a home without the integration when it is unset), refuses withrefuse, sends a save withkeep(), records each message and each subscription's options, and reconnects. -
styles.test.tsasserts that every--mnml-*variable a card reads has a fallback, so the cards work under any theme. -
editors/cards.test.tsandeditors/form.test.tssend 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.tsfails 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.tsexpands every shipped template from itsexample, and again with only its required slots, and gives each MNML card it draws, at any depth, to that card'ssetConfig(): 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 declaredfields:, and every path into one to a declared field.src/templates/docs.test.tsfails whendocs/templates.mdmisses a template, a slot or a field. -
examples/cards.yamlis a hand-written configuration of every card, on thedemointegration's entities and the template entities ofexamples/fixtures.yaml.tools/installs it on a throwaway Home Assistant and looks at it under both themes.examples/templates.yamlis a second view, rooms placed byarea, whichtools/areas.tsfills by moving demo entities into two areas.
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 demotools/up.ts does the whole round, and runs again as often as wanted:
- writes
.ha/configuration.yamlthe first time (thedemointegration, the themes folder, the packages), and copiesrecipes/messages.yamlandexamples/fixtures.yamlinto.ha/packages/; - writes the demo's package with
node demo/entities.ts, and buildscustom_components/mnml/www/(pnpm build); - starts
ghcr.io/home-assistant/home-assistant:betaafresh (the beta channel, while the oldest Home Assistant supported is a beta) as the containermnml-ha, published on127.0.0.1:8124only (pnpm demotakes 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; - on a
.ha/with no owner yet, onboards it: the ownerdemo, passworddemo, and a long-lived token written to.env(git-ignored) withHA_URL=http://localhost:8124(or the port given); then waits until Home Assistant is running; - 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), andtools/dashboard.ts, which removes what an older install put in.ha/(the/local/mnmlresources,www/mnml-cards*,themes/mnml.yaml), adds the MNML entry or reloads it, saves the templates ofexamples/home-templates.yamlto the store, removes themnml-templatesdashboard an older install made, saves themnml-examplesandmnml-demodashboards, 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.pngpnpm 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.
Oxc for TypeScript, ruff and mypy for Python.
-
oxlint, withtypeAwareandtypeCheckthroughoxlint-tsgolint, lints and reports the TypeScript diagnostics. There is notscand notypescriptpackage. -
oxfmtformats;knipfinds dead code;rolldownbundles the elements, andminify-literalsminifies their templates;vitestruns the TypeScript tests, the elements' on jsdom. -
rufflints and formats the Python,mypy --stricttype 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-websocketis 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 fixrather than padding it by hand. -
Ignored paths.
ignorePatternskeeps oxfmt offuv.lock,.ha/,out/,.venv/andcustom_components/mnml/www/. All the tools honour.gitignore. -
Dependencies.
pnpm-lock.yamlanduv.lockhold the exact versions;pnpm installanduv syncinstall them, and CI'spnpm install --frozen-lockfileanduv sync --lockedrefuse a lock that has drifted.packageManagerinpackage.jsonnames 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.