Skip to content

Arachnid issues in EWC - #518

Merged
cursork merged 20 commits into
mainfrom
arachnophobia
Sep 9, 2026
Merged

Arachnid issues in EWC#518
cursork merged 20 commits into
mainfrom
arachnophobia

Conversation

@cursork

@cursork cursork commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@cursork cursork changed the title Arachnid issues in EWC WIP Arachnid issues in EWC Aug 5, 2026
@cursork

cursork commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Closes #449

@cursork cursork changed the title WIP Arachnid issues in EWC Arachnid issues in EWC Aug 21, 2026
cursork and others added 17 commits August 26, 2026 10:15
- Answer Button State / Scroll+Trackbar Thumb WG from the central tree
  (dataRef) instead of the redundant localStorage branch, so a freshly-created
  widget can answer a WG during init (State stays client-authoritative).
- Add TrackBar (range slider) and ColorButton (colour picker) components and
  route them in SelectComponent.
- Rebuilt dist.
Client half of EWC's new Static: the plain ⎕WC panel that pre-dates
SubForm and Group. A positioned container with BCol/FCol, optional
EdgeStyle border, and mouse events.

Two details that matter for the legacy applications that use it:

  - overflow:hidden, because Win32 clips children to the panel. Without
    it a column of cards taller than its Static spills over the panels
    beside it.

  - No caption and no etched frame. Group defaults its EdgeStyle to
    'Groove' to restore the frame ⎕WC draws; Static must not, because
    native Static defaults to EdgeStyle 'None' and renders flush.

Children are positioned against this element and mouse coordinates are
measured from it, so a click landing on a child Image bubbles up and is
still reported in this element's space — which is what ⎕WC does, and
what applications that hit-test drawn children against ⎕WG'Points'
depend on.
…menubar

MenuBarItem. A ⎕WC MenuItem may be parented directly on a MenuBar rather
than inside a Menu — that is how legacy code writes a command with no
submenu, e.g. Arachnid's "Deal Row". SelectComponent had no MenuItem
branch at all, so the object arrived from the APL side and was silently
dropped. MenuItems inside a Menu still go through DropDown, so this only
catches the bar-level case.

Separator renders as a divider in the dropdown, keyed off Type rather
than a caption convention.

Active is honoured in both places: greyed and non-clickable. Previously a
disabled command still looked and behaved live, letting the user invoke
something the application had deliberately turned off.

Align 'Right' on a Menu pushes it to the end of the bar (auto left
margin in the existing flex row) — where ⎕WC applications put Help.

The menu bar now draws on the system control colour instead of being
transparent. ⎕WC draws it on a plinth, and against a coloured form —
Arachnid's table is dark green — transparent was close to unreadable.

Also made the Select-event lookup in DropDown case-insensitive, matching
what the mouse handlers already do.
Client half of ⎕WC's Locator. Native creates the outline and then MOVES
THE OS CURSOR into it (⎕NQ obj 3 y x) so the user is already holding it.
No browser can move the pointer, so instead the outline starts at the
requested Posn and follows the pointer, centred on it — exactly where
the cursor would have been put. See the port's ADR-005.

Three details that are not obvious:

  - Resolve on mouseDOWN, not mouseup. The gesture that CREATES a
    Locator is itself a press (a right-click on a card), and its
    trailing mouseup arrives first — placing the locator instantly at
    the point it started from, so every drag became a no-op.

  - Stop rendering once placed. The native locator is a transient rubber
    band; leaving it up also leaves a live listener that swallows the
    next click.

  - Reset when the object is recreated. ⎕WC on an existing name
    recreates it, and applications reuse one locator name for every pick
    — SELECT_STACK does. React keeps the same component instance, so
    without this the SECOND pick renders nothing, still "placed" from
    the first.

The reply matches what ⎕DQ returns natively — (name 80 y x h w), the
event then Posn and Size — because callers do arithmetic on it:
POSN←1 0.5+.×2 2⍴2↓POSN takes the centre.
⎕WC Forms carry their window title in Caption, and applications keep
state there: Arachnid's TITLE rewrites it on every deal ("APL Arachnid :
Deal 2 of 5"), which is how the player knows how many deals remain.

Desktop mode shows it in the HTMLRenderer window chrome. A browser has
no chrome, so it had nowhere to go and the user never saw any of it. It
belongs in the tab.
⎕WC Points is (Y X) and either axis may be a SCALAR standing for every
point: ('Points'(0 (x1 x2 x3))) is three points sharing y=0. Mapping over
Points[0] drew only the first, so an Image asked to show a row of bitmaps
rendered exactly one of them.

Picture likewise may be one name per point, a single name shared by all,
or a (name style) pair. All three are handled now.

Together with the APL-side fixes this makes Arachnid's Show Stack window
display its cards.
Two defects that between them made the application unreachable after
using a subsidiary window.

Creating a Form ran deleteFormAndSiblings over the whole data tree,
destroying every form already present. ⎕WC does no such thing: a second
window opens over the first and the first is still there — which is
exactly what DISPSTACK and HELP rely on. So when the second window
closed there was NO window left and the game was unusable. Forms now
accumulate, findFormParentID renders the most recent, and the existing
EX handler removing one brings the previous back for free.

A Locator is only live while something is ⎕DQ-ing it, but the object
persists afterwards — SELECT_STACK never expunges it — so "the object
exists" is not "the locator is active", and the client could not tell
them apart. A leftover locator captured the user's NEXT click anywhere
on the form; registered with handler 1 that event returns straight out
of ⎕DQ, so it satisfied the application's MAIN ⎕DQ, SETUP ran to
completion, and everything stopped responding while still looking fine.

Arming is now once per ⎕WC: App.jsx stamps each creation, the component
arms for a stamp it has not resolved, and resolved stamps are remembered
at module level so a remount cannot revive one. Unstamped means inert —
an armed stray is far worse than a dead one.

Two wrong turns worth recording. Re-arming on `data` change fails
because object identity changes on every message. Re-arming on geometry
change fails because the component REMOUNTS when the rendered form
changes, which is precisely what closing Show Stack does. And returning
null from render does not stop the effect running, so the guard had to
go inside the effect too or the listeners were still attached.
On Win32 the menu bar sits in the window's non-client area, above the client
rectangle, so ⎕WC ('Size' (300 600)) gives 300 pixels of usable height
whether or not the form has menus. EWC draws the menu bar inside the form,
so the content area was Size minus the menu: every form with a menu bar was
25px short, the application's own layout arithmetic overflowed, and children
positioned against the bottom fell off.

Visible in Arachnid, which sizes its ten card panels to the full form
height — they hung past the bottom edge onto the page background. That is
what the layout-parity check against the Win32 build caught; it went from
10.56% of cells disagreeing to 0.56%.

hasMenuBar also moves out of the render closure so the sizing effect can
depend on it. Computed inside, it was invisible to the effect, which then
kept a stale size when a menu bar arrived after the form.
The browser raises its own menu on right-button RELEASE. Native ⎕WC has none:
button 2 arrives as MouseDown/MouseUp with the button in the message and
nothing else happens. On a ported application that uses the right button —
picking up cards to drag, say — the menu lands on top of the result.

Gated on registration, exactly like handleMouseDown, which already sends
nothing unless the object registered MouseDown. An object the application has
not claimed the button on keeps the browser's menu: that is the platform's
behaviour and EWC has no business overriding it globally. A first attempt did
exactly that, with one handler on the App root suppressing the menu for every
EWC application everywhere except editable fields.

Wired in beside onMouseDown across the 24 components that take mouse events.
Leaving it off a component simply means the menu still appears there.

The Splitters are deliberately not included: they declare a local
handleMouseDown for a drag they implement themselves and register no ⎕WC
events, so there is nothing to gate on.
…n release

Two defects a player found in a minute that the suite had been green over.

Points are ABSOLUTE positions within the parent. setStyle returns
position:relative for an object with no Posn — which a multi-point Image never
has — and was spread after the per-point placement, so React kept the
per-point left and made it an offset from the FLOW position: each image
displaced by the accumulated width of the ones before it. A 58px pitch
rendered at 112px, and Arachnid's Show Stack, sized by DISPSTACK to fit
exactly four cards, showed two and clipped the rest.

Fixed by ordering, not by force. setStyle's output is EWC's own DERIVED
default and must not beat the Points; customStyles is the CSS the application
asked for explicitly and still beats everything, position included. A first
attempt put position last and made the application's own CSS unoverridable.

Separately: ⎕DQ on a Locator returns when the button comes UP, and DRAGCARDS
creates one from a press the user is still holding, so the release places it.
The client resolved only on mousedown. That was right for a locator created
from a gesture already over — Show Stack's, from a menu item, has no release
coming and would otherwise place instantly where it started — and wrong for
one created mid-press. Now decided by whether a button is held when the
locator arms.

Purely additive: mousedown stays registered either way as a backstop, resolve
is idempotent, so whichever arrives first wins and a release lost to a drag
ending outside the window does not strand the locator.

Until the browser context menu was suppressed, the missing release was masked
— the second click was supplied by accident when the user dismissed the menu.
EWC showed one form at a time (findFormParentID, most recent) and removed the
rest from the DOM, so a subsidiary window wiped out the main one. In
Browser/Multi mode a form tagged ('Primary' 1) now renders in-flow and every
other top-level form renders as a FloatingForm over it — a window-in-the-page
with a titlebar, an X that sends Close, and drag. A transparent click-shield
under the topmost floater keeps the forms beneath inert (a v0 stand-in for the
real pseudo-modal behaviour, which gates event dispatch on the active ⎕DQ).

The policy is one function, findPrimaryFormID: the tagged form, or — with no
tag — the sole form, because most apps have exactly one and must not be made
to care. Several untagged forms return null and the previous single-form
render stands: no inference (creation order breaks on replace-main and on a
login window), no regression, no new behaviour until an app opts in.

Desktop mode is gated off entirely (Mode.Properties.Desktop): there every
form is a real OS window, as before, and Primary is inert. window.open was
never an option — a blocked popup is worse than a visible bug.
The shield blocks pointer events to the forms beneath a floating window, but
keyboard events go to the focused element, not through the shield — so a
focused input in a frozen form still took keystrokes. While a floater is up, a
capture-phase key listener now swallows key events whose target is not the
topmost floater (marked data-floattop) or a MsgBox raised over it. So only the
active window — the top of the ⎕DQ stack — is live, for keys as well as clicks.

The listener attaches ONCE and reads a live ref rather than attaching and
detaching as the floater count changes: a transient re-render briefly
reporting zero floaters would otherwise drop it for a tick, and a keystroke in
that window would slip through. That was flaky until the ref pattern.

This is the browser reimposing "only the top pump is live", which native gets
for free because the OS only feeds events to the foreground window. The server
does not scope dispatch at all — every event goes to the active ⎕DQ and fires
whatever object it names — so the client is where modality lives; the frozen
form simply never generates the event. See ADR-007 / FINDINGS B20.
findPrimaryFormID drops the redundant "a lone form is its own primary" clause:
a single untagged form already renders through the legacy most-recent path, so
the special case earned nothing. The whole rule is now just "the form tagged
Primary, or null", and the two render branches collapse to one — render the
primary if there is one to float over, else the most-recent form, exactly as
before. Single-form apps set nothing and are untouched; there is no inference
policy. Lighter, same behaviour.
The Locator attached its window-level mousedown resolve listener in a passive
useEffect, which runs AFTER the browser paints. So between the outline being
painted — looking armed — and its listener actually attaching, there was a
window in which a placement click was silently dropped: the Locator sat there
still armed, and the next click resolved it, usually to no valid stack ("Error
selecting stack"). Fast placement, or extra render work lengthening the gap,
lost the race.

useLayoutEffect for both the reset and the listener-attach (kept in that
order) runs synchronously after the DOM update and BEFORE paint, so by the
time the outline is visible it is already listening. An impatient stress probe
(place-clicks with no wait) went from missing ~1 in 8 to missing none across a
real deal.

This is latent without floating windows but was exposed by them: rendering a
subsidiary form as a floater also mounts the shield and the keyboard-modality
effect, and that extra work widened the gap enough that the race went from
rare to common. FINDINGS B21.
@cursork
cursork merged commit b8c416b into main Sep 9, 2026
3 checks passed
sloorush added a commit that referenced this pull request Sep 11, 2026
Brings the six commits that landed upstream since the branch was cut:
Plugins (#524), Mandelbrot + Arachnid (#518), Extensive tests for Multi
(#520), the Rect and pictures test fixes, and visual baselines run #31.

Git mapped the moved tree automatically: edits to src/ landed in client/src/,
and the eight new components (ColorButton, FloatingForm, BitmapCanvas,
Locator, MenuBarItem, Static, TrackBar, pluginHost) were placed there too.

Resolutions:

  package.json    upstream's 14 new multitests:*/ewc-multi:* scripts go on
                  the workspace root, next to the demotests:* ones -- they
                  drive Playwright and ci/, both of which live at the root
  ci/run-server.sh kept upstream's SETUP_APL indirection, defaulting to the
                  in-tree /work/ewc/ci/setup-ewc.apl rather than /scripts
  dist/           dropped. Upstream still commits it; this branch does not.
                  Git guessed the new bundles belonged in client/public/
                  (the fonts are identical in both trees) -- they are build
                  output and stay untracked.

ci/ewc-multi-start.sh and ci/setup-ewc-multi.apl were adapted to the single
mount, matching ewc-demo-start.sh.

Note: the Multi suite cannot pass yet. It drives test-apps/multitest, which
is still on ewc's unmerged multimode-tests branch with no open PR -- client
tests landed without their APL counterpart, which is precisely the split this
merge exists to end.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant