Review: examples/music-tree (2026-10-19)

examples/music-tree rewrites examples/music (the Sonos/Spotify wall remote for the HP TouchPad and Nexus 7, 1024x768, drawn with zinc:gfx) around an AS3-style display tree (core/display.ts) plus a small flexbox (components/layout-nodes.ts). This review compares it with examples/music at c548da1c (v1.3.0). zinc check examples/music-tree passes (exit 0). The code was read in full. One engine behaviour was checked with a probe program, run headless outside the repository (see H2). Nothing else was run: Sonos, Spotify and the network were not available.

Size: 11,419 lines in 38 files, against 3,676 lines for music. That is 3.1 times more code, and music-tree has fewer features (see “Worse”).

Ranked findings

#SevWhereIssueFix (one line)
H1Highpages/rooms/rooms-page.ts:170-194, core/display.ts:124,136, main.ts:923onMeasure calls rebuildRoomsTree on every layout: removeChildren() plus 40-60 new nodes, closures and strings. addChild/removeChild set uiState.dirty = true after main cleared it for this frame, so the Rooms page redraws every frame for as long as it is mounted. music only draws a frame when something changed, so this is a regression. The unused field lastTopologySignature (:136) shows the intended fix was never written.Build the cards once per topology signature and update labels and slider values in place. Do not set the dirty flag from changes made to the tree during layout.
H2Highcore/pictures.ts:256-268 called from pages/home/home-page.ts:65 under setTransform(1,0,0,1,0,30) (core/display.ts:438)beginImage keeps the matrix that is in force, so the drawing into the 12x12 blur texture lands 30 px down, outside the texture. The texture stays black, so the player’s blurred cover background is plain black. Probe: a rect drawn into an image under a translate of (0,30) reads back (0,0,0); the same rect drawn at identity reads back (255,0,0). music draws its backdrop at identity and is not affected.resetTransform() before beginImage, then restore the node’s transform. Or build the blur texture in decodeNextPicture, outside the render.
H3Highmain.ts:500-513This is the keyboard code from before music’s fix c548da1c. It never calls startTextInput/stopTextInput, so a desktop keyboard types nothing in the search fields. It also has no Escape and no Backspace repeat. Wheel and trackpad scrolling is missing too (music has wheelInput), so lists cannot be scrolled with a mouse on the Mac, the README’s first run target.Port c548da1c and music’s wheelInput/wheelScroll.
M1Medpages/rooms/rooms-page.ts:162-163, components/layout-nodes.ts:326-345, core/input.ts:129-145Touch zones are not clipped to the scroll viewport, and ScrollContainerNode draws every child (no culling). Rows scrolled under the header still register their zones, and they register after the header’s, so they win. Tapping “Actualiser” while the list is scrolled can select a hidden group (and jump to the home page) or move a hidden volume slider. Inherited: music registers its refresh pill before beginScroll too.Intersect each zone with the clip rectangle that is open (beginScrollBox pushes it), and cull children outside the viewport.
M2Medmain.ts:574,583 → syncMountedPageInTree (main.ts:154-167)The warm-up render switches currentPage, which mounts the warm page in the live tree; the next real frame mounts the user’s page back. Each warm item fires onAddedToStage twice: network calls (rooms-page.ts:166 pollAllRoomVolumes, spotify-page.ts:172) and a Rooms rebuild. music’s warmOne draws without side effects.Render the warm page node directly into the image, without mounting it (no lifecycle calls).
M3Medmain.ts:582-583, main.ts:675 vs :925The warm-up render adds its zones to activeZones without clearing them. The next frame hit-tests (processUserInput) before clearTouchZones, so a touch in that frame can land on a page that is not on screen (its zones are on top). This happens during the 12 warm frames, i.e. the first touch after 1 s idle. Inherited from music’s warmOne.Save activeZones before the warm render and restore it after.
M4Medpages/spotify/spotify-page.ts:292-298,351-360, components/slider.ts:81The “more pages” spinner is drawn even when it is clipped far below the viewport, and it always sets uiState.animating. The Spotify page redraws every frame while a tab with 20 or more items is open (playlists, albums and liked songs, in practice). Inherited from music 1.0.Draw and animate the spinner only when its y is inside [topY, bottomY].
M5MedREADME.md:22, core/input.ts:122,137,161-169, components/buttons.ts:103,133-136, components/sidebar.ts:104, pages/radios/radios-page.ts:272,281,299,326, pages/spotify/spotify-page.ts:387, pages/home/notifications-slide.ts:162-163, pages/home/clock-faces.ts:234-237, components/status-bar.ts:36The README’s “zero allocation per frame” claim is false. Every redraw allocates a new zones array, one TouchZone per zone, a wrapper closure per drag zone, a closure per button, template-string zone ids, a segmentWidths array, a new Item(), a Map with arrays for the notifications, 4 arrays per lava frame and a Date with an array for the clock. Each is small, but together they make garbage on every redraw on a 1 GB tablet.Pool TouchZone objects (reset the length), cache segment widths in the node, hoist the constant Item. Measure before making any claim.
M6Medpages/home/clock-slide.ts:343-367The renderTree override draws the face dots after the children, with the transform the last rendered face left in force. During a vertical swipe or settle, the dots slide with the lower face.setTransform to the slide’s own world position before drawing the dots, or add an onDrawOver hook.
M7Medcore/display.ts:191,228,259-265,193 vs 442Latent layout bugs. (1) In a row with 'stretch', a fixed child gets its height after it was laid out, so its subtree uses the previous height. (2) A width of 0 means “0” in row layout but “the parent’s width” in renderTree, so the child is drawn full width where layout reserved nothing. (3) Flex children run onMeasure twice. (4) direction, alignItems and justifyContent are strings compared per node on every layout. No caller hits (1) or (2) today.Use i32 enum constants, set the cross size before laying out the child, and have one explicit “auto” value.
M8Medinstall.sh:9,23, services/config.ts:68-82, zinc.json:4install.sh installs under music’s webOS id org.zinc.music, so it replaces Musique on the tablet and shares its data directory. saveAppConfig then rewrites config.json without music’s glances/notify keys, which silently resets them. The version is hard-coded to 1.0.0, and the id disagrees with zinc.json (dev.zinc.music-tree).Use ID=org.zinc.music-tree, read the version from zinc.json, and merge the new values into the parsed JSON so unknown keys survive a save.
M9Medevery file, README.mdComments, JSDoc and the README are in French; the repo rule is English, and music is in English. (No JSX, so no class attributes and no lowercase tags.)Translate them (mechanical work, can go to a sonnet agent).
L1Lowstate.ts (900 lines), state.ts:40,43-44,186-195,871-900, services/notify-service.ts:97-117, components/status-bar.ts:36state.ts is a god module. It holds the app state, the Sonos, Spotify and radio actions, the text-field editing, toasts, logging, persistence, the gsap targets and the lava-hint timeline. It also opens webOS ports when it is imported, and it imports a page (pages/home/clock-faces), which inverts the dependency direction. Elsewhere, the UI drag state lives in a service (notify-service), and main.ts takes a clock helper from a component (status-bar).Split into sonos-actions, spotify-actions, radios-actions and text-input. Move the lava palette into the store and the drag state into the notifications slide.
L2Lowstate.ts:306-314 and radios-page.ts:107, spotify-page.ts:172, rooms-page.ts:166The same page data is loaded twice: by navigateToPage and by onAddedToStage. The busy guards drop the second request.Keep only the lifecycle hook.
L3Lowstate.ts:699-712, state.ts:804-826, state.ts:840-865, state.ts:436Races. A radio search, or a Spotify search with forceReload, has no request id, so an older answer can overwrite a newer one. “Load more” can append to a list that was replaced in the meantime. pollActiveSonosGroup compares against appState.activeGroup, which can change during the await. Weather geocoding already does this right (weather-service.ts:94,140,152).Use the same request-counter pattern.
L4Lowpages/radios/radios-page.ts:283-286,300-303splice(findStationIndexByUrl(...), 1) with an index of -1 deletes the last station. Inherited (music main.ts:1094,1100). The two closures are also duplicates.Guard index >= 0 and share one removeStation.
L5Lowradios-page.ts:74-81, layout-nodes.ts:323, notify-service.ts:94 + main.ts:772The scroll position is kept across radio tabs (inherited). ScrollContainerNode never clamps after the content shrinks, which leaves a blank area until the next drag. The notifications scroll box is not in pageScrollBoxes, so it has no fling (inherited).Reset the scroll on a tab change, call clamp() after measuring, and register the notifications scroll box.
L6Lowcore/pictures.ts:80-82,119-123A failed download sets -1 without marking the frame dirty. Covers that are waiting for a download slot only start when something else redraws. A transient failure stays permanent until the entry is evicted.Set uiState.dirty on failure and retry once after a delay.
L7Lowpages/home/clock-slide.ts:273-284, main.ts:781The lava hint timeline keeps gsap.active() true for about 10 s, so every frame is redrawn even after the user leaves the face. The warm-up render of the clock slide also starts it at boot. Inherited.Kill the timeline when the face is not visible, or treat a tween that is holding still as idle.
L8Lowsee listDead code and unused exports: drawCardBackground (primitives.ts:272), COLOR_BLUE (constants.ts:24), PAGE_COUNT (constants.ts:68), setPadding/setSize/worldX/worldY (display.ts:87,103,156,166). Fields never set or never read: alpha (only used as a cull), name, justifyContent (all its branches, display.ts:277-283,369-375), autoWidth = true, alignItems 'end', textAlpha, iconAlpha, lastTopologySignature. Unused imports: fontAscent (primitives.ts:13) and Speaker (rooms-page.ts:10). Exported twice: FONT_* (each constant and the Fonts map). Exported but only used in their own file: LAVA_COLOR_PALETTES, drawVirtualKeyboard.Delete them.
L9Lowzinc.jsonIt has no "permissions" (music declares ["opener","fs","net"]), so file and network calls are not checked.Add ["fs","net"].
L10Lowplayer-slide.ts:84-85, main.ts:228, main.ts:318-352,471Naming: volumeBarScreenX/Y hold coordinates local to the slide. statusBarNode.height is set to the screen height. The same size parameter is named stageWidth, viewWidth or screenWidth depending on the file. The same as (x,y,phase)=>void cast appears 4 times.Rename, and add one callDrag(phase) helper.
L11Lownode_modules/zinc-ts-pluginIt is an absolute symlink to /Users/mowmow/.... It is git-ignored, but tsconfig’s plugin depends on it.Document it, or create it from a script.

Architecture

  • The tree duplicates zinc:ui. zinc:ui already offers Yoga flexbox, a retained tree that costs nothing on idle frames (gfx.keep), hit testing and gestures, a ScrollView with the Android overscroller, a TextInput that uses the system IME, and transitions. display.ts rewrites a subset of the flexbox: no wrap, no min/max, no percentages, no margins, no absolute positioning, no measure cache and no dirty propagation. Half of that subset is never used (L8).
  • And it only covers part of the UI. The home slides, sidebar, status bar, sheets, toast, grids and lists are still drawn in immediate mode inside onDraw; only Settings, Rooms, the volume popover and the page headers use the flexbox nodes. That is two UI models in one app, a third in the repository.
  • The layout nodes repeat the draw functions’ measurements. Most of them only wrap a draw* function and compute its size a second time: PillButtonNode.onMeasure (layout-nodes.ts:215-219) repeats drawPillButton (buttons.ts:65-68), SegmentedControlNode.onMeasure (:260-271) repeats drawSegmentedControl (:100-110), and drawNoSpeakerEmptyState recomputes the pill’s width (player-slide.ts:67).
  • The reason given is not measured. The README says the tree exists to avoid “the cost of zinc:ui”. No decision record and no perf entry measures zinc:ui on the TouchPad (D142 measured music’s raster only).
  • Recommendation. Either (a) keep music’s immediate-mode drawing and take only the module split, or (b) measure zinc:ui with zbench on the TouchPad and Nexus 7, then port the app to JSX (<View className=...>, <ScrollView>, <TextInput>). (a) is the smallest change. Do not keep a third UI model.
  • State coupling. Every page reads and writes appState directly (for example context-sheet.ts:124 sets currentPage without navigateToPage, so the keyboard and search state are not reset). There is no seam between the pages and the actions.

What is better than music (input for the demo quality bar)

  • One module per concern. core/ holds the tree, input, pictures and screen power; services/ the config, radios, UDP, weather and notifications; then components/, and pages/<page>/. music’s main.ts is 1,622 lines holding the config, the network, the input and all five pages.
  • Names that read. PAGE_RADIOS, SLIDE_CLOCK, COLOR_TEXT_SECONDARY, LIST_ROW_HEIGHT, navigateToPage, pollActiveSonosGroup, against music’s TEXT2, F.bodyB, P, SIDE, sc, gg, tGroup.
  • Shared values in one file. constants.ts holds every identifier and limit (page, slide and face ids, ports, cache sizes), instead of bare numbers.
  • A typed config. AppConfig documents each field, and loading and saving live in their own service.
  • One place where a page loads its data: onAddedToStage (once the duplicate calls in L2 are removed).
  • Local coordinates. Each component draws in its own frame and zones are offset automatically (input.ts:139), instead of music’s manual ui.ox/ui.oy bookkeeping (main.ts:1390-1411).
  • A declarative Settings page. The tree is built once in the constructor and the values are bound in onMeasure, with no hand-accumulated y.
  • Pooled popover rows. The volume popover reuses its row nodes and only grows the pool (volume-popover.ts:100-107).
  • Documentation and editor support. Every exported function and class has a JSDoc comment, and env.d.ts plus the tsconfig paths give editor completion.

What is worse than music

  • Visible regressions. The blurred backdrop is black (H2). The Rooms page redraws every frame (H1). The warm-up render has lifecycle side effects (M2). The clock’s face dots move during a swipe (M6).
  • Two days of feature drift (music-tree was forked from music around 1.0 on 2026-10-17). It is missing desktop typing, Escape and Backspace repeat (H3), wheel and trackpad scrolling, the in-app Spotify login and logout, “Reprendre ici” (handoff), the Spotify queue, the glances and notifications settings, and zinc.json permissions.
  • French text. Comments and the README are in French, against the repo rule.
  • 3.1 times the code for fewer features. Each rrect is formatted over 9 lines, and about 1,800 lines are comments.
  • A second UI model mixed with immediate mode, and adapters that measure every widget twice.
  • A README perf claim that is not true (M5).
  • An installer that overwrites music on the tablet and drops its settings (M8).

Edit this page on GitHub ↗