diff --git a/README.md b/README.md
index 0d3a6b62..40c27865 100644
--- a/README.md
+++ b/README.md
@@ -19,9 +19,9 @@ Live: · Widget playground: deviatesFromAsset(object, asset, def.key))
+ );
+}
+
+/**
+ * Ids the hierarchy marks: everything deviating itself, plus every ancestor of one. The ancestors
+ * matter because subtrees fold – without them a collapsed row would hide that something below it
+ * was changed.
+ */
+export function deviationMarks(project: Project, holder: ObjectTree): Set {
+ const view: ObjectTree = { objects: resolveObjects(project, holder) };
+ const marks = new Set();
+ for (const object of view.objects) {
+ if (!objectDeviates(project, holder, object)) continue;
+ marks.add(object.id);
+ for (const ancestorId of ancestorIdsOf(view, object.id)) marks.add(ancestorId);
+ }
+ return marks;
+}
diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts
index f20dc215..3fa1cead 100644
--- a/src/editor/debugHook.ts
+++ b/src/editor/debugHook.ts
@@ -1,4 +1,4 @@
-import { type Application, type Container, Rectangle, Text } from 'pixi.js';
+import { type Application, Color, type ColorSource, type Container, Rectangle, Text } from 'pixi.js';
import { resolveObjects } from '../core/model/instance';
import { findObjectHolder } from '../core/model/Project';
import { Button, ui, Widget } from '../ui';
@@ -34,6 +34,20 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const p = node.getGlobalPosition();
return { x: p.x + node.width / 2, y: p.y + node.height / 2 };
};
+ /** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */
+ const textColor = (text: string): string | null => {
+ let found: string | null = null;
+ const walk = (node: Container): void => {
+ if (found !== null || !node.visible) return;
+ if (node instanceof Text && node.text === text) {
+ found = new Color(node.style.fill as ColorSource).toHex();
+ return;
+ }
+ for (const child of node.children) walk(child);
+ };
+ walk(app.stage);
+ return found;
+ };
/**
* The visible text of the overlay layer, in drawing order – everything an open popup shows.
* Menus hide filtered-out entries, so this is also what a search box currently leaves.
@@ -118,6 +132,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
objectsOf,
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
locate,
+ textColor,
overlayTexts,
overlayRect,
textOverflows,
@@ -159,6 +174,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
hierarchyOrder: () => hierarchy.rowOrder(),
hierarchyChevron: (id: string) => hierarchy.chevronPosition(id),
+ hierarchyRowColor: (id: string) => hierarchy.rowColor(id),
hierarchyExpandAll: () => hierarchy.expandAll(),
hierarchyCollapseAll: () => hierarchy.collapseAll(),
/** The colour actually on screen at a point, as `#rrggbb` – what was drawn, not what the model says. */
diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts
index a8c6a508..c6ff8d17 100644
--- a/src/editor/inspector/fieldFactory.ts
+++ b/src/editor/inspector/fieldFactory.ts
@@ -46,7 +46,7 @@ export interface FieldRow {
/** Push a model value into the widget (no events fired). */
setValue(value: PropertyValue): void;
/**
- * Highlight the row as deviating from its base value (an override on scene objects, a
+ * Colour the label as deviating from its base value (an override on scene objects, a
* non-default value on assets) and re-evaluate which actions currently apply.
*/
setModified(modified: boolean): void;
@@ -154,7 +154,7 @@ export function createFieldRow(
row,
setValue: field.setValue,
setModified(modified) {
- label.setStyle(modified ? 'bodyBold' : 'muted');
+ label.setStyle(modified ? 'override' : 'muted');
refreshActions();
},
};
diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts
index 8e3b1e80..a87aeb61 100644
--- a/src/editor/inspector/objectSection.ts
+++ b/src/editor/inspector/objectSection.ts
@@ -474,7 +474,7 @@ function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObjec
}
/**
- * Bold marks exactly what the ↺/↑ buttons offer: for content of a placed container a deviation
+ * Gold marks exactly what the ↺/↑ buttons offer: for content of a placed container a deviation
* from the template (position per axis, overrides), for any other object one from the asset. The
* editor offset has one base only – no offset at all.
*/
diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts
index a62bacdc..5e8bd19f 100644
--- a/src/editor/panels/HierarchyPanel.ts
+++ b/src/editor/panels/HierarchyPanel.ts
@@ -6,6 +6,7 @@ import { hasColorSwatch } from '../../core/model/AssetTypes';
import { ancestorIdsOf, childIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
+import { deviationMarks } from '../../core/properties/deviation';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
@@ -18,6 +19,9 @@ import { type EditorState, objectSelection } from '../state';
* siblings, or onto the empty space below the rows to move it to the top level. The object keeps
* its world position every time.
*
+ * A row's name turns gold when the object deviates from what it follows – its container template
+ * or its asset – or when anything below it does, so a folded subtree still shows it.
+ *
* Rows with children fold: the chevron (or ←/→, or a double-click on the row) toggles them.
* Everything starts collapsed – which ids are open is view state kept here, never part of the
* project, and selecting an object always unfolds the way down to it.
@@ -128,6 +132,11 @@ export class HierarchyPanel extends Panel {
return holder ? visibleTreeOrder(holder, this.expanded).map((e) => e.object.id) : [];
}
+ /** Label colour of a row as `#rrggbb` – gold means it or something below it deviates (test hook). */
+ rowColor(id: string): string | null {
+ return this.list.rowColor(id);
+ }
+
/** Global centre of a row's chevron, so tests can click the real hit area (test hook). */
chevronPosition(id: string): { x: number; y: number } | null {
return this.list.chevronGlobal(id);
@@ -226,6 +235,10 @@ export class HierarchyPanel extends Panel {
this.shownHolderId = holder?.id ?? '';
const kind = holder?.type === 'container' ? 'Container' : 'Scene';
this.setTitle(holder ? `${kind}: ${holder.name}` : 'Scene objects');
+ // From the stored holder, not the resolved one: what an object follows depends on where it
+ // lives, and in a resolved view container content looks stored.
+ const source = this.source();
+ const marks = source ? deviationMarks(this.state.project, source) : new Set();
this.list.setItems(
(holder ? visibleTreeOrder(holder, this.expanded) : []).map(({ object, depth, hasChildren }) => {
const asset = findAsset(this.state.project, object.assetId);
@@ -237,6 +250,7 @@ export class HierarchyPanel extends Panel {
depth,
swatch: asset && hasColorSwatch(asset.type) ? Number(props.color ?? 0) : undefined,
expanded: hasChildren ? this.expanded.has(object.id) : undefined,
+ overridden: marks.has(object.id),
};
}),
);
diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts
index 8be08cbc..4e4cfcbb 100644
--- a/src/ui/Theme.ts
+++ b/src/ui/Theme.ts
@@ -41,6 +41,8 @@ export const Theme = {
trigger: 0xf2c84b,
container: 0x3fb8c9,
marquee: 0x4c8dff,
+ /** A value deviating from what it follows: an override on a placed object, a non-default on an asset. */
+ override: 0xf2c84b,
/** Node graph: canvas, node chrome, per-category title colors, port colors, live highlight. */
graphBackground: 0x15171b,
node: 0x262a32,
@@ -67,6 +69,7 @@ export type TextStyleToken =
| 'body'
| 'bodyBold'
| 'muted'
+ | 'override'
| 'small'
| 'title'
| 'mono'
@@ -89,6 +92,9 @@ export function textStyle(token: TextStyleToken = 'body'): TextStyle {
case 'muted':
style = new TextStyle({ ...base, fill: Theme.colors.textMuted });
break;
+ case 'override':
+ style = new TextStyle({ ...base, fill: Theme.colors.override });
+ break;
case 'small':
style = new TextStyle({ ...base, fontSize: Theme.smallFontSize, fill: Theme.colors.textMuted });
break;
diff --git a/src/ui/widgets/ListRow.ts b/src/ui/widgets/ListRow.ts
index 715ab1d9..7913ebd0 100644
--- a/src/ui/widgets/ListRow.ts
+++ b/src/ui/widgets/ListRow.ts
@@ -1,4 +1,4 @@
-import { Graphics, Text } from 'pixi.js';
+import { Color, type ColorSource, Graphics, Text } from 'pixi.js';
import { drawControlBox } from '../draw';
import { centreY } from '../math';
import { Theme, textStyle } from '../Theme';
@@ -25,6 +25,7 @@ export class ListRow extends Widget {
super();
this.cursor = 'pointer';
this.minHeight = height;
+ if (item.overridden) this.labelText.style = textStyle('override');
this.addChild(this.bg, this.labelText, this.hint);
this.trackHover();
}
@@ -37,6 +38,11 @@ export class ListRow extends Widget {
this.invalidate();
}
+ /** Fill of the label as `#rrggbb` – tells a marked row from a plain one (test hook). */
+ get labelColor(): string {
+ return new Color(this.labelText.style.fill as ColorSource).toHex();
+ }
+
/** Left edge of the chevron slot (or of the content itself in a flat list). */
private get indent(): number {
return Theme.padding + (this.item.depth ?? 0) * Theme.treeIndent;
diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts
index 8f2ad1ea..aeac9612 100644
--- a/src/ui/widgets/ListView.ts
+++ b/src/ui/widgets/ListView.ts
@@ -16,6 +16,8 @@ export interface ListItem {
swatch?: number;
/** Whether the subtree is shown; such rows get a chevron. Left undefined for leaves. */
expanded?: boolean;
+ /** Draws the label in the override colour – the row, or something inside it, deviates. */
+ overridden?: boolean;
}
/** Pixels of travel before a press turns into a drag. */
@@ -129,6 +131,11 @@ export class ListView extends Widget {
this.redraw();
}
+ /** Label colour of a row as `#rrggbb`; null when no such row is shown (test hook). */
+ rowColor(id: string): string | null {
+ return this.rows.find((r) => r.item.id === id)?.labelColor ?? null;
+ }
+
/** Global centre of a row's chevron; null when the row has none. */
chevronGlobal(id: string): { x: number; y: number } | null {
return this.rows.find((r) => r.item.id === id)?.chevronGlobal() ?? null;
diff --git a/tests/core/deviation.test.ts b/tests/core/deviation.test.ts
new file mode 100644
index 00000000..2fa87b67
--- /dev/null
+++ b/tests/core/deviation.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
+import {
+ ClearOverrideCommand,
+ SetOverrideCommand,
+ SetParentCommand,
+} from '../../src/core/commands/sceneObjectCommands';
+import { instanceChildId } from '../../src/core/model/instance';
+import { findAsset } from '../../src/core/model/Project';
+import type { ContainerAsset } from '../../src/core/model/Asset';
+import { deviationMarks, objectDeviates } from '../../src/core/properties/deviation';
+import { makeSpriteScene, place } from '../helpers';
+
+describe('deviation marks', () => {
+ it('marks an object only once an override really deviates', () => {
+ const f = makeSpriteScene();
+ const id = place(f, f.spriteId, { x: 0, y: 0 });
+ const deviates = () => objectDeviates(f.project, f.sceneOf(), f.object(id));
+ expect(deviates()).toBe(false);
+ // The asset's own value carried as an override is no deviation.
+ f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'width', 100));
+ expect(deviates()).toBe(false);
+ f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'width', 250));
+ expect(deviates()).toBe(true);
+ f.stack.execute(new ClearOverrideCommand(f.sceneId, id, 'width'));
+ expect(deviates()).toBe(false);
+ });
+
+ it('marks the ancestors of a deviating object, so a folded row still shows it', () => {
+ const f = makeSpriteScene();
+ const parent = place(f, f.spriteId, { x: 0, y: 0 });
+ const child = place(f, f.spriteId, { x: 0, y: 0 });
+ f.stack.execute(new SetParentCommand(f.sceneId, child, parent));
+ expect(deviationMarks(f.project, f.sceneOf()).size).toBe(0);
+ f.stack.execute(new SetOverrideCommand(f.sceneId, child, 'width', 250));
+ expect([...deviationMarks(f.project, f.sceneOf())].sort()).toEqual([child, parent].sort());
+ });
+
+ it('marks container content against its template, and the instance above it', () => {
+ const f = makeSpriteScene();
+ const a = place(f, f.spriteId, { x: 0, y: 0 });
+ const create = new CreateContainerCommand(f.sceneId, [a]);
+ f.stack.execute(create);
+ const template = (findAsset(f.project, create.assetId) as ContainerAsset).objects[0]!.id;
+ const content = instanceChildId(create.objectId, template);
+ expect(deviationMarks(f.project, f.sceneOf()).size).toBe(0);
+ f.stack.execute(new SetOverrideCommand(f.sceneId, content, 'width', 250));
+ expect([...deviationMarks(f.project, f.sceneOf())].sort()).toEqual([content, create.objectId].sort());
+ });
+});
diff --git a/todo-notes/done/override-darstellung.md b/todo-notes/done/override-darstellung.md
new file mode 100644
index 00000000..527f131b
--- /dev/null
+++ b/todo-notes/done/override-darstellung.md
@@ -0,0 +1,19 @@
+override darstellung bei scene objects die werte von assets überschreiben:
+- momentan wird es fett dargestellt => stattdessen das label in einer gelb/goldener schriftfarbe darstellen
+- auch in der hierarchie der scene/container bitte den namen gelb/gold darstellen, falls es irgendein child/property gibt dass dirty ist
+
+---
+
+**Umgesetzt (2026-09-09), beide Punkte.**
+
+- Inspector: das Label einer abweichenden Property ist jetzt gold (`#f2c84b`) statt fett.
+ Die Farbe steht als Theme-Token `override` an einer Stelle.
+- Hierarchie: der Name wird gold, wenn das Objekt selbst abweicht **oder etwas darunter** —
+ eine zugeklappte Zeile soll nicht verbergen, dass im Teilbaum etwas geaendert wurde.
+
+Die Logik dazu sitzt in `src/core/properties/deviation.ts` (rein, getestet), nicht im Panel:
+`objectDeviates` vergleicht gegen das, dem ein Objekt folgt — bei Container-Inhalt gegen das
+**Template**, sonst gegen das **Asset** —, `deviationMarks` traegt die Marke auf alle Vorfahren
+hoch. Wichtig dabei: `deviationMarks` bekommt den **gespeicherten** Holder, nicht den
+aufgeloesten; in der aufgeloesten Sicht sieht Container-Inhalt wie ein normales Objekt aus und
+jede Instanz waere faelschlich gold.
diff --git a/todo-notes/override-darstellung.md b/todo-notes/override-darstellung.md
deleted file mode 100644
index 5b3da3c7..00000000
--- a/todo-notes/override-darstellung.md
+++ /dev/null
@@ -1,3 +0,0 @@
-override darstellung bei scene objects die werte von assets überschreiben:
-- momentan wird es fett dargestellt => stattdessen das label in einer gelb/goldener schriftfarbe darstellen
-- auch in der hierarchie der scene/container bitte den namen gelb/gold darstellen, falls es irgendein child/property gibt dass dirty ist
diff --git a/tools/e2e/02-inspector.mjs b/tools/e2e/02-inspector.mjs
index 5df7e612..9f958c73 100644
--- a/tools/e2e/02-inspector.mjs
+++ b/tools/e2e/02-inspector.mjs
@@ -14,6 +14,7 @@ export async function inspector(t) {
drag,
press,
typeText,
+ mutate,
undo,
redo,
state,
@@ -33,6 +34,28 @@ export async function inspector(t) {
);
await shot('02-object-selected');
+ // 1b. Deviations are shown in gold – on the inspector label and on the hierarchy row. The blue
+ // crate overrides width and color, the camera and the red crate's subtree nothing.
+ const GOLD = '#f2c84b';
+ const PLAIN = '#e6e8ee';
+ const rowColor = (id) => json(`__gm.hierarchyRowColor('${id}')`);
+ check('an overridden object is marked gold in the hierarchy', (await rowColor('o2')) === GOLD);
+ check('an untouched object is not', (await rowColor('o3')) === PLAIN, await rowColor('o3'));
+ check('nor is a parent whose subtree is untouched', (await rowColor('o1')) === PLAIN);
+ check(
+ 'the overridden field is gold, an inherited one is not',
+ (await json(`__gm.textColor('Width')`)) === GOLD &&
+ (await json(`__gm.textColor('Height')`)) === '#8a90a0',
+ JSON.stringify(await json(`[__gm.textColor('Width'), __gm.textColor('Height')]`)),
+ );
+ // The mark also travels up: with the crate's own overrides taken away the row must stay gold
+ // for the sake of its collider child, which keeps one. Put back right after, byte for byte.
+ await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').overrides = {}`);
+ check('a parent carries the mark of a deviating child', (await rowColor('o2')) === GOLD);
+ await mutate(
+ `__gm.state.activeScene.objects.find(o => o.id === 'o2').overrides = { width: 140, color: 0x4488ff }`,
+ );
+
// 2. Rename via inspector: the name field sits right of the "Name" label.
const nameLabel = await locate('Name');
await click(nameLabel.x + 160, nameLabel.y);