The asset link in the inspector already pings an asset on a click and
opens it on a double-click. The rows of the Scene objects panel only
selected and folded. Now they lead to the asset the same way.
- A click on the row that is selected already pings the object's
asset in the asset panel. The selection stays. The ping comes on
release, so picking a row up to drag it pings nothing.
- A double-click or Enter on an object's row opens its asset,
selected or not.
- ListView has a new onReselect for a plain click on the row that is
selected alone. onSelect stays quiet then, as nothing changes.
Decided without asking:
- A double-click on an object's row no longer folds it. The chevron
and the arrow keys still do. A group's row has no asset and keeps
folding on a double-click.
- A click with Ctrl or Shift, and one that narrows a multi-selection
to its row, does not ping. Both change the selection.
- A row whose asset is missing pings and opens nothing.
Findings:
- The double-click window goes by time (400 ms), not by the event's
detail. Two quick click() calls from the harness are a double-click.
The comment in e2e 01 said otherwise and is gone.
Tests:
- e2e 01 (melektaus-atx): ALL PASSED, 13 checks. The two checks for
folding on a double-click became one for the ping and two for
opening. Counter-check without the ping: that check red.
- Gate (melektaus-atx): CHECK EXIT 0, 268 files, 4142 tests.
- Not checked: Enter on a row, a group's row on a double-click.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recurring cleanup round over the code since round 7 (bc688f3). The
issue now also asks to fix bugs and small inconsistencies found on the
way, so unlike the rounds before, this one changes behaviour in a few
places. Duplicated code in api went from 23 clones with 228 lines to
none (jscpd, at least 50 tokens). In src it stays at 22 clones with
271 lines, the same before and after.
Bugs:
- nginx served project and snapshot files as they were, so anyone who
knew a project id could read it without signing in. Every file the
API writes now goes through AtomicFile (temp file, mode, rename).
Projects, snapshots, releases, accounts and sessions are 0640, and
nginx answers 403 for them.
- Any signed-in account could delete a shared image, font or sound,
which projects of other accounts may wear. Deleting one is now for
administrators only. The editor never deleted a file, so the
client's delete is gone.
- Two saves of one project could run at once. The lock file was
removed after each save, so a waiting save and the next one locked
different files. The lock file now stays, and deleting a project
takes the lock as well.
- Bytes that are no picture got a 500 instead of a 422, because the
error handler also threw for warnings silenced with @.
- A session file was rewritten in place. A request reading it at that
moment found it empty and was signed out. It is replaced atomically.
- Delete did nothing for a shape picked in the vector art list. Delete
now goes through removeSelection, one case per kind of selection,
and a kind without a case fails the type check.
Inconsistencies:
- Escape on a question does what its Cancel does. Cancel on "Delete
project?" and "Restore snapshot?" leads back to the picker or the
list like the other questions did, and so does deleting a project.
- Editor texts use American spelling throughout, like the port names:
48 British spellings in 45 lines (colour, centre, initialised).
- objectName and groupName replace eleven copies of "own name, else
the asset's" and three of "Group". One copy fell back to the object
id instead of "?".
Duplicates, dead code, comments:
- routeFiles serves images, fonts and sounds instead of three copies.
HashedFileStore holds what the three stores shared: keeping a file
under its hash with its name, listing and deleting. The stores keep
only which bytes they take and what a record says.
- AtomicFile replaces nine copies of temp file plus rename.
- removeShapes is the one way the canvas, the inspector button and
Delete take shapes out.
- GraphInterpreter.fire was called by tests only. The 54 calls use a
helper in tests/helpers.ts now.
- FileStore.delete, HttpFileStore.delete and the PluginApi type were
unused. A doc comment in actions.ts sat above the wrong function.
Checked and left as is:
- solidCells is used by tests only. It is the per-cell view before the
merge into rectangles, the one place the largest-scale rule shows.
- An undo checker ran all 124 commands of the unit tests and compared
the project before and after undo. The only differences are empty
ports, groups and notes lists where there were none, which the file
format drops anyway.
Docs:
- README: the check sum.
- Wiki, own commit: API (admin-only deletes, 422, file modes, lock
file), Home (the "Not yet" list named three things that exist),
Concepts, Keyboard, Vector art, Architecture and Development, and
the 38 characters Gitea flagged as ambiguous on eight pages.
- .gitignore: the lock files, and pasted-*.png in any folder, which
keeps the screenshot archive out.
Tests:
- ./check.sh: 195 files, 2817 tests, exit 0 (+2 for the name helpers).
- PHP: accounts 52 in the web container and 53 in the CI image with
the race test (+2), snapshots 15 (+1), releases 20.
- Full e2e run: ALL PASSED, 1586 checks (+6 in 12, 47, 49 and 50).
- Smokes jumprun, tetris, actor: 24, 19 and 21 checks. The example
project files were restored byte for byte afterwards.
- The file stores of HEAD and of this commit give byte-identical
output for the live folders and for store, list and delete in a
throwaway folder.
- Counter-checks, all fail as expected: a 500 for bad bytes fails 12,
no admin check fails 49, removing the lock file again loses saves
in 3 of 3 runs (151 to 170 of 321), Delete ignoring shapes fails 47,
Escape that only closes fails 50, and dropping the shape case fails
the type check (TS2366).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three follow-ups to the hierarchy groups. Dragging a row that belonged
to a multi-selection moved only that one row. The group dialog could
only create a new group. The group row drew its folder in front of the
label, unlike every other row in the hierarchy.
- Dragging a selected row moves every selected object. This holds for
reordering, dropping into a group or between its members, dragging
out of a group and dropping onto an object as new parent.
- The moved objects land side by side in hierarchy order, whichever
row was grabbed. The whole move is one undo step.
- A selected child of a selected object travels with its parent, not
on its own. Dropping the selection onto its own rows does nothing.
- Dragging a row that is not selected still moves only that row.
- hierarchyDrop.moveObjects replaces the single-object rule. One object
is the case with one element, so the old drop tests hold unchanged.
- The group dialog has a "Group" dropdown: New group or an existing
group. Picking a group greys out the name and turns the button into
"Add". A group that holds all chosen objects already is not offered.
- Rename keeps using the dialog without the dropdown.
- ListRow draws an end icon at the very right and the hint left of it.
Only the hierarchy uses iconAtEnd, so other lists do not change.
- New test hook __gm.hierarchyHint() gives the centre of a row's hint.
Not clicked in the browser: dragging a selection in the container tab
and onto an object as parent. Only the unit tests cover these.
Tests:
- ./check.sh: 178 files, 2564 tests, CHECK EXIT 0
- e2e 1 + 52: ALL PASSED, 27 checks
- 7 new unit tests in hierarchy-drop.test.ts, 4 new e2e checks in
52i and 52j with real clicks and a real drag
- 4 counter-probes bite: drop rule ignores the selection (5 unit tests
fail), icon column not kept free, dialog ignores the picked group,
panel ignores the selection (only o2 lands in the group)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ctrl- and shift-click only added single rows to the selection. With
many objects spread over the hierarchy, picking a run of them meant
clicking each one. Shift-click now selects everything between the
anchor and the clicked row, as in a file explorer.
- The anchor is the row last clicked without shift, plain or with
ctrl. A second shift-click redraws the range from the same anchor.
- Ctrl+shift-click adds the range to the current selection instead
of replacing it.
- A collapsed group inside the range brings all its members. Children
hidden under a collapsed parent stay out, since they are not shown.
- Without an anchor from the hierarchy, the primary object picked in
the scene view serves as anchor. With no selection at all,
shift-click adds the single row as before.
- The range rule is a pure function in hierarchyRange.ts, so it is
tested without a browser. HierarchyPanel keeps the anchor.
Not checked in the browser: ctrl+shift-click and the anchor taken from
the scene view. Only the code path covers them.
Tests:
- ./check.sh: 178 files, 2557 tests, CHECK EXIT 0
- e2e 1 + 52: ALL PASSED, 23 checks
- 5 new unit tests in tests/editor/hierarchy-range.test.ts
- New e2e check 52h with real clicks
- Counter-check: without the range, 52h fails with ["o2","o5"]
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scene with a hundred crates fills the hierarchy with a hundred rows.
Groups bundle top-level objects under one folder row that folds away.
They exist for the editor only. Parenting, positions and the game do
not see them.
- Model: a scene or container template holds a list of groups, an
object may carry a groupId. Old projects load unchanged. Loading
drops empty groups and members that are no longer top level.
- Only top-level objects can join a group. Parenting a member takes it
out of the group, and Undo puts it back. There are no groups in
groups.
- Order: treeOrder lists the members together, where the first one
stands. The same order decides drawing at equal z, in the editor and
in the game, so the hierarchy and the picture cannot disagree. The
stored object list stays untouched when grouping.
- Hierarchy: a group row shows a folder icon, the name and the member
count. Clicking it selects all members and keeps it folded. Selecting
a single member elsewhere unfolds it. Folding lasts for the session.
- Create with "Group..." in the object menu. A name dialog asks first,
so creating is one Undo step and Cancel creates nothing.
- The group menu has Select members, Rename... and Ungroup. The object
menu adds "Remove from group".
- Drag and drop: onto the group row appends, into a gap between members
inserts there, next to a row outside leaves the group. Dragging the
group row moves all members as a block. The drop rules live in
hierarchyDrop.ts as a pure function.
- Copy and paste keeps a top-level copy in the group of its original.
A container built from group members takes their place in the group.
- Clicking an object that is already selected together with others now
keeps only that object selected. ListView narrows the selection on
release, so a drag starting on such a row keeps the selection.
Not checked in the browser: groups in the container tab, "Remove from
group", Ctrl-click on a group row and a drag out of a multi-selection.
The code path covers them.
Tests:
- check.sh: 177 files, 2552 tests, CHECK EXIT 0
- e2e 1, 51, 52: ALL PASSED, 31 checks (new module 52)
- 19 new unit tests in object-groups.test.ts and hierarchy-drop.test.ts
- 9 counter-probes, all fail as expected (7 unit, 2 in the browser)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The object menu lived only on the hierarchy rows. Now a right-click in
the scene view opens the same menu for the object under the cursor.
- The menu moves out of HierarchyPanel into src/editor/objectMenu.ts.
Hierarchy and scene view call the same function, so their entries
cannot drift apart. Only the hierarchy adds "Expand all" and
"Collapse all".
- Which object the menu is for follows the press rule from #203. A
selected object under the cursor, even a hidden one, keeps the
selection and the menu acts on all of it. Otherwise the topmost
object is selected first.
- The rule is the pure function menuTargetOf in src/viewport/pick.ts,
next to pressOn.
- Empty space, including an empty tilemap cell, opens no menu.
- The container tab gets the same, because ContainerCanvas extends the
scene view.
Tests:
- ./check.sh: 142 files, 2094 tests, exit 0 (was 2090)
- e2e 39,40 fresh: ALL PASSED, 80 checks
- e2e 1-16,39,40 fresh: ALL PASSED, 565 checks
- New block 40r (3 checks): menu on a hidden selected crate, topmost
object without a selection, no menu on an empty cell.
- Counter-check: a right-click that always takes the topmost object
fails 2 unit tests and the hidden-crate e2e check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shader is an asset carrying a node graph (Texture color, Output, Property,
Color, Add/Subtract/Multiply/Divide, Mix, Luminance) that compiles to the GLSL
fragment of a Pixi filter. The Shader tab reuses the Behavior canvas; a sprite
wears a shader through its Shader picker, and the rows below it are the
graph's Property nodes. Property schema, resolution and deviation take the
project because a sprite's rows now live in another asset. The built-in
registry of the prototype is gone; "Adjust colors" ships as a graph in the
fixture and the three example games.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus;
the port and marker menus were the same list and are now one function.
Value specs of the inspector and the list tab share valueSpecs.ts, and
the remaining copies (compound commands, type glyphs, typed asset
lookup, name rule, play/editor world setup, marquee) are merged.
Four copies had already drifted apart; merging them fixes:
- "Disconnect N links" in the port menu is one undo step, not N
- "+ Add collider" is hidden on a placed container instance, as in
the hierarchy menu, instead of always failing
- a bool property coerced from the text "false" becomes false
- a port command on an unknown node type reports instead of crashing
Comment-only edits leave the bundle byte-identical. README corrected in
35 places (step limit, jump-run clock, suite duration, 409 body, ...).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pixi only fills `detail` on the `click` event it synthesises; on
`pointerdown` it is always 0, so every `e.detail === 2` branch was dead
code. Asset rows, hierarchy folding and select-all in text fields never
responded to a double-click.
A shared click counter in src/ui/clicks.ts replaces the three readings of
`e.detail`. A finished double-click ends the run, so two in a row count
2 and 2 rather than 3 and 4.
The harness could only send `clickCount: 1`, which is why no test caught
this: every "activate" check went through Enter. It can send real
double-clicks now, and five checks drive the three paths separately.
An inspector label whose value deviates from its base was drawn bold, which is
easy to miss next to the regular labels and says nothing in the hierarchy, where
a folded subtree could hide a change entirely.
The colour now lives as a theme token (`override`, #f2c84b) used by both the
inspector labels and the hierarchy rows. Whether a row deviates is decided by
`src/core/properties/deviation.ts`: `objectDeviates` compares an object against
what it follows -- the container template for content, the asset otherwise --
and `deviationMarks` carries the mark up to every ancestor, so a folded subtree
still shows that something inside was changed.
`deviationMarks` takes the stored holder, not the resolved one: in the resolved
view container content looks like an ordinary object and `instanceTargetOf`
would compare against the asset, marking every instance.
Covered by 3 unit tests and 5 e2e checks, including one that strips an object's
own overrides to prove the row stays gold for a deviating child and restores
them byte for byte. Full suite remote on the RTX 4070: 275 checks, all passed.
The aspect button now carries two offers, so it becomes a menu: fit one
side to the texture ratio, or set both sides to the exact pixel size as a
single undo step.
Dropping a row on the outer quarter of another row inserts it into that
gap instead of parenting into it, reordering among the siblings there.
Order is the stored order, so ReorderObjectCommand is a splice; equal z
falls back to tree order, which makes this the way to draw one object in
front of another. Rejected on container content like the other structural
edits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A scene now stores only the container object; its content is resolved from
the template on every read, with deviations kept as sparse instanceEdits on
the container object. This drops the propagation bookkeeping entirely --
templateSync.ts is gone and the three structural gaps (add, remove, reparent)
cannot diverge any more.
Values stay editable on an instance, structure does not; Unpack writes the
content into the scene, keeping ids. Play mode bakes containers into the
throwaway copy before entities are built. No migration: formatVersion is 2
and the seeders write the new format.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Container tab reuses SceneViewport against any object holder, so a
container template is edited exactly like a scene. Right-click no longer
changes the selection, which previously made nesting impossible: opening
the menu on a container loaded it, and "Place in container" then targeted
itself.
Template edits (move, rename, override) now reach already-placed instances
per slot, through a shared templateSync used by both the apply path and the
plain object commands. Copies the user has touched keep their own value.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tooling:
- Prettier as the single formatting authority (110 columns, single quotes,
measured against the existing style); Markdown excluded because table-cell
padding turns the README glossary into 1000-character lines.
- Type-aware ESLint ending in eslint-config-prettier, so a rule always means
"this is a bug", never "this looks different".
- .editorconfig and an `npm run check` that runs typecheck, lint, format and
tests in one go.
Findings fixed on the first run: three more duplicate imports, four
String(unknown) calls (parameter types narrowed instead of casts), two dead
initialisers, two redundant casts, a console.log for a real error. Two
warnings were false here and are disabled with a reason in place.
Tests 132 -> 269:
- node-catalog.test.ts covers every node with a run(), with a guard that fails
when a new node has no row.
- behavior-events.test.ts covers the ten event nodes through a running World,
checking timing rather than existence.
- Input's state machine (press/release/releaseAll) is reachable without DOM
events now; the DOM handlers are thin adapters.
Compare fell back to == without a configured operator although the schema
default is <; aligned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Inspector rows compute their reset/apply entries through providers, so an
entry that would not change anything is dropped; a single remaining entry
renders as a direct button instead of a menu, none removes it entirely.
- Bold and buttons now follow the same rule (deviatesFromAsset /
deviatesFromTemplate in core/properties/resolve.ts).
- Hierarchy panel is a collapsible tree: chevrons, arrow keys, double click,
expand/collapse all. Everything starts collapsed; selecting an object
expands its ancestors and scrolls it into view.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Asset type 'collider' (box/circle) placed as child of a body object
- 'Add collider' shortcut (hierarchy context menu + inspector)
- CollisionSystem with enter/stay/exit pairs, sibling colliders excluded
- Nodes: On collision enter/stay/exit, Is touching
- Green outline in edit mode, hidden in play
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Canvas-only 2D game editor/runtime built on PixiJS v8 + Vite + TypeScript.
- core: asset model (Scene/Camera/Sprite), scene objects with sparse
overrides, property schema + resolve, command stack (undo/redo),
serialization and project store interface (16 unit tests)
- ui: custom in-canvas widget toolkit (panels, fields, color picker,
list/scroll, popup) with a hidden <input> keyboard bridge
- editor: asset/hierarchy panels, schema-driven inspector, toolbar
- viewport: box rendering, camera frame, selection gizmo, drag/zoom/pan
- runtime: world/entity instantiation for play mode, input, camera follow
- api: PHP JSON project store with revision counter (409 on conflict)
- tools: CDP-based e2e smoke test
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>