diff --git a/custom_components/taskmate/coord_setup.py b/custom_components/taskmate/coord_setup.py index 1627718c..a370da12 100644 --- a/custom_components/taskmate/coord_setup.py +++ b/custom_components/taskmate/coord_setup.py @@ -100,6 +100,7 @@ async def async_apply_setup_wizard( "avatar": spec.get("avatar") or "mdi:account-circle", "birthday": normalize_birthday(spec.get("birthday", "")), "age_group": _age_group(spec.get("age_group")), + "color": spec.get("color") or "", } ) @@ -161,6 +162,7 @@ def resolve(who: str) -> str: avatar=spec["avatar"], birthday=spec["birthday"], age_group=spec["age_group"], + color=spec["color"], refresh=False, ) ref_to_id[spec["ref"]] = child.id diff --git a/custom_components/taskmate/coordinator.py b/custom_components/taskmate/coordinator.py index 9e3b6ee8..0adf844e 100644 --- a/custom_components/taskmate/coordinator.py +++ b/custom_components/taskmate/coordinator.py @@ -991,6 +991,7 @@ async def async_add_child( presence_entity: str = "", age_group: str = "", is_adult: bool = False, + color: str = "", refresh: bool = True, ) -> Child: """Add a new child. @@ -1011,6 +1012,7 @@ async def async_add_child( presence_entity=presence_entity, age_group=age_group, is_adult=is_adult, + color=color, ) self.storage.add_child(child) await self.storage.async_save() diff --git a/custom_components/taskmate/models.py b/custom_components/taskmate/models.py index 202b64aa..3bc61dd4 100644 --- a/custom_components/taskmate/models.py +++ b/custom_components/taskmate/models.py @@ -257,6 +257,9 @@ class Child: # A grown-up's profile (#1061): their own completions never wait for # approval, and cards leave them out unless a card opts in (show_adults). is_adult: bool = False + # The child's colour (#1078): "#rrggbb", or "" to fall back to the + # panel's palette by position (every child that predates it). + color: str = "" id: str = field(default_factory=generate_id) @classmethod @@ -284,6 +287,7 @@ def from_dict(cls, data: dict[str, Any]) -> Child: birthday=str(data.get("birthday", "") or ""), age_group=normalize_age_group(data.get("age_group")), is_adult=bool(data.get("is_adult", False)), + color=str(data.get("color", "") or ""), availability_entity=data.get("availability_entity", ""), availability_inverted=data.get("availability_inverted", False), unavailability_entity=data.get("unavailability_entity", ""), @@ -324,6 +328,7 @@ def to_dict(self) -> dict[str, Any]: "birthday": self.birthday, "age_group": self.age_group, "is_adult": self.is_adult, + "color": self.color, "availability_entity": self.availability_entity, "availability_inverted": self.availability_inverted, "unavailability_entity": self.unavailability_entity, diff --git a/custom_components/taskmate/websocket.py b/custom_components/taskmate/websocket.py index 77f7d91f..839a24f1 100644 --- a/custom_components/taskmate/websocket.py +++ b/custom_components/taskmate/websocket.py @@ -361,6 +361,20 @@ def _validate_presence_entity(value): return value +_HEX_COLOR = re.compile(r"#[0-9a-f]{6}") + + +def validate_child_color(value): + """voluptuous validator: '' (palette default) or a "#rrggbb" colour (#1078). + + Strict hex only — the panel drops it straight into a style attribute. + """ + value = _opt_str(value).lower() + if value and not _HEX_COLOR.fullmatch(value): + raise vol.Invalid("color must be a #rrggbb hex colour") + return value + + # --------------------------------------------------------------------------- # State snapshot # --------------------------------------------------------------------------- @@ -496,6 +510,7 @@ async def _ws_get_state(hass, connection, msg, coordinator): vol.Optional("presence_entity", default=""): _validate_presence_entity, vol.Optional("age_group", default=""): vol.In(("", *AGE_GROUPS)), vol.Optional("is_adult", default=False): bool, + vol.Optional("color", default=""): validate_child_color, } ) @websocket_api.async_response @@ -519,6 +534,7 @@ async def _ws_add_child(hass, connection, msg, coordinator): presence_entity=msg.get("presence_entity", ""), age_group=msg.get("age_group", ""), is_adult=bool(msg.get("is_adult", False)), + color=msg.get("color", ""), ) connection.send_result(msg["id"], {"id": child.id}) @@ -541,6 +557,7 @@ async def _ws_add_child(hass, connection, msg, coordinator): vol.Optional("birthday"): vol.All(str, vol.Length(max=10)), vol.Optional("age_group"): vol.In(("", *AGE_GROUPS)), vol.Optional("is_adult"): bool, + vol.Optional("color"): validate_child_color, } ) @websocket_api.async_response @@ -578,6 +595,8 @@ async def _ws_update_child(hass, connection, msg, coordinator): existing.age_group = msg["age_group"] if "is_adult" in msg: existing.is_adult = bool(msg["is_adult"]) + if "color" in msg: + existing.color = msg["color"] if "is_guest" in msg or "guest_expires_on" in msg: # Routed through the coordinator so the expiry is validated and an # archived guest is un-archived when promoted to a family member. diff --git a/custom_components/taskmate/websocket_setup.py b/custom_components/taskmate/websocket_setup.py index a0e2ffbf..f9135609 100644 --- a/custom_components/taskmate/websocket_setup.py +++ b/custom_components/taskmate/websocket_setup.py @@ -15,7 +15,7 @@ from .const import AGE_GROUPS, SCHEDULE_MODES from .coord_birthdays import normalize_birthday, parse_birthday from .setup_catalogue import setup_catalogue -from .websocket import WS_SETUP_APPLY, WS_SETUP_CATALOGUE, _admin_only +from .websocket import WS_SETUP_APPLY, WS_SETUP_CATALOGUE, _admin_only, validate_child_color # Generous ceilings: a wizard run is a handful of children and a few dozen # chores; these only stop a runaway payload. @@ -32,6 +32,7 @@ vol.Optional("avatar", default="mdi:account-circle"): vol.All(str, vol.Length(max=80)), vol.Optional("birthday", default=""): vol.All(str, vol.Length(max=10)), vol.Optional("age_group", default=""): _AGE_GROUP, + vol.Optional("color", default=""): validate_child_color, } _CHILD_UPDATE_SCHEMA = { diff --git a/custom_components/taskmate/www/locales/da.json b/custom_components/taskmate/www/locales/da.json index 831ba490..3d41f54c 100644 --- a/custom_components/taskmate/www/locales/da.json +++ b/custom_components/taskmate/www/locales/da.json @@ -945,6 +945,9 @@ "panel.child_age_group_hint": "Bestemmer, hvilke pligter opsætningsguiden foreslår. Ændrer ikke andet.", "panel.child_adult_label": "Voksenprofil", "panel.child_adult_hint": "Til en forælder eller anden voksen. Pligter, de markerer som udført, tæller med det samme uden godkendelse, og kort viser dem kun, når kortets indstilling \"Vis voksne\" er slået til.", + "panel.child_color_label": "Farve", + "panel.child_color_hint": "Markerer barnets avatar og navn i adminpanelet. Automatisk vælger en ud fra barnets plads på listen.", + "panel.child_color_auto": "Automatisk", "panel.child_adult_pill": "Voksen", "wizard.title": "Opsætningsguide", "wizard.run_btn": "Kør opsætningsguiden", diff --git a/custom_components/taskmate/www/locales/de.json b/custom_components/taskmate/www/locales/de.json index 77199dd5..d7052aff 100644 --- a/custom_components/taskmate/www/locales/de.json +++ b/custom_components/taskmate/www/locales/de.json @@ -893,6 +893,9 @@ "panel.child_age_group_hint": "Bestimmt, welche Aufgaben der Einrichtungsassistent vorschlägt. Ändert sonst nichts.", "panel.child_adult_label": "Erwachsenenprofil", "panel.child_adult_hint": "Für ein Elternteil oder einen anderen Erwachsenen. Aufgaben, die sie als erledigt markieren, zählen sofort ohne Genehmigung, und Karten zeigen sie nur, wenn die Kartenoption „Erwachsene anzeigen“ aktiviert ist.", + "panel.child_color_label": "Farbe", + "panel.child_color_hint": "Kennzeichnet Avatar und Namen dieses Kindes im Admin-Bereich. „Automatisch“ wählt eine Farbe nach der Position in der Liste.", + "panel.child_color_auto": "Automatisch", "panel.child_adult_pill": "Erwachsen", "wizard.title": "Einrichtungsassistent", "wizard.run_btn": "Einrichtungsassistent starten", diff --git a/custom_components/taskmate/www/locales/en-GB.json b/custom_components/taskmate/www/locales/en-GB.json index 20b7f9ac..2ee3efdf 100644 --- a/custom_components/taskmate/www/locales/en-GB.json +++ b/custom_components/taskmate/www/locales/en-GB.json @@ -945,6 +945,9 @@ "panel.child_age_group_hint": "Decides which chores the setup wizard suggests. Changes nothing else.", "panel.child_adult_label": "Adult profile", "panel.child_adult_hint": "For a parent or other grown-up. Chores they mark done count straight away with no approval, and cards leave them out unless the card's \"Show adults\" option is on.", + "panel.child_color_label": "Colour", + "panel.child_color_hint": "Marks this child's avatar and name in the admin panel. Automatic picks one from their place in the list.", + "panel.child_color_auto": "Automatic", "panel.child_adult_pill": "Adult", "wizard.title": "Setup wizard", "wizard.run_btn": "Run setup wizard", diff --git a/custom_components/taskmate/www/locales/en.json b/custom_components/taskmate/www/locales/en.json index 20b7f9ac..2ee3efdf 100644 --- a/custom_components/taskmate/www/locales/en.json +++ b/custom_components/taskmate/www/locales/en.json @@ -945,6 +945,9 @@ "panel.child_age_group_hint": "Decides which chores the setup wizard suggests. Changes nothing else.", "panel.child_adult_label": "Adult profile", "panel.child_adult_hint": "For a parent or other grown-up. Chores they mark done count straight away with no approval, and cards leave them out unless the card's \"Show adults\" option is on.", + "panel.child_color_label": "Colour", + "panel.child_color_hint": "Marks this child's avatar and name in the admin panel. Automatic picks one from their place in the list.", + "panel.child_color_auto": "Automatic", "panel.child_adult_pill": "Adult", "wizard.title": "Setup wizard", "wizard.run_btn": "Run setup wizard", diff --git a/custom_components/taskmate/www/locales/fr.json b/custom_components/taskmate/www/locales/fr.json index 8eedac16..52c84d25 100644 --- a/custom_components/taskmate/www/locales/fr.json +++ b/custom_components/taskmate/www/locales/fr.json @@ -893,6 +893,9 @@ "panel.child_age_group_hint": "Détermine les tâches suggérées par l'assistant de configuration. Ne change rien d'autre.", "panel.child_adult_label": "Profil adulte", "panel.child_adult_hint": "Pour un parent ou un autre adulte. Les tâches qu'il marque comme faites comptent tout de suite, sans validation, et les cartes ne l'affichent que si l'option « Afficher les adultes » de la carte est activée.", + "panel.child_color_label": "Couleur", + "panel.child_color_hint": "Repère l'avatar et le nom de cet enfant dans le panneau d'administration. « Automatique » en choisit une selon sa place dans la liste.", + "panel.child_color_auto": "Automatique", "panel.child_adult_pill": "Adulte", "wizard.title": "Assistant de configuration", "wizard.run_btn": "Lancer l'assistant de configuration", diff --git a/custom_components/taskmate/www/locales/nb.json b/custom_components/taskmate/www/locales/nb.json index f5cecfbf..f4f5bd41 100644 --- a/custom_components/taskmate/www/locales/nb.json +++ b/custom_components/taskmate/www/locales/nb.json @@ -893,6 +893,9 @@ "panel.child_age_group_hint": "Bestemmer hvilke oppgaver oppsettveiviseren foreslår. Endrer ikke noe annet.", "panel.child_adult_label": "Voksenprofil", "panel.child_adult_hint": "For en forelder eller en annen voksen. Oppgaver de markerer som gjort, teller med en gang uten godkjenning, og kort viser dem bare når kortets valg «Vis voksne» er slått på.", + "panel.child_color_label": "Farge", + "panel.child_color_hint": "Markerer barnets avatar og navn i adminpanelet. Automatisk velger en ut fra plassen i lista.", + "panel.child_color_auto": "Automatisk", "panel.child_adult_pill": "Voksen", "wizard.title": "Oppsettveiviser", "wizard.run_btn": "Kjør oppsettveiviseren", diff --git a/custom_components/taskmate/www/locales/nn.json b/custom_components/taskmate/www/locales/nn.json index d207d552..0e99dd07 100644 --- a/custom_components/taskmate/www/locales/nn.json +++ b/custom_components/taskmate/www/locales/nn.json @@ -893,6 +893,9 @@ "panel.child_age_group_hint": "Avgjer kva oppgåver oppsettsvegvisaren føreslår. Endrar ikkje noko anna.", "panel.child_adult_label": "Vaksenprofil", "panel.child_adult_hint": "For ein forelder eller ein annan vaksen. Oppgåver dei markerer som gjort, tel med ein gong utan godkjenning, og kort viser dei berre når kortet sitt val «Vis vaksne» er slått på.", + "panel.child_color_label": "Farge", + "panel.child_color_hint": "Markerer avataren og namnet til barnet i adminpanelet. Automatisk vel ein ut frå plassen i lista.", + "panel.child_color_auto": "Automatisk", "panel.child_adult_pill": "Vaksen", "wizard.title": "Oppsettsvegvisar", "wizard.run_btn": "Køyr oppsettsvegvisaren", diff --git a/custom_components/taskmate/www/locales/pl.json b/custom_components/taskmate/www/locales/pl.json index d42b6d67..2fb6a20a 100644 --- a/custom_components/taskmate/www/locales/pl.json +++ b/custom_components/taskmate/www/locales/pl.json @@ -945,6 +945,9 @@ "panel.child_age_group_hint": "Decyduje, jakie obowiązki proponuje kreator. Nie zmienia niczego innego.", "panel.child_adult_label": "Profil dorosłego", "panel.child_adult_hint": "Dla rodzica lub innej dorosłej osoby. Obowiązki, które oznaczy jako wykonane, liczą się od razu bez zatwierdzania, a karty pokazują tę osobę tylko wtedy, gdy w karcie włączona jest opcja „Pokaż dorosłych”.", + "panel.child_color_label": "Kolor", + "panel.child_color_hint": "Oznacza awatar i imię dziecka w panelu administracyjnym. „Automatycznie” wybiera kolor według miejsca na liście.", + "panel.child_color_auto": "Automatycznie", "panel.child_adult_pill": "Dorosły", "wizard.title": "Kreator konfiguracji", "wizard.run_btn": "Uruchom kreator konfiguracji", diff --git a/custom_components/taskmate/www/locales/pt-BR.json b/custom_components/taskmate/www/locales/pt-BR.json index e69003c5..f1e49673 100644 --- a/custom_components/taskmate/www/locales/pt-BR.json +++ b/custom_components/taskmate/www/locales/pt-BR.json @@ -893,6 +893,9 @@ "panel.child_age_group_hint": "Define quais tarefas o assistente de configuração sugere. Não muda mais nada.", "panel.child_adult_label": "Perfil de adulto", "panel.child_adult_hint": "Para um pai, mãe ou outro adulto. As tarefas que ele marcar como feitas contam na hora, sem aprovação, e os cartões só o mostram quando a opção \"Mostrar adultos\" do cartão está ativada.", + "panel.child_color_label": "Cor", + "panel.child_color_hint": "Destaca o avatar e o nome desta criança no painel de administração. \"Automática\" escolhe uma cor pela posição na lista.", + "panel.child_color_auto": "Automática", "panel.child_adult_pill": "Adulto", "wizard.title": "Assistente de configuração", "wizard.run_btn": "Executar assistente de configuração", diff --git a/custom_components/taskmate/www/locales/pt.json b/custom_components/taskmate/www/locales/pt.json index ccaf2824..3f43d9e2 100644 --- a/custom_components/taskmate/www/locales/pt.json +++ b/custom_components/taskmate/www/locales/pt.json @@ -898,6 +898,9 @@ "panel.child_age_group_hint": "Decide que tarefas o assistente de configuração sugere. Não altera mais nada.", "panel.child_adult_label": "Perfil de adulto", "panel.child_adult_hint": "Para um pai, mãe ou outro adulto. As tarefas que marcar como feitas contam logo, sem aprovação, e os cartões só o mostram quando a opção \"Mostrar adultos\" do cartão está ativa.", + "panel.child_color_label": "Cor", + "panel.child_color_hint": "Assinala o avatar e o nome desta criança no painel de administração. «Automática» escolhe uma cor conforme a posição na lista.", + "panel.child_color_auto": "Automática", "panel.child_adult_pill": "Adulto", "wizard.title": "Assistente de configuração", "wizard.run_btn": "Executar assistente de configuração", diff --git a/custom_components/taskmate/www/taskmate-panel.js b/custom_components/taskmate/www/taskmate-panel.js index 5c322a71..bdf3380d 100644 --- a/custom_components/taskmate/www/taskmate-panel.js +++ b/custom_components/taskmate/www/taskmate-panel.js @@ -181,6 +181,7 @@ const WZ_REVIEW = WZ_STEPS.indexOf("review"); const WZ_DONE = WZ_STEPS.indexOf("done"); const WZ_KID_ICONS = ["mdi:star", "mdi:heart", "mdi:paw", "mdi:white-balance-sunny", "mdi:weather-night", "mdi:trophy", "mdi:leaf", "mdi:creation", "mdi:fire", "mdi:medal", "mdi:book-open-variant", "mdi:car-sports"]; +const CHILD_AUTO_COLORS = ["#e91e63", "#2196f3", "#009688", "#ff9800", "#9c27b0", "#795548", "#3f51b5", "#8bc34a"]; const WZ_KID_COLORS = ["#ff6b9d", "#3498db", "#1abc9c", "#9b59b6", "#e67e22", "#f1c40f", "#e74c3c", "#2ecc71"]; const WZ_POINT_NAMES = [["points", "mdi:star"], ["stars", "mdi:star"], ["coins", "mdi:circle-multiple"], ["gems", "mdi:diamond-stone"]]; const WZ_DRAFT_KEY = "taskmate-setup-draft"; @@ -795,6 +796,16 @@ class TaskMatePanel extends HTMLElement { if (act === "bounty-icon") { this._bountyDialogSet("icon", t.dataset.id); return; } if (act === "bounty-exp") { this._bountyDialogSet("exp_mode", t.dataset.id); return; } if (act === "bounty-el-all") { this._bountyDialogSet("eligible_child_ids", []); return; } + if (act === "child-color" && this._dialog?.data) { + this._syncIconPickers(); + this._dialog.data.color = t.dataset.v || ""; + const body = this.querySelector(".tm-dialog-body"); + const scrollY = body ? body.scrollTop : 0; + this._render(); + const b = this.querySelector(".tm-dialog-body"); + if (b) b.scrollTop = scrollY; + return; + } if (act === "toggle-bounty-el") { this._syncIconPickers(); this._toggleArrayField("eligible_child_ids", t.dataset.id); return; } // Chore auctions (#982) @@ -1981,6 +1992,7 @@ class TaskMatePanel extends HTMLElement { birthday: c.birthday || "", age_group: c.age_group || "", is_adult: !!c.is_adult, + color: c.color || "", presence_entity: c.presence_entity || "", // Kiosk PIN (#930): write-only. The panel only knows whether one is set. kiosk_pin: "", kiosk_pin_clear: false, @@ -1992,7 +2004,7 @@ class TaskMatePanel extends HTMLElement { availability_inverted: false, unavailability_entity: "", pause_streak_when_unavailable: false, linked_user_id: "", picture_entity: "", presence_entity: "", - birthday: "", age_group: "", is_adult: false, + birthday: "", age_group: "", is_adult: false, color: "", kiosk_pin: "", kiosk_pin_clear: false, has_kiosk_pin: false, } }); } @@ -2042,14 +2054,14 @@ class TaskMatePanel extends HTMLElement { unavailability_entity: d.unavailability_entity || "", pause_streak_when_unavailable: !!d.pause_streak_when_unavailable, linked_user_id: d.linked_user_id || "", birthday: (d.birthday || "").trim(), age_group: d.age_group || "", is_adult: !!d.is_adult, - presence_entity: d.presence_entity || "", + color: d.color || "", presence_entity: d.presence_entity || "", picture_entity: d.picture_entity || "" } : { type: "taskmate/update_child", child_id: d.id, name: d.name.trim(), avatar: d.avatar || "mdi:account-circle", availability_entity: d.availability_entity || "", availability_inverted: !!d.availability_inverted, unavailability_entity: d.unavailability_entity || "", pause_streak_when_unavailable: !!d.pause_streak_when_unavailable, linked_user_id: d.linked_user_id || "", birthday: (d.birthday || "").trim(), age_group: d.age_group || "", is_adult: !!d.is_adult, - presence_entity: d.presence_entity || "", + color: d.color || "", presence_entity: d.presence_entity || "", picture_entity: d.picture_entity || "" }; const { ok, err, res } = await this._callWS(payload); if (!ok) { this._showToast("err", this._t("panel.toast_save_failed", {error: err})); return; } @@ -3864,10 +3876,11 @@ class TaskMatePanel extends HTMLElement { return !Array.isArray(who) || who.length === 0 || who.includes(id); } + // The child's own colour (#1078), else one from the palette by position. _childColor(childId) { - const palette = ["#e91e63", "#2196f3", "#009688", "#ff9800", "#9c27b0", "#795548", "#3f51b5", "#8bc34a"]; - const idx = (this._state && this._state.children || []).findIndex(c => c.id === childId); - return palette[(idx < 0 ? 0 : idx) % palette.length]; + const child = ((this._state && this._state.children) || []).find(c => c.id === childId); + const own = (child && child.color) || ""; + return /^#[0-9a-f]{6}$/i.test(own) ? own : this._childColorAuto(childId); } _childAvatar(child, cls = "") { @@ -4500,7 +4513,7 @@ class TaskMatePanel extends HTMLElement { return `
-
${this._mdi(child.avatar)}
+
${this._mdi(child.avatar)}

${this._esc(child.name || this._t("panel.child_unnamed"))}${child.is_adult ? ` ${this._t("panel.child_adult_pill")}` : ""}

${this._idBadge(child.id)} @@ -5420,7 +5433,7 @@ class TaskMatePanel extends HTMLElement { _wizardPlan() { const wz = this._wz; const children = wz.kids.filter(k => !k.existing).map(k => ({ - ref: k.key, name: k.name.trim(), avatar: k.icon, + ref: k.key, name: k.name.trim(), avatar: k.icon, color: k.color || "", birthday: k.mode === "bday" ? k.bday : "", age_group: this._wizardBand(k), })); @@ -10072,6 +10085,7 @@ class TaskMatePanel extends HTMLElement { [ this._field(this._t("panel.child_name_label"), "name", d.name, "text", this._t("panel.child_name_placeholder")), this._iconPickerField(this._t("panel.child_avatar_label"), "avatar", d.avatar), + this._childColorField(d), // A grown-up's profile (#1061): auto-approved, hidden from cards by default. this._switch(this._t("panel.child_adult_label"), "is_adult", !!d.is_adult, this._esc(this._t("panel.child_adult_hint"))), @@ -10108,6 +10122,30 @@ class TaskMatePanel extends HTMLElement { ); } + /** The child's colour (#1078): "Automatic" (palette by position) or a swatch. */ + _childColorField(d) { + const cur = d.color || ""; + const swatch = (v, style, label, inner = "") => ``; + const auto = this._t("panel.child_color_auto"); + return ` +
+ ${this._esc(this._t("panel.child_color_label"))} +
+ ${swatch("", `--sw:${this._childColorAuto(d.id)}`, auto, '')} + ${WZ_KID_COLORS.map(c => swatch(c, `background:${c}`, c)).join("")} +
+ ${this._esc(this._t("panel.child_color_hint"))} +
`; + } + + /** The palette colour a child gets by position; with no id, the next child added. */ + _childColorAuto(childId) { + const children = (this._state && this._state.children) || []; + const idx = childId ? Math.max(0, children.findIndex(c => c.id === childId)) : children.length; + return CHILD_AUTO_COLORS[idx % CHILD_AUTO_COLORS.length]; + } + /** Kiosk PIN (#930): a masked 4-digit input, plus "remove" once one is set. */ _kioskPinField(d) { const clearing = !!(d.has_kiosk_pin && d.kiosk_pin_clear); @@ -11886,6 +11924,7 @@ class TaskMatePanel extends HTMLElement { color: var(--tm-accent); } .tm-avatar ha-icon { --mdc-icon-size: 24px; } + .tm-avatar-kid { background: color-mix(in srgb, var(--kc), transparent 80%); border-color: color-mix(in srgb, var(--kc), transparent 55%); color: var(--kc); } .tm-bounty-cell { display: flex; align-items: center; gap: 12px; } .tm-bounty-ic { width: 34px; height: 34px; border-radius: 50%; color: var(--tm-gold, #d4ac0d); } .tm-bounty-ic ha-icon { --mdc-icon-size: 18px; } @@ -12921,6 +12960,10 @@ class TaskMatePanel extends HTMLElement { border-top: 1px solid var(--tm-border-soft); } .tm-check-row:first-child { border-top: 0; padding-top: 0; } + .tm-swatches { display: flex; gap: 8px; flex-wrap: wrap; margin: 4px 0; } + .tm-swatches button { width: 28px; height: 28px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; padding: 0; display: grid; place-items: center; } + .tm-swatches button.tm-sw-auto { background: color-mix(in srgb, var(--sw), transparent 75%); border-color: var(--sw); color: var(--tm-text); --mdc-icon-size: 16px; } + .tm-swatches button.tm-on { box-shadow: 0 0 0 2px var(--tm-surface-0), 0 0 0 4px var(--tm-text); } .tm-check-row > div { flex: 1; } .tm-check-title { font-size: 13px; color: var(--tm-text); font-weight: 500; } diff --git a/tests/test_child_color.py b/tests/test_child_color.py new file mode 100644 index 00000000..0269a7d8 --- /dev/null +++ b/tests/test_child_color.py @@ -0,0 +1,88 @@ +"""A child's colour (#1078). + +The setup wizard asked for a colour per child but never saved it, and the +Children dialog had nowhere to set one, so the panel coloured children by +their place in the list. The colour is now stored on the child. +""" + +from __future__ import annotations + +import pytest +import voluptuous as vol + +from custom_components.taskmate import websocket as ws +from custom_components.taskmate import websocket_setup +from custom_components.taskmate.models import Child + +from .test_setup_wizard import ROOT, _ws_env, coord, run # noqa: F401 (coord is a fixture) + + +class TestModel: + def test_round_trips(self): + assert Child.from_dict(Child(name="Isla", color="#3498db").to_dict()).color == "#3498db" + + def test_children_saved_before_it_have_none(self): + assert Child.from_dict({"name": "Old"}).color == "" + + +class TestValidator: + @pytest.mark.parametrize(("value", "expected"), [("", ""), ("#3498DB", "#3498db"), (" #1abc9c ", "#1abc9c")]) + def test_accepts_hex_or_empty(self, value, expected): + assert ws.validate_child_color(value) == expected + + @pytest.mark.parametrize("value", ["#fff", "red", "#12345g", "3498db", "#3498db;background:url(x)", '#3498db"x']) + def test_rejects_anything_else(self, value): + # The panel puts it straight into a style attribute. + with pytest.raises(vol.Invalid): + ws.validate_child_color(value) + + def test_every_child_command_validates_it(self): + src = (ROOT / "custom_components" / "taskmate" / "websocket.py").read_text(encoding="utf-8") + assert src.count('vol.Optional("color", default=""): validate_child_color') == 1 # add_child + assert src.count('vol.Optional("color"): validate_child_color') == 1 # update_child + child = vol.Schema(websocket_setup._CHILD_SCHEMA) + assert child({"ref": "n", "name": "Isla"})["color"] == "" + with pytest.raises(vol.Invalid): + child({"ref": "n", "name": "Isla", "color": "red"}) + + +def test_wizard_saves_the_picked_colour(coord): # noqa: F811 + run( + coord.async_apply_setup_wizard( + children=[ + {"ref": "new1", "name": "Malia", "color": "#ff6b9d"}, + {"ref": "new2", "name": "Vaiha"}, + ] + ) + ) + kids = {c.name: c for c in coord.storage.get_children()} + assert kids["Malia"].color == "#ff6b9d" + assert kids["Vaiha"].color == "" + + +def test_add_child_saves_the_colour(coord): # noqa: F811 + hass, conn = _ws_env(coord) + run(ws._ws_add_child(hass, conn, {"id": 1, "type": "taskmate/add_child", "name": "Isla", "color": "#9b59b6"})) + assert coord.storage.get_children()[0].color == "#9b59b6" + + +def test_update_child_changes_and_clears_the_colour(coord): # noqa: F811 + coord.storage._data["children"] = [Child(name="Malia", id="k1", color="#ff6b9d").to_dict()] + hass, conn = _ws_env(coord) + run( + ws._ws_update_child( + hass, conn, {"id": 1, "type": "taskmate/update_child", "child_id": "k1", "color": "#2ecc71"} + ) + ) + assert coord.storage.get_child("k1").color == "#2ecc71" + run(ws._ws_update_child(hass, conn, {"id": 2, "type": "taskmate/update_child", "child_id": "k1", "name": "Malia"})) + assert coord.storage.get_child("k1").color == "#2ecc71" # left alone when not sent + run(ws._ws_update_child(hass, conn, {"id": 3, "type": "taskmate/update_child", "child_id": "k1", "color": ""})) + assert coord.storage.get_child("k1").color == "" + + +def test_panel_uses_the_stored_colour_and_the_wizard_sends_it(): + src = (ROOT / "custom_components" / "taskmate" / "www" / "taskmate-panel.js").read_text(encoding="utf-8") + assert "child.color" in src and "this._childColorAuto(childId)" in src + assert 'data-act="child-color"' in src + assert 'avatar: k.icon, color: k.color || ""' in src