@@ -116,6 +125,14 @@ class TaskMateFamilyGoalCard extends LitElement {
`;
}
+ _head(icon, title) {
+ return html`
+
+
+ ${title}
+
`;
+ }
+
static get styles() {
// Layout classes carry an fg- prefix because the shared design kit defines
// its own .bar (and .stat / .grid / …); a bare .bar here would pick up the
@@ -153,7 +170,7 @@ class TaskMateFamilyGoalCard extends LitElement {
.fg-reward.done { font-weight: 600; color: var(--tmd-gold, #d4ac0d); }
.fg-empty {
display: flex; flex-direction: column; align-items: center; gap: 8px;
- padding: 28px 16px; color: var(--tmd-dim, var(--secondary-text-color));
+ padding: 16px 16px 24px; color: var(--tmd-dim, var(--secondary-text-color));
}
`;
const tokens = window.__taskmate_design && window.__taskmate_design.styles
diff --git a/custom_components/taskmate/www/taskmate-photo-gallery-card.js b/custom_components/taskmate/www/taskmate-photo-gallery-card.js
index 307c25bf..c75a6ce1 100644
--- a/custom_components/taskmate/www/taskmate-photo-gallery-card.js
+++ b/custom_components/taskmate/www/taskmate-photo-gallery-card.js
@@ -46,6 +46,15 @@ class TaskMatePhotoGalleryCard extends LitElement {
return 4;
}
+ static async getConfigElement() {
+ // The editor ships as its own module so dashboards never parse it.
+ await Promise.all([
+ import(`./editors/taskmate-photo-gallery-card-editor.js${new URL(import.meta.url).search}`),
+ window.__taskmate_loadTranslations?.(null, ["editor"]),
+ ]);
+ return document.createElement("taskmate-photo-gallery-card-editor");
+ }
+
_t(key, params) {
const fn = window.__taskmate_localize;
return fn ? fn(this.hass, key, params) : key;
@@ -114,16 +123,20 @@ class TaskMatePhotoGalleryCard extends LitElement {
if (!this.hass || !this.config) return html``;
this._applyDesign();
const entity = this.hass.states[this.config.entity];
+ // The header shows in every state, so the editor's title, colour and
+ // design show in its preview even before the entity is right.
+ const head = html`
+
+
+ ${this.config.title || this._t("gallery.default_title")}
+
`;
if (!entity) {
- return html`
${this._t("common.entity_not_found", { entity: this.config.entity })}
`;
+ return html`
${head}${this._t("common.entity_not_found", { entity: this.config.entity })}
`;
}
const items = this._gallery();
return html`
-
-
- ${this.config.title || this._t("gallery.default_title")}
-
+ ${head}
${items.length === 0
? html`${this._t("gallery.empty")}
`
: html`
diff --git a/tests/frontend/taskmate-goal-gallery-editors.test.cjs b/tests/frontend/taskmate-goal-gallery-editors.test.cjs
new file mode 100644
index 00000000..1b96ad1a
--- /dev/null
+++ b/tests/frontend/taskmate-goal-gallery-editors.test.cjs
@@ -0,0 +1,143 @@
+const assert = require("node:assert/strict");
+const { readFileSync, readdirSync } = require("node:fs");
+const path = require("node:path");
+const { test } = require("node:test");
+
+const { loadCard, localize, render, WWW } = require("./harness.cjs");
+
+// The Family Goal and Photo Gallery cards had no visual editor, so Home
+// Assistant showed "Visual editor not supported" and they could only be set up
+// in YAML. Each now opens an ha-form editor like the other TaskMate cards.
+
+const CARDS = {
+ "taskmate-family-goal-card": {
+ entity: "sensor.taskmate_overview",
+ fields: ["entity", "title", "card_design"],
+ accent: "#16a085",
+ full: { entity: "sensor.taskmate_overview", title: "Movie night", card_design: "playroom" },
+ },
+ "taskmate-photo-gallery-card": {
+ entity: "sensor.taskmate_activity",
+ fields: ["entity", "title", "max", "card_design"],
+ accent: "#5d6d7e",
+ full: { entity: "sensor.taskmate_activity", title: "Proof photos", max: 40, card_design: "console" },
+ },
+};
+const plain = (v) => JSON.parse(JSON.stringify(v));
+
+function editor(card, config) {
+ const loaded = loadCard(`${card}.js`, {
+ sandbox: { CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init?.detail; } } },
+ window: { __taskmate_design: { apply: () => "classic", styles: () => null, editorOptions: () => [{ value: "global", label: "x" }] } },
+ });
+ const el = new (loaded.get(`${card}-editor`))();
+ el.hass = { language: "en", states: {} };
+ el.setConfig(config);
+ return el;
+}
+const formData = (el) => el.render().values.find((v) => v && v.card_design !== undefined);
+
+for (const [card, spec] of Object.entries(CARDS)) {
+ test(`${card} opens its visual editor`, async () => {
+ const loaded = loadCard(`${card}.js`, { editor: false, window: { __taskmate_loadTranslations: () => Promise.resolve() } });
+ assert.equal(loaded.get(`${card}-editor`), undefined, "the editor must load only when asked for");
+ await loaded.get(card).getConfigElement();
+ assert.deepEqual(loaded.imports, [`./editors/${card}-editor.js?v=test`]);
+ assert.ok(loaded.get(`${card}-editor`));
+ });
+
+ test(`${card} editor is an ha-form with every documented option, translated`, () => {
+ const el = editor(card, { entity: spec.entity });
+ const markup = render(el.render()).markup;
+ assert.match(markup, / s.name), spec.fields);
+ for (const name of spec.fields) {
+ const label = el._computeLabel({ name });
+ assert.notEqual(label, name, `${name} has no label`);
+ assert.doesNotMatch(label, /^[a-z_]+\.[a-z_.]+$/, `${name} label is a raw key: ${label}`);
+ }
+ assert.notEqual(el._computeHelper({ name: "title" }), "", "title has no helper");
+ });
+
+ test(`${card} editor shows stored values and round-trips, dropping defaults`, () => {
+ const el = editor(card, { type: `custom:${card}`, ...spec.full });
+ assert.deepEqual({ ...formData(el) }, spec.full);
+ el._formChanged({ detail: { value: { ...spec.full } } });
+ assert.deepEqual(plain(el.dispatched.at(-1).detail.config), { type: `custom:${card}`, ...spec.full });
+ const cleared = Object.fromEntries(Object.keys(spec.full).filter((k) => k !== "entity").map((k) => [k, k === "card_design" ? "global" : undefined]));
+ el._formChanged({ detail: { value: cleared } });
+ assert.deepEqual(plain(el.dispatched.at(-1).detail.config), { type: `custom:${card}`, entity: spec.entity });
+ const bare = formData(editor(card, { entity: spec.entity }));
+ assert.equal(bare.card_design, "global");
+ assert.equal(bare.max, undefined, "an unset photo limit stays empty, not 0");
+ });
+
+ test(`${card} editor sets the header colour, defaulting to the card's own`, () => {
+ let seen = null;
+ const designed = loadCard(`${card}.js`, {
+ sandbox: { CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init?.detail; } } },
+ window: { __taskmate_design: { apply: () => "classic", styles: () => null, editorOptions: () => [], colourPicker: (o) => { seen = o; return ""; } } },
+ });
+ const e2 = new (designed.get(`${card}-editor`))();
+ e2.hass = { language: "en", states: {} };
+ e2.setConfig({ entity: spec.entity });
+ e2.render();
+ assert.equal(seen.defaultValue, spec.accent);
+ assert.equal(seen.current, spec.accent);
+ seen.onPreset("#e74c3c");
+ assert.equal(plain(e2.dispatched.at(-1).detail.config).header_color, "#e74c3c");
+ assert.equal(localize("common.editor.header_colour"), seen.label);
+ });
+}
+
+test("every locale translates the new editor strings", () => {
+ const LOCALES = path.join(WWW, "locales");
+ const en = JSON.parse(readFileSync(path.join(LOCALES, "en.json"), "utf8"));
+ const keys = Object.keys(en).filter((k) => k.startsWith("family_goal.editor.") || k.startsWith("gallery.editor."));
+ assert.equal(keys.length, 6);
+ for (const f of readdirSync(LOCALES).filter((n) => n.endsWith(".json"))) {
+ const loc = JSON.parse(readFileSync(path.join(LOCALES, f), "utf8"));
+ for (const k of keys) assert.ok(loc[k] && loc[k].trim(), `${f} lacks ${k}`);
+ if (!f.startsWith("en")) {
+ const same = keys.filter((k) => loc[k] === en[k]);
+ assert.deepEqual(same, [], `${f} leaves English in ${same}`);
+ }
+ // The browser reads the editor bundle, not the source file.
+ const bundle = JSON.parse(readFileSync(path.join(LOCALES, f.replace(/\.json$/, ""), "editor.json"), "utf8"));
+ for (const k of keys) assert.equal(bundle[k], loc[k], `${f}: ${k} missing from the editor bundle`);
+ }
+});
+
+// #771 gate: with no family goal set (or a wrong entity) the cards drew only a
+// placeholder, so the title and header colour picked in the new editor never
+// showed in its preview. Every state now draws the card's header.
+function card(tag, config, states) {
+ const loaded = loadCard(`${tag}.js`, { editor: false, window: { __taskmate_design: { apply: () => "classic", styles: () => null } } });
+ const el = new (loaded.get(tag))();
+ const props = {};
+ el.style = { setProperty: (k, v) => { props[k] = v; }, removeProperty: (k) => { delete props[k]; } };
+ el.hass = { language: "en", states };
+ el.setConfig(config);
+ return { markup: render(el.render()).markup, props };
+}
+
+test("the Family Goal card keeps its title and colour with no goal set", () => {
+ const states = { "sensor.taskmate_overview": { attributes: { family_goal: { enabled: false } } } };
+ const off = card("taskmate-family-goal-card", { entity: "sensor.taskmate_overview", title: "Movie night", header_color: "#e74c3c" }, states);
+ assert.match(off.markup, /class="fg-head"[\s\S]*Movie night/);
+ assert.match(off.markup, new RegExp(localize("family_goal.disabled")));
+ assert.equal(off.props["--fg-accent"], "#e74c3c");
+ const plainOff = card("taskmate-family-goal-card", { entity: "sensor.taskmate_overview" }, states);
+ assert.match(plainOff.markup, new RegExp(`class="fg-title">${localize("family_goal.default_title")}<`));
+ const missing = card("taskmate-family-goal-card", { entity: "sensor.nope", title: "Movie night" }, {});
+ assert.match(missing.markup, /class="fg-head"[\s\S]*Movie night/);
+});
+
+test("the Photo Gallery card keeps its header with no photos or a wrong entity", () => {
+ const empty = card("taskmate-photo-gallery-card", { entity: "sensor.taskmate_activity", title: "Proof" }, { "sensor.taskmate_activity": { attributes: { photo_gallery: [] } } });
+ assert.match(empty.markup, /class="pg-head"[\s\S]*Proof/);
+ assert.match(empty.markup, new RegExp(localize("gallery.empty")));
+ const missing = card("taskmate-photo-gallery-card", { entity: "sensor.nope", title: "Proof", header_color: "#27ae60" }, {});
+ assert.match(missing.markup, /class="pg-head"[\s\S]*Proof/);
+ assert.equal(missing.props["--pg-accent"], "#27ae60");
+});
diff --git a/tests/frontend/taskmate-lazy-editors.test.cjs b/tests/frontend/taskmate-lazy-editors.test.cjs
index ecfd2c90..0ac9f473 100644
--- a/tests/frontend/taskmate-lazy-editors.test.cjs
+++ b/tests/frontend/taskmate-lazy-editors.test.cjs
@@ -21,7 +21,7 @@ const read = (rel) => readFileSync(path.join(WWW, rel), "utf8");
const SHARED = { "taskmate-incentive-card-editor.js": ["taskmate-bonuses-card.js", "taskmate-penalties-card.js"] };
test("there is one editor module per card editor", () => {
- assert.equal(editorFiles.length, 30);
+ assert.equal(editorFiles.length, 32);
for (const f of editorFiles) assert.ok(readdirSync(WWW).includes(cardOf(f)), `${f} has no card ${cardOf(f)}`);
});
diff --git a/wiki/Dashboard-Cards.md b/wiki/Dashboard-Cards.md
index e3bafb81..0177b677 100644
--- a/wiki/Dashboard-Cards.md
+++ b/wiki/Dashboard-Cards.md
@@ -724,17 +724,20 @@ See [Achievement Badges](Achievement-Badges.md) for full details on tiers, crite
**Default header colour:** Teal `#16a085`
+Set it up in the visual editor (the overview entity, an optional title, the [design style](Card-Design-Styles.md) and the header colour) or in YAML:
+
```yaml
type: custom:taskmate-family-goal-card
entity: sensor.taskmate_overview
title: Family Goal # optional — custom card title
+card_design: global # optional — classic, playroom, console, cleanpro, accessible, graphite
header_color: "#16a085"
```
**Behaviour:**
- Displays a progress bar from the combined family balance toward the goal target, with a percentage
- Shows the goal's reward, and a celebration state once the target is reached
-- If no family goal is enabled, the card shows a friendly "no goal set" placeholder
+- If no family goal is enabled, the card keeps its title and shows a friendly "no goal set" placeholder
The goal itself is enabled and configured in [Settings](Settings.md). See [Family Goals](Family-Goals.md) for the full feature.
@@ -748,11 +751,14 @@ The goal itself is enabled and configured in [Settings](Settings.md). See [Famil
**Default header colour:** Slate `#5d6d7e`
+Set it up in the visual editor (the activity entity, an optional title, **Photos to show**, the [design style](Card-Design-Styles.md) and the header colour) or in YAML:
+
```yaml
type: custom:taskmate-photo-gallery-card
entity: sensor.taskmate_activity
title: Proof Photos # optional — custom card title
max: 40 # optional — limit the number of photos shown
+card_design: global # optional — classic, playroom, console, cleanpro, accessible, graphite
header_color: "#5d6d7e"
```
diff --git a/wiki/Family-Goals.md b/wiki/Family-Goals.md
index ba3da825..93084841 100644
--- a/wiki/Family-Goals.md
+++ b/wiki/Family-Goals.md
@@ -47,6 +47,8 @@ A `taskmate_family_goal_reached` event is also fired with the goal name, reward,
Add the **Family Goal card** to a dashboard to show live progress toward the target — a progress bar, the combined total against the target, the percentage, and the reward. When the goal is reached the card switches to a trophy and a celebration message.
+Pick **TaskMate Family Goal** in the dashboard's card picker and set it up in the visual editor, or write the YAML:
+
```yaml
type: custom:taskmate-family-goal-card
entity: sensor.taskmate_overview
@@ -58,9 +60,10 @@ header_color: "#16a085"
|--------|----------|-------------|
| `entity` | ✅ | The TaskMate overview sensor (`sensor.taskmate_overview`) |
| `title` | — | Overrides the heading; defaults to the goal name |
+| `card_design` | — | The [design style](Card-Design-Styles.md); defaults to the global one |
| `header_color` | — | Accent colour for the icon and progress bar (default `#16a085`) |
-If no family goal is enabled, the card shows a quiet "No family goal set" placeholder. The card reads the overview sensor's `family_goal` attribute, which exposes `enabled`, `name`, `target`, `reward`, `progress`, and `achieved`.
+If no family goal is enabled, the card keeps its title and shows a quiet "No family goal set" placeholder beneath it. The card reads the overview sensor's `family_goal` attribute, which exposes `enabled`, `name`, `target`, `reward`, `progress`, and `achieved`.
See [Dashboard Cards](Dashboard-Cards.md) for the full set of cards.
diff --git a/wiki/Photo-Proof.md b/wiki/Photo-Proof.md
index dc789046..6651629a 100644
--- a/wiki/Photo-Proof.md
+++ b/wiki/Photo-Proof.md
@@ -64,7 +64,7 @@ A completion that carries a `photo_url` shows a small thumbnail next to the entr
## Browsing Past Photos — the Photo Gallery Card
-To look back over evidence photos after they've been approved, add the **Photo Gallery Card** (`custom:taskmate-photo-gallery-card`). It shows a grid of thumbnails for every chore completion that carried a proof photo, each captioned with the child, the chore, and the date.
+To look back over evidence photos after they've been approved, add the **Photo Gallery Card** (`custom:taskmate-photo-gallery-card`). It shows a grid of thumbnails for every chore completion that carried a proof photo, each captioned with the child, the chore, and the date. Pick **TaskMate Photo Gallery** in the card picker and set the title, how many photos to show, the design style and the header colour in its visual editor, or write the YAML:
```yaml
type: custom:taskmate-photo-gallery-card