A sprite's texture and shader were only reachable by hunting for them
in the asset panel. Every place that shows such a reference now leads
straight to it.
- Object menu (hierarchy and scene view): "Open texture" and "Open
shader" sit right below "Open asset" when the sprite has one. The
object's own values count, so an overridden shader opens the
override.
- Asset context menu: the same entries at the top.
- Inspector: every asset field with a value shows its label as a blue,
underlined link. One click selects the asset, a double click opens
it. An override stays gold and gets the underline too.
- The first click rebuilds the inspector, so the second one lands on a
different row. The inspector catches that second click before any
row sees it.
- New pure rule `references.ts`: the asset behind a reference field. A
frame leads to its sheet. Kinds without a tab (palette, font, sound)
get no menu entry.
- `followsPress` in `clicks.ts` keeps the double-click window and
tolerance in one place.
- `activateAsset` in `actions.ts`: opening means the same everywhere
as a double click in the asset panel, so a scene also becomes the
active one. The asset panel uses it too.
Tests:
- ./check.sh: 150 files, 2226 tests, CHECK EXIT 0 (before 148/2214).
- 12 new unit tests: 6 for the reference rule, 4 for the link in
createFieldRow, 2 for followsPress.
- 5 new e2e checks in 28l-ii: both menus, the link's look, click
selects, double click opens.
- e2e 1-16,26,28,35,39,40 on the final build: ALL PASSED, 732 checks.
- Counter-probes: no "Open ..." entries, no underline and a second
click never recognised each fail exactly at their checks.
- Check total 1341 (CI counted 1336 at fd6af84), computed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Until now every account could do the same, and managing accounts
meant running make-user.php on the server. Accounts now carry a role,
and admins get a tab in the editor to create, rename, re-role, reset
and delete accounts. Both issues share the same server files, so they
land in one commit.
#180 Roles:
- Every account is admin or user. A missing or unknown role counts as
user, on the server and in the client.
- auth/session and auth/login return {user, role}.
- Auth::requireAdmin() answers everyone else with 403.
- make-user.php takes the role as a third argument. Without it an
existing account keeps its role, so a password reset from the CLI
never demotes an admin.
- Admins see and save other users' projects like their own. Saving
keeps the original owner.
#179 User management:
- The account menu gets "User management" (admins only) and "Plugins"
(everyone). Both open the same closable tab, accounts on top and
plugins below. The fixed Plugins tab is gone.
- Per account: Rename, Role, Password (no old password needed) and
Delete with a confirmation. "New user..." creates one.
- Each account lists its projects with an Open button.
- Delete is refused while the account still owns projects.
- Renaming moves the projects along and ends every session of the
account. transfer takes the same lock as save.
- On their own row an admin can only set the password. The last admin
cannot be demoted or deleted. The server enforces both.
- The account rules live in AccountRules.php and accountRules.ts as
pure functions, so they are testable without a server.
Findings:
- At 1280 px the Delete button reached past the column edge and was
not clickable. The name column is flexible now, and module 49 checks
the layout.
- The server lets an admin delete other users' projects, but the
editor has no button for it yet. Left open in the issue.
Tests:
- ./check.sh: 148 files, 2214 tests, exit 0 (was 147 / 2204).
- New tests/php/accounts.test.php, 51 checks, plain PHP. CI runs it
as its own step in the gate job, the host has no PHP.
- New e2e module 49 with 18 checks. CI creates the e2e-admin account
in serve.sh. Modules 14, 38, 46 and the scaffold follow the new tab.
- e2e 1-16,37,38,45,46,49: all passed, 581 checks.
- Counter-checks: owner on save, admin view, default role and self
delete in PHP fail 7, 5, 5 and 1 checks. Unchecked requireAdmin,
a wider column and the caller as owner each fail exactly their
e2e check.
- Check sum 1314 -> 1333 (+1 in 38, +18 in 49), computed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The theme asks for Source Sans 3 at weight 500, but fonts.ts only
loaded 400, 600 and 700. The browser synthesised the 500 text. The
issue comment also asked for the symbols the font lacks to become SVG
icons. A scan of src/ against the font's cmap found 20 such characters
in 30 places.
- fonts.ts loads source-sans-3-latin-500-normal.woff2 as well.
- Menus draw their ticks and the "current" dot as icons. The dot is a
new MenuItem.current field and no longer part of the label text.
- Inspector action buttons (reset, apply, fit width/height) are
icons. The menu arrow behind them is the drawn Dropdown chevron.
- Kind marks come from a table in markIcons.ts. Picture kinds use an
icon, # and Aa stay text because the font has them.
- Scene marks in the asset panel, sound play/stop and the transition
arrows are icons too.
- Apply is an icon although the font has the arrow. It sits next to
the reset icon and would look different as a glyph.
- A new Icon widget draws on redraw like Button, so Node tests need no
DOMParser.
- Tests find icons through the SVG id. The new hook
__gm.locateIcons(id) and the harness helpers findIcon/clickIcon
replace locate() on the old characters.
Findings:
- Weight 700 is loaded but the theme never uses it. Left as is.
- The old scene-mark check in module 35 accepted any play glyph on
screen. It now checks the mark on the start scene's row.
- Module 44 needs the textures from module 37 and fails without it.
Tests:
- ./check.sh: 145 files, 2187 tests, exit 0 (before 144 / 2143).
- New tests/ui/fonts.test.ts: every theme weight is loaded, and every
character in src/ exists in every shipped face. The cmap reader
matches fontTools (231 characters).
- Icon tests cover the 19 new files. Each kind mark is either an
icon or text, never both.
- e2e 1-16,22,30,34,35,43,44: 586 green, 0 FAIL, then 44 stopped
for lack of module 37. e2e 1-16,37,44,47,48: 618 green.
e2e 1-14 on the final build: 454 green.
- Two new e2e checks: loaded weights in 14, a reset icon pixel in 04.
- Counter-probes all fail as expected: 500 removed, a play glyph put
back, the struct icon removed (one unit test each). In the browser,
500 removed and reset icon hidden fail exactly the two new checks.
- Editor-suite check sum 1312 (1310 measured by CI at 056a622, +2).
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>
Name, opacity and Z each sat in a separate field below or beside
the list. You had to select a row first and then look elsewhere to
change it. Duplicating was a separate button or not possible at all.
Now the row itself carries everything.
- SortableList gets three optional callbacks. onDuplicate shows a
copy button between the eye and the delete button. onRename opens
a text field on double click, Enter or a click outside commits,
Esc cancels, an empty name is ignored. value puts a NumberField
into the row.
- Dragging on the number changes the value and never moves the row.
A whole drag is one undo step, the canvas follows live.
- Copy and delete only show on the hovered and the selected row.
Their space stays reserved, so nothing jumps on hover.
- Pixel layers: copy right above with the same pixels on all
frames, rename, opacity in percent. The Name and Opacity fields
below the list are gone.
- Pixel frames: copy right after, replaces the Duplicate button.
- Tilemap layers: copy with cells, Z and visibility, rename, Z in
the row. The Name and Z fields in the panel are gone.
- Vector shapes: copy at the same place and select it, rename.
- New in core: DuplicateVectorShapeCommand,
DuplicateTilemapLayerCommand, withLayerDuplicated and copyName
("X copy", "X copy 2"). copyName lives in src/core/util/names.ts
because the model never imports from commands/.
Findings:
- Double clicking a shape that is not selected yet only selects it.
The first click moves the list up, so the second click misses the
name. Module 47 selects the row first.
Tests:
- ./check.sh: 138 files, 2055 tests, CHECK EXIT 0 (before 2052).
- GM_STEPS=47,48 (fresh, twice): ALL PASSED, 91 checks (before 86).
- GM_STEPS=39,40,47,48: module 40 green, 47 died on the rename
check, which was a test fault and is fixed.
- Counter probes: a constant copyName fails the shape and pixel
layer tests. A dead copy hit fails the copy check in 47.
- Check total 1272 in README and wiki, computed (1266 + 6).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The layer, frame and shape lists still had their add and delete
buttons scattered below the list, each in its own layout. They now
live in the shared SortableList, so every list works the same way.
- SortableList gets an optional header with the title on the left
and a + on the right, plus an optional x per row next to the eye.
- The keepOne option hides the x on the last remaining row, so a
pixel art image and a tilemap always keep one layer.
- Pixel art layers and frames and tilemap layers use header, + and
x. Only Merge down and Duplicate stay below the lists.
- Vector art shapes get the x but no +. Add shape stays below the
list and Delete shapes stays for a multi-selection.
- The section headings in the pixel art column use the same style
as the list titles and the inspector.
Findings:
- The wait loop added to check 47i for #196 only waited for the
right half of the shape. If the texture landed between the two
reads, the left half was still blue and the check failed. It now
waits for both halves.
Tests:
- ./check.sh: 138 files, 2052 tests, exit 0.
- GM_STEPS=39,40,47,48 (fresh): ALL PASSED, 156 checks (was 154).
- GM_STEPS=47,48 (fresh, final): ALL PASSED, 86 checks.
- Two new e2e checks: x on a shape (47) and x on a frame (48). The
tilemap check in 40 now uses the x instead of Delete layer.
- Counter-check: with the x not reporting to its owner, exactly the
two new checks fail.
- Check total 1266 in README and wiki is computed (1264 + 2).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reordering shapes by dragging worked well in the vector tab. Pixel
art layers and frames and tilemap layers still used up/down buttons.
All three lists now share one widget built from the shape list.
- SortableList in the UI toolkit, with the drag rules next to it in
src/ui/sortable.ts. A row has an optional thumbnail, a name, a hint
on the right and an eye toggle. Every drop is one undo step.
- The shape list is a thin wrapper around it. shapeRows.ts is gone,
its tests moved to the new rules.
- Pixel art layers show the layer alone on the current frame, frames
show the whole frame. Up/Down, Hide, Earlier and Later are gone.
- Tilemap layers get the eye instead of the checkbox. The per-row
up, down and delete buttons are replaced by Delete layer next to
Add layer.
- A row is selected on release instead of press. Selecting rebuilt
the inspector or tilemap panel and cancelled a drag that started
on a row not yet selected.
Tests:
- ./check.sh: 138 files, 2052 tests, exit 0 (before 137 / 2051).
- GM_STEPS=39,40,47,48 (fresh seed): ALL PASSED, 154 checks.
- 5 new e2e checks. Check total 1264 in the README is computed
(1259 + 5), CI never measured 1259.
- Counter-checks bite: dropMove ignoring reversed fails a unit test,
a tilemap drop not applied fails the drag check, a layer thumbnail
showing all layers fails the thumbnail check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Textures are uploaded files named by their content, so they cannot
be edited in place. Pixel art is therefore its own asset type, like
vector art. Its pixels live in the project and the tab rasters them
into a texture that sprites, tiles and animations can use.
- New asset types Pixel art and Palette. The first pixel art of a
project gets a palette with the 16 PICO-8 colours in the same undo
step. Every pixel is a palette index, 0 is transparent.
- Each layer on each frame is its own file data/pixels/<sha256>.px.
The project only holds the names, so undo swaps names. The browser
computes the hash and uploads new cels before the project is saved.
- New route POST ?r=pixels in api/lib/PixelStore.php. It answers 201
and 422 for broken data. data/pixels/ is git-ignored.
- Tools: pencil, eraser, fill (contiguous or all pixels of a colour),
eyedropper, line, rectangle and ellipse (outline or filled), brush
size 1 to 8.
- Layers can be added, deleted, renamed, reordered, hidden, faded and
merged down. Frames can be added, duplicated, deleted and reordered,
with onion skin and a small playing preview.
- Zoom 1x to 32x in whole steps, grid from 8x, a 3x3 tile preview and
Resize... with a 3x3 anchor.
- All frames sit side by side in one image. Each frame is a region
like a cut texture frame, so texture rows offer "Asset / Frame 2"
and the context menu has Create animation from frames.
Decisions:
- Merging down drops the opacity of the upper layer. Indexed colours
know no blends.
- Only the last palette colour can be removed. Otherwise the indices
of every image using the palette would shift.
- Undo stores the whole layer per stroke instead of pixel diffs.
- Alt+click picks a colour. It is a mouse gesture, not a shortcut.
Follow-up issues: #197 import from texture, #198 selection and
flipping, #199 PNG export, #200 more tools.
Tests:
- ./check.sh: 136 files, 2049 tests (before 135 / 2011). 34 new in
tests/core/pixel-art.test.ts, 4 from the icon tables.
- e2e module 48 fresh: ALL PASSED, 29 checks, console empty.
- Counter-probes bite: five on the unit tests, upload order (2 tests
fail) and cels not uploaded in the browser (1 check fails).
- Check total 1259 in README is computed (1232 measured in CI + 27).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The snap toggle was a checkbox with the word "Snap" next to Fit, and
the vector art tab had its own "Grid" button for the same idea. Both
now share one square icon button, so snapping looks and works the
same wherever it exists.
- SnapToggle is a square toggle button with a magnet icon and no
text. It lights up blue when snapping is on, like the other toggle
buttons.
- The magnet is drawn with cubic curves instead of arcs, because the
Pixi SVG parser draws arcs as jagged edges.
- The vector art tab drops its Grid button and uses the same
SnapToggle. It still snaps to 8 px, the graph tabs to 24 px.
- SnapToggle accepts anything with setSnapping(on). The vector art
canvas method is renamed from setSnap to setSnapping for that.
- Every tab exposes the button as __gm.<tab>.snapButton(). Modules
06, 26 and 36 click through that hook instead of the label "Snap"
plus 30 px.
Tests:
- ./check.sh: 129 files, 1903 tests (was 128 / 1897)
- 3 new tests in tests/editor/snap-toggle.test.ts, 3 in the icon tests
- GM_STEPS=1-6,26,36,47 fresh: ALL PASSED, 346 checks
- GM_STEPS=47: ALL PASSED, 32 checks (was 29)
- Counter-checks: toggle not switching its target fails 2 unit
tests, the canvas ignoring the switch fails module 47
- Check total 1188 in README and wiki is computed (1184 + 4), the
full run is left to CI
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Part (a) of the animation rework: model, commands, saving, runtime and nodes.
- AnimationAsset holds a root (sprite, text, container or scene) and rows; a row keys one
property of the root or of an object inside it, keys sit on whole frames.
- The last key decides the length (its frame counts as a frame of its own). The playhead runs
between frames, so numbers and colors blend every tick even at 10 fps; everything else holds.
- Playback gains `once-reset`, which puts back what was there before the start. A row can be
relative, adding to where its target stood.
- Play / Pause / Stop animation act on an object; each instance plays on its own, the later
start wins on a shared property, and a behavior writing the same property still writes last.
- A sprite no longer wears an animation through `texture`. "Create animation from texture"
builds a sprite plus a texture row keyed frame by frame instead.
- The Animation tab shows a placeholder until the timeline editor (part b) lands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI found the gap the hard way: with every kind of asset in the project
the new scene sits below the panel, and the right click for "Set as
start scene" landed somewhere under the list instead of on the row.
locate() only asked whether a text was visible, and a masked row still
is - it is merely clipped away.
locateAll and overlayTexts now walk the tree together (eachText) and
carry the clip of every ancestor, measured on the widget that owns the
mask: Pixi v8 hands a Graphics set as a mask to the mask effect and
reports empty bounds for it, so asking the mask itself declares every
masked text in the editor missing. Two hooks with two notions of
"visible" was the second half of the same bug.
The panel also scrolls the selected row into view now, the way the
hierarchy has always done - an asset selected elsewhere has to be
findable in the list, test or no test. And step 21 learned that scene
is a property kind.
Load scene sets a flag on the World like Stop and Restart do; the swap
happens after the tick, and the last wish of a tick wins. Exactly one
thing crosses over: the SharedState, so global variables and global
machines keep what they know while everything local is built anew.
Loading the scene that is already running rebuilds it, which is what
"reset the level" means when the score must survive.
A run starts either from the start scene (a new project field, set from
the asset panel and marked with an arrow in the list) or from whatever
scene is open. The toggle next to Play says which question is asked and
remembers the answer in localStorage: it is a way of working, not part
of the game.
Sounds no longer end because a scene ends. A voice belongs to the
behavior that started it and stops right after On destroy, so the scene
swap is no longer a special case at all. For the enemy that plays its
explosion and destroys itself in the same breath there is now a
detached input on Play sound.
The scene selector stays usable while a run plays, because a double
click in the asset panel already did the same thing unguarded. It moves
what the editor looks at, not what the run plays.
Typing "variable" into the node palette landed on `Run behavior`, because the
filter weighed label, type and description alike and then sorted by category.
`searchRank` in `src/ui/search.ts` now ranks a match: exact label or type, then
prefix, then label, then type, and only last a hit that lives in the
description alone. Category and alphabet order within a rank. The searchable
`Menu` uses the same function, so searching means the same thing everywhere.
The `+` menu grew to fifteen entries in declaration order. It is built in two
alphabetical groups now -- the types that go into a scene, then the ones that
stay in the library -- with a separator between them, the split `isPlaceableType`
already draws. `ASSET_TYPES` itself stays untouched: it is also the
serialisation and check order. A search flattens the groups and drops the lines.
Popups were capped at fixed heights (320 menu, 260 palette, 340 pickers) while
the canvas had room to spare. `Popup.roomFor()` measures its own chrome and
takes the window height minus a tenth at each end. Placement clamps
asymmetrically: the bottom margin is enforced, the top is only a budget, so a
menu anchored near the top stays at its button instead of being pushed off it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.
Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.
Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.
The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.
A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.
The picker lists everything the server holds and renders each row in the face it offers.
The family name is gm-font-<hash16>, never the name inside the file, so two "Regular" cannot
collide. WorldViewport.syncText is the single place that draws it, which covers scene, game
preview and play at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One rule for every asset type: a click on an asset row selects it, a
double-click (or Enter, or creating it via +) opens its tab. Selection
alone still switches tabs only for nodes, frames and template objects.
- state.openAsset(id) = select + 'open' event; the editor maps the asset
type to its tab via TAB_OF_ASSET. The two special clauses for state
machine and shader assets in the selection handler are gone.
- Creating via + and importing a texture from the picker open the tab;
paste/drop of an image only selects.
- 3 unit tests (event order, re-open of a selected asset, selection never
fires open), 14 e2e checks in step 4.
Co-Authored-By: Claude Fable 5.1 <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>
An animation asset is a list of pictures (whole textures or frames) with
frames per second and Loop/Once/Ping-pong playback. A sprite wears it
through its texture property, so the picker, instance overrides and the
Properties node work unchanged; there is no dedicated play node.
A different animation starts from its first frame, the same one set again
runs on, so a graph may choose Run or Idle every frame. The clock lives on
the entity, not in props, which the world writes back as overrides. The
AnimationSystem ticks before behaviors, and On animation finished fires
once when a Once animation reaches its end.
The Animation tab plays a preview above a frame strip: reorder by dragging,
Delete, duplicate, + Frames, pause/resume, and wheel zoom about the pointer
with pan and Fit. Scene and Container tabs show the first frame; edit mode
has no clock. Right-clicking a texture creates an animation from its frames.
Button now measures its label with the bold font it draws in, which cut
the Animation tab and red dialog buttons short.
Co-Authored-By: Claude Opus 5 <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>
The sprite takes the whole image at its pixel size and the texture's name,
numbered like any other clash. CreateAssetCommand now accepts starting
properties; they go through the schema in execute, so an unknown key or a
bad value is refused before the project is touched, and one undo removes
the asset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pause is a sub-state of play, not a third mode: the toolbar button and
Ctrl+Enter stop ticking the world, the clock included. A held frame still
drops the input edges, so resuming does not fire a pause worth of keys in
one go.
The editor run missed the case it is most wanted for. hasEditorRun only
looked at the stored scene objects, so a behavior inside a placed
container never started one; the world now builds from resolveObjects and
writes back through objectRef, keeping the move as an instance edit
instead of baking the instance out.
Keys and clicks go to the run while the Game tab is on screen, where no
key means anything to the editor, and are handed back on leaving it -
held keys released, so nothing keeps walking unpressed.
"+ → Texture" opened the machine's file dialog, so a picture that was uploaded once
but lost its asset – an undone import, a deleted asset – was unreachable from the
editor while still sitting on the server. Nothing collects those files, by design.
It now opens a grid of every picture the server holds, with thumbnails: pick one and
"Use image" makes a texture asset on that file, "Upload…" is the way in for bytes the
server does not have yet and lands in the same import as a drop. A tile captioned with
an asset name is one this project already wears – picking it selects that asset instead
of making a second one, since two assets on one file would fight over `filter`.
- `GET ?r=textures` now reports `width`/`height`; a texture asset holds the pixel size,
and reading it off the GPU texture would tie the asset's correctness to load timing.
- `TextureLibrary.load(file)` loads without touching `scaleMode` – a thumbnail must not
restage the filter of a picture the scene is drawing with.
- The grid geometry lives in `texture/imageGrid.ts` as pure functions; inside the widget
no test could reach it.
- The part after an upload is now one function shared by the drop import and the picker,
so "one file = one asset" is stated in a single place.
12 new E2E checks; the strongest one undoes an import, leaving the file on the server
with no asset naming it, and picks exactly that file back out of the dialog.
The inspector already marked an asset value that differs from its schema
default, but the Assets panel gave no hint, so a changed asset looked
like an untouched one until you opened it.
The rule sits next to the existing objectDeviates as a pure function:
assetDeviates asks whether any marked property differs, markedAssetSchema
says which rows the inspector marks at all. buildAssetSection now builds
its rows from that same list, replacing the behavior/rest split it used
to carry, so the list cannot claim a deviation the inspector then fails
to show.
A behavior's variables are deliberately not in it. Their default is
declared on the variable itself, which means the asset value *is* that
default; counting them would paint the fixture's Mover gold while the
inspector beside it marks nothing.
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 instance edit was dropped as soon as its value matched the template
object. With nested containers the template is not what the value falls
back to: the outer container holds an edit for the object below it, and
that one wins. If the inner template happened to carry the same value the
user typed, the edit was thrown away and the value jumped to whatever the
container in between says.
Reported against the Jump-and-Run project: a coin narrowed to 8 inside
"level", then set back to the sprite's own 24 in the scene, snapped back
to 8. Reproduced through the inspector before touching anything.
InstanceTarget now offers `base`: the template with the edits of every
container in between woven in. Everything that judges deviation uses it -
setOverride/setName/setPosition, both Revert commands, and the gold marks
in the inspector. Writing still goes into the real template, which is
where "apply to container" belongs.
`base` is built on every read. Apply writes into the template and then
asks whether the edit still deviates, so a base captured beforehand would
answer for the template as it was - the e2e suite caught exactly that.
Also in here, because it corrects the commit before it: placing an asset
by double-clicking its row is gone, as asked for in asset-doppelklick.md.
Dragging is the only way to place now. That took AssetPanel.place, the
PlacementTarget interface, Editor.placementTarget and the two worldCenter()
methods that existed only to feed it. A double-click on a scene asset still
makes it the active scene.
The context menu entry is gone; dragging replaces it entirely. The drop
target is the view under the pointer, not the active tab, so the same
gesture works for scenes and for container templates.
While dragging, an outline shows the footprint the object will have plus
a crosshair on its origin, turning red when the drop would be refused.
Preview and command share one function (placementRejection), so the
outline cannot promise something the drop then rejects.
A left-press used to select the list row immediately. Since the container
tab follows the selection, it switched to the very container being dragged
and every drop would have nested it inside itself. The list now selects on
release without a drag: picking something up is not the same as choosing it.
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>
Remove comments that only restate the code, keeping the section banners
and the notes that carry a reason, a measured fact or an external
contract.
ButtonOptions.width claimed to be a fixed width but only set minWidth,
so the Graph and Texture pickers grew with the name they showed. It is
now what it promises, with a separate minWidth for the buttons that have
two forms. Narrow buttons shrink their padding, otherwise a 24px icon
button had 8px left for its glyph and truncate dropped it entirely.
__gm.labelsDropped() guards that class: the overflow guard only saw text
that was too wide, never a label truncated to nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Images are uploaded to a content-addressed blob endpoint and stored as
data/textures/<sha256>.<ext>; the project only keeps the file name. The
sprite points at a texture asset through an ordinary property of the new
kind `asset`, so instance overrides, "Apply to container", "Reset to
asset", undo and `Set property` work without any extra code. The new kind
also replaces the ad-hoc asset picker the graph nodes faked as an enum.
Import goes through + -> Texture, drag & drop onto the canvas or Ctrl+V.
Images wider than 2048px are downscaled client-side and re-encoded to
WebP if needed, because nginx caps uploads at 1MB; an image that already
fits is uploaded byte for byte so pixel art is never resampled.
The sprite colour default changes from red to white, since colour tints
the texture multiplicatively and a tint of white shows an image as it is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Menu entries are left-aligned and live in a ScrollContainer capped at 320 px, so a
long list (the parent picker holds every object of the scene) scrolls instead of
running off the screen. `Menu(items, { searchable: true })` puts a filter box above
the scroll area; Enter takes the first match. Filtering forces a relayout because
two queries with the same hit count leave the height unchanged, which would keep
the stale row positions.
Fields that open a menu are a new `Dropdown` widget: the value sits flush left and
the chevron is drawn flush right instead of being written into the label. A menu
opened below a field is never narrower than that field.
New debug hooks `overlayTexts()` / `overlayRect()` make the popup contents
assertable; e2e step 11 covers the cap, the scrolling and the search box.
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>