Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
with:
python-version: "3.12"
- run: pip install pyyaml
- run: python scripts/check_themes.py
- run: python scripts/contrast.py
- name: LICENSE is present
run: test -s LICENSE
Expand Down
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Changelog

Each version is a GitHub release with a tag of the same name; HACS installs the release, not the branch. Theme names never change between versions, because homes store their default theme by name.

## v1.3.0

Four new themes, completing the family the owner approved in [#1](https://github.com/kaboomAE/dartec-theme/issues/1). **Dartec** and **Dartec Graphite** are unchanged, so homes that use them see nothing new.

### New themes

- **Dartec Glass**: frosted, translucent cards (`ha-card-background` with `ha-card-backdrop-filter`) over a teal gradient ground (`lovelace-background`), with 14 px corners. No photograph and no download: the ground is a gradient. The top bar stays solid. Blur is costly to draw, so Glass is for phones and capable tablets, not wall panels.
- **Dartec Glass Lite**: Glass's colours and ground with solid cards and no blur, for wall panels and slower tablets.
- **Dartec Soft**: borderless paper cards on the brand's two-layer shadow in light mode, a hairline edge instead of a shadow in dark mode, 12 px corners.
- **Dartec Material**: tonal surfaces from a Material 3 "tonal spot" scheme generated from the brand teal #1A6B6B, with the dark ground lifted off near-black; 16 px cards with no border or shadow.

Each is in `themes/dartec.yaml` beside the other two, sets light and dark in full, uses theme variables only, and names Lateef and IBM Plex Mono as Dartec and Graphite do.

### Changes from the approved drafts

- The font stack is the one v1.2.0 ships (`'Dartec Lateef', Roboto, …`). The drafts named Dubai first, whose licence does not allow it to be redistributed.
- Covers use `state-cover-active-color`, which Home Assistant reads, instead of the drafts' `state-cover-open-color`.
- Text on the light dark-mode teal is ink, not white, as in Dartec and Graphite.
- Light-mode warning is `#c96e05` in Glass, Glass Lite and Material, as in Graphite: the drafts' `#d97706` was 2.89:1, 2.99:1 and 2.75:1 on their cards, below the 3:1 for icons. The drafts' contrast table did not measure warnings.
- Info colour, the sidebar's selected item and the dark-mode code editor are set to match each theme.

### Checks

- `scripts/contrast.py` now measures views whose ground is a gradient and glass cards: every pair on a card or the page is measured against each colour of the gradient, with a translucent card blended over it, and the worst ratio counts. Dialogs, which use the solid `card-background-color`, are measured too. **252 of 252 pairs pass WCAG AA** across the six themes; Dartec's and Graphite's 84 figures are unchanged.
- New `scripts/check_themes.py`, run in CI: each file loads as Home Assistant's themes map, no key is set twice, every theme writes light and dark with the core colours, every colour value is valid, and nothing uses card-mod, `url()`, JavaScript or copper.

## v1.2.0 (2026-09-25)

- **Dartec, corrected:** the accent is brand teal instead of copper; the AC's cool, dry and fan modes, lights that are on and moving covers use brand colours; dark-mode text on teal is ink. Fonts use the `ha-font-family-*` variables Home Assistant reads since 2025.5.
- **New: Dartec Graphite**, the panel and night theme.
- MIT licence for the files (the name "Dartec" is not licensed). Requires Home Assistant 2025.5 or later.
- `scripts/contrast.py` checks WCAG AA on every change: 84 of 84 pairs pass.

## v1.1.0 (2026-08-26)

- The theme is renamed from `DarTec` to `Dartec`, per the brand guidelines. Re-apply it after updating: the theme's key changed.

## v1.0.0 (2026-08-25)

- First release: teal primary, copper accent, warm neutrals, light and dark modes.
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ The official [Dartec Smart Homes](https://dartec.ae) themes for Home Assistant,
|---|---|
| **Dartec** | The house theme: brand teal on warm cream and paper. Teal for everything interactive, a readable gold for lights that are on. |
| **Dartec Graphite** | Quiet warm-graphite surfaces with grey icons, and teal only where something is on. The theme for wall panels, and in dark mode for bedrooms at night. |
| **Dartec Glass** | Frosted, translucent cards over a deep teal gradient. The most distinctive look, and the costliest to draw: for phones and capable tablets, not wall panels. |
| **Dartec Glass Lite** | Glass's colours and gradient with solid cards and no blur, so it draws about as cheaply as an opaque theme. The Glass look for wall panels and slower tablets. |
| **Dartec Soft** | Borderless paper cards that float on a soft shadow in light mode, with a hairline edge in dark mode. |
| **Dartec Material** | Tonal surfaces generated from the brand teal with Material 3's colour method, in rounder 16 px cards with no border or shadow. |

- **Light and dark** are both written in full; Home Assistant follows the device's setting.
- **Theme variables only.** No card-mod, no JavaScript, no images, nothing downloaded.
- **WCAG AA in both modes.** Text is at least 4.5:1 and icons and controls at least 3:1, checked on every change by [`scripts/contrast.py`](scripts/contrast.py).
- **WCAG AA in both modes.** Text is at least 4.5:1 and icons and controls at least 3:1, checked on every change by [`scripts/contrast.py`](scripts/contrast.py). Where a view's ground is a gradient, or a card is glass, the worst place a card can sit is what is measured. [`scripts/check_themes.py`](scripts/check_themes.py) checks each theme's structure and every colour value.
- **No copper.** The Dartec brand reserves copper for content written by AI.

## Screenshots
Expand All @@ -31,6 +35,17 @@ Home Assistant 2026.9 on Dartec's test bench, with the themes installed through
| ![Dartec Graphite, light](docs/screenshots/graphite-overview-light-en.webp) | ![Dartec Graphite, dark](docs/screenshots/graphite-overview-dark-en.webp) |
| ![Dartec Graphite, device states, light](docs/screenshots/graphite-states-light.webp) | ![Dartec Graphite, dark, Arabic](docs/screenshots/graphite-overview-dark-ar.webp) |

Screenshots of Glass, Glass Lite, Soft and Material are still to come.

## Which theme where

- **A home's default:** Dartec.
- **Wall panels:** Dartec Graphite, or Dartec Glass Lite for the Glass look. Bedroom panels at night: Graphite in dark mode.
- **Dartec Glass** blurs what is behind every card. Drawn without a graphics chip, as a stand-in for a weak tablet, the blur multiplied the drawing work about elevenfold and dropped a third of the frames while scrolling. Keep it to phones and capable tablets until it has been measured on the real panels, and give the panels Glass Lite.
- **Soft and Material** are alternatives a family can pick for themselves.

A theme picked in a person's own profile overrides the home's default, so a panel's own account can use one theme while the family's phones use another.

## Install

Homes managed by Dartec HA Manager receive these themes automatically. To install them by hand:
Expand All @@ -41,15 +56,15 @@ Homes managed by Dartec HA Manager receive these themes automatically. To instal
frontend:
themes: !include_dir_merge_named themes
```
3. Restart Home Assistant, then pick **Dartec** or **Dartec Graphite** in your profile, or let your Dartec installer set it for the home.
3. Restart Home Assistant, then pick a Dartec theme in your profile, or let your Dartec installer set it for the home.

Since Home Assistant 2026.2, a theme chosen in a person's own profile overrides the home's default theme.

Requires Home Assistant 2025.5 or later, the first release that reads the `ha-font-family-*` variables.

## Fonts

Both themes name Dartec's Arabic face, Lateef, and IBM Plex Mono for code. A theme can name a font but cannot load one, so these appear only on pages that load them; until then Arabic uses the device's own font. Latin text is Roboto, which Home Assistant ships itself. Dubai, the brand's Latin face, is not used: its licence does not allow it to be redistributed.
Every theme names Dartec's Arabic face, Lateef, and IBM Plex Mono for code. A theme can name a font but cannot load one, so these appear only on pages that load them; until then Arabic uses the device's own font. Latin text is Roboto, which Home Assistant ships itself. Dubai, the brand's Latin face, is not used: its licence does not allow it to be redistributed.

## Licence

Expand Down
168 changes: 168 additions & 0 deletions scripts/check_themes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""Structure checks for every theme file in themes/.

python scripts/check_themes.py # exit 1 on any problem

Each file must load as Home Assistant's `frontend: themes:` map: theme names
mapping to string variables, with an optional `modes` holding `light` and
`dark`, each a map of strings (the frontend's THEME_SCHEMA). On top of that,
for these themes:

- no key appears twice in any map (PyYAML would silently keep the last one);
- every theme writes both light and dark, and each mode sets the core colours;
- every colour value is valid: a hex colour, rgb()/rgba() in range, var(), or a
gradient made of those;
- theme variables only: no card-mod, no JavaScript, no url() (no images or
downloads);
- no copper, which the brand reserves for content written by AI.
"""
import pathlib
import re
import sys

import yaml

ROOT = pathlib.Path(__file__).resolve().parent.parent

# Every mode of every theme sets these itself rather than inheriting them.
CORE = [
"primary-background-color", "secondary-background-color", "card-background-color",
"primary-text-color", "secondary-text-color", "disabled-text-color", "divider-color",
"primary-color", "accent-color", "text-primary-color",
"state-icon-color", "state-active-color",
"error-color", "warning-color", "success-color",
"sidebar-background-color",
]

# The copper accents of v1.1.0 (light and dark).
COPPER = {"#a8551f", "#e08b52"}

HEX = r"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})(?![0-9a-zA-Z])"
NUM = r"\s*(\d+(?:\.\d+)?)\s*"
RGB = rf"rgba?\({NUM},{NUM},{NUM}(?:,{NUM})?\)"
VAR = r"var\(--[a-z0-9_-]+(?:\s*,\s*[^()]+)?\)"
GRADIENT = re.compile(r"(?:repeating-)?(?:linear|radial|conic)-gradient\(")


class UniqueKeyLoader(yaml.SafeLoader):
pass


def _no_duplicates(loader, node, deep=False):
seen = {}
for key_node, _ in node.value:
key = loader.construct_object(key_node, deep=deep)
if key in seen:
raise yaml.constructor.ConstructorError(
None, None, f"duplicate key {key!r} (first at line {seen[key] + 1})",
key_node.start_mark)
seen[key] = key_node.start_mark.line
return loader.construct_mapping(node, deep)


UniqueKeyLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_duplicates)


def colour_problems(key, value):
"""Problems with the colours in one value; [] if it holds none or all are valid."""
problems = []
for m in re.finditer(r"#[0-9a-zA-Z]+", value):
if not re.fullmatch(HEX, m.group()):
problems.append(f"{key}: {m.group()!r} is not a hex colour")
for m in re.finditer(r"rgba?\([^)]*\)", value):
rm = re.fullmatch(RGB, m.group())
if not rm:
problems.append(f"{key}: {m.group()!r} is not a valid rgb()/rgba()")
continue
r, g, b, a = rm.groups()
if not all(0 <= float(c) <= 255 for c in (r, g, b)):
problems.append(f"{key}: {m.group()!r} has a channel outside 0-255")
if m.group().startswith("rgba(") and a is None:
problems.append(f"{key}: {m.group()!r} has no alpha")
if a is not None and not 0 <= float(a) <= 1:
problems.append(f"{key}: {m.group()!r} has an alpha outside 0-1")
if key.endswith("-color") and not re.fullmatch(rf"{HEX}|{RGB}|{VAR}", value.strip()):
problems.append(f"{key}: {value!r} is not a single colour")
if key.endswith("-background") and not (
re.fullmatch(rf"{HEX}|{RGB}|{VAR}|none|transparent", value.strip())
or GRADIENT.match(value.strip())):
problems.append(f"{key}: {value!r} is neither a colour nor a gradient")
if GRADIENT.match(value.strip()) and value.count("(") != value.count(")"):
problems.append(f"{key}: unbalanced parentheses")
return problems


def check_vars(where, mapping):
problems = []
if not isinstance(mapping, dict) or not mapping:
return [f"{where}: must be a non-empty map"]
for key, value in mapping.items():
if not isinstance(key, str) or not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", key):
problems.append(f"{where}: {key!r} is not a theme variable name")
continue
if not isinstance(value, str):
problems.append(f"{where}: {key} must be a quoted string, got {type(value).__name__}")
continue
low = value.lower()
if "card-mod" in key or "card-mod" in low:
problems.append(f"{where}: {key} uses card-mod")
if "url(" in low or "javascript" in low or "<" in low:
problems.append(f"{where}: {key} loads something ({value!r})")
for hex_ in re.findall(HEX, low):
if hex_ in COPPER:
problems.append(f"{where}: {key} is copper ({hex_})")
problems += [f"{where}: {p}" for p in colour_problems(key, value)]
return problems


def check_file(path):
try:
data = yaml.load(path.read_text(encoding="utf-8"), Loader=UniqueKeyLoader)
except yaml.YAMLError as e:
return [f"{path.name}: {e}"], []
if not isinstance(data, dict) or not data:
return [f"{path.name}: must be a map of theme names"], []
problems, names = [], []
for name, theme in data.items():
names.append(name)
where = f"{path.name}: {name}"
if not isinstance(name, str) or not name.startswith("Dartec"):
problems.append(f"{where}: theme names start with 'Dartec'")
if not isinstance(theme, dict):
problems.append(f"{where}: must be a map")
continue
modes = theme.get("modes")
base = {k: v for k, v in theme.items() if k != "modes"}
problems += check_vars(f"{where}", base)
if not isinstance(modes, dict) or set(modes) != {"light", "dark"}:
problems.append(f"{where}: modes must be exactly light and dark")
continue
for mode in ("light", "dark"):
problems += check_vars(f"{where} ({mode})", modes[mode])
if isinstance(modes[mode], dict):
missing = [k for k in CORE if k not in modes[mode]]
if missing:
problems.append(f"{where} ({mode}): does not set {', '.join(missing)}")
return problems, names


def main():
files = sorted((ROOT / "themes").glob("*.yaml"))
if not files:
print("no theme files in themes/")
return 1
problems, names = [], []
for path in files:
p, n = check_file(path)
problems += p
names += n
if len(names) != len(set(names)):
problems.append("a theme name is used twice across files")
for p in problems:
print(f"FAIL {p}")
print(f"{len(names)} themes in {len(files)} file(s): {', '.join(names)}")
print("All structure checks pass." if not problems else f"{len(problems)} problem(s).")
return 1 if problems else 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading