Skip to content

docs(presentations): "SpyDE — an overview" — a 12-minute talk, authored in SpyDE - #104

Open
CSSFrancis wants to merge 19 commits into
mainfrom
docs/spyde-presentation
Open

docs(presentations): "SpyDE — an overview" — a 12-minute talk, authored in SpyDE#104
CSSFrancis wants to merge 19 commits into
mainfrom
docs/spyde-presentation

Conversation

@CSSFrancis

@CSSFrancis CSSFrancis commented Jul 31, 2026

Copy link
Copy Markdown
Owner

A ~12-minute conference talk about SpyDE, authored in SpyDE — a real
.spyde-report presentation document, not a PDF. It dogfoods the Report
Builder's Present mode, and now its theme support.

doc/presentations/spyde-overview.spyde-report   the deck — 21 slides, 513 KB
doc/presentations/build_spyde_overview.py       the generator (the durable source)
doc/presentations/media/                        real app screenshots it embeds
doc/presentations/README.md                     how to open / edit / re-capture / verify

To open it: launch SpyDE → Report sidebar (panel toggle, top right) →
Open → pick the file → Present. / navigate, S toggles the
presenter view, Esc exits.

The deck uses only markdown / image / split cells — no live figure bindings — so
it opens standalone with no data loaded.

title where this comes from
open, or it isn't reproducible direct electron's bet on open source
anyplotlib anyplotlib is a bridge
active development roadmap
what spyde is presenter view

What the talk argues

The previous draft was a feature tour. It answered what SpyDE does well and
never answered the two questions a conference audience asks first — who is
this person
and why should I trust the software with my data. The rewrite
puts both up front and keeps the engineering where it belongs, as evidence.

Where this comes from (slide 2) is the biographical argument: a PhD with
Paul Voyles at UW–Madison measuring disorder in glasses with 4D-STEM, then
HyperSpy and pyxem maintenance because the analysis did not exist yet, then
Direct Electron and the same complaint from the detector side. "SpyDE is what
those two jobs look like when you do them at the same time."

Open, or it isn't reproducible (slide 8) is the Apple-vs-Linux framing —
polished-and-closed against readable-and-still-running-in-ten-years. The point
it lands on: a number you cannot re-derive is an anecdote, and science needs the
property where you open the same file years later, run the same pipeline and get
the same answer. "SpyDE wants the polish of the first and the guarantees of the
second."

Direct Electron's bet on open source (slide 9) says the position plainly
rather than implying it: DE pays for this and gives it away, GPL-3.0, for any
detector, no paid tier and no licence server; the work goes upstream into
HyperSpy, pyxem and RosettaSciIO; deapi and anyplotlib are MIT. "A camera
is only as useful as what you can do with the data."

anyplotlib gets two slides where it had half of one. Slide 11 is what it is
— matplotlib's object-oriented API rendered in the browser, so pan/zoom/drag
never touch the kernel, and deliberately light (anywidget, numpy,
traitlets, colorcet, no matplotlib). Slide 12 is what it is for: a
bridge that runs in Jupyter Lab, in Electron, behind deapi, and as a
self-contained save_html() export — with a HyperSpy interactive backend
listed as the goal, not a feature, because that is what it currently is.

Roadmap (slide 19) is quantem — the PyTorch-native toolkit for ptychographic
phase retrieval and HAADF tomography — framed as making the same bet SpyDE's own
GPU paths already make: put the whole problem on the device and batch it. The
integration goal is stated as a data-model question, not a rewrite.

"Inherit it, then give it back" — slide 6 now closes on the point that an
enormous amount of excellent work already exists and SpyDE's job is to put one
consistent interface on it and hand it back to the community free. Slide 20
ends on the same note.

The deck now carries a theme

Using the theme: front-matter support that landed on main:

  • the app's own dark palette (#12121c, accent #89b4fa) — every screenshot in
    this deck is a dark SpyDE window, so a light deck would frame each one in a
    bright box
  • a footer bar with name · email · affiliation and the slide number, drawn
    on every slide except the three title/section cards, which carry their own
    attribution
  • the SpyDE mark embedded as a data: URL, so the file still looks right
    after it is emailed to someone who has never had this repo on their disk

SPEAKER is now one table at the top of the generator — name, role, org, email,
venue and date. A blank venue/date drops its line rather than leaving a
dangling separator, so the same file works for the next booking with one edit.

Slide-by-slide, with the time budget

# slide s
1 SpyDE (title) 20
2 Where this comes from 45
3 Big data, small patience 35
4 What SpyDE is 📷 45
5 HyperSpy is the data model 50
6 Don't reimplement the science — inherit it 40
7 One app, several techniques 📷 30
8 Open, or it isn't reproducible 40
9 Direct Electron's bet on open source 40
10 Two processes, one line protocol 40
11 anyplotlib — plotting that stays interactive 📷 45
12 anyplotlib is a bridge 35
13 The hard part: staying live on lazy data 40
14 GPU where it pays 📷 40
15 Interaction is the feature 📷 30
16 …and this talk is a SpyDE document 40
17 What's next (section) 10
18 Active development 45
19 Roadmap 40
20 Try it 25
21 Thank you (title) 10
total 745 s = 12.4 min

Five more slides than the previous draft in about the same running time — the
data-model and signal-tree slides merged, and the engineering slides were
trimmed rather than dropped. The build now asserts the budget stays inside
the ~11–13.5 min slot instead of only printing it, so the talk cannot silently
drift out of its booking.

No placeholders left

The previous draft shipped [TODO] markers on the slides and two
deliberately blank ones. Both are gone. Everything still to be decided moved
into the speaker notes, where a projector cannot show it to the room —
including which release to point people at, whether to mention the HyperSpy fork
upstreaming, and what to say if someone asks which machine the benchmarks ran
on.

Claims are still repo-checked rather than remembered: SUPPORTED_EXTS (.csb
included), the eels/ebsd/atoms extras, requires-python = ">=3.10,<3.14",
GPL-3.0-or-later, and the spyde/{fitting,ebsd,atoms,drift,particles, spectroscopy} packages behind the Active-development slide. orix is still
described as arriving transitively rather than as a declared dependency, and
lumispy is still absent because it is not a dependency.

One link correction: the deck previously pointed the audience at
directelectron.github.io/spyde and github.com/directelectron/spyde. Both
currently 404 — the live pair is cssfrancis.github.io/spyde and
github.com/CSSFrancis/spyde, which is what the deck now says. They are
REPO_URL / DOCS_URL constants at the top of the generator, so a move is one
edit.

How it was verified

electron/tests/talk_present.spec.ts runs against the real app: it opens
the committed deck through report_open, enters Present mode, pages all 21
slides, screenshots each, and fails a slide that renders no text or overflows
horizontally. It now also pins the theme, as computed styles — the failure
mode is a theme that round-trips through the front matter perfectly and then
loses to a hard-coded stylesheet rule:

  • the deck background and the --spyde-deck-accent custom property
  • the footer bar, its embedded logo, and the contact line
  • that a title card carries no footer, and that footers number exactly the
    non-title slides
  • that an h2 inside slide markdown takes the themed colour

Passing, and all 21 shots plus the presenter view were looked at. 76 report
tests still pass (test_report_theme / _model / _present).

Two defects came out of looking rather than out of the assertions:

  1. The generator's repo-root path was one dirname short, so the logo lookup
    silently missed and the deck serialized with no logo. The spyde import
    worked anyway because the script is normally run from the repo root, which is
    why it had never surfaced.
  2. getByTestId('present-footer') matches every mounted slide — Present
    mode keeps them all in the DOM — so the first version of the "title slide has
    no footer" assertion passed for the wrong reason. It is now scoped to the
    active stage, with a separate count check.

Housekeeping

The branch was 92 commits behind main and predated the deck-theme work, so
main is merged in. No application source is touched — this PR is additive: the
deck, its generator, its screenshots, a README, and the two specs.

…SpyDE

A ~12-minute conference talk about SpyDE, built AS a SpyDE presentation
document rather than a PDF — dogfooding the Report Builder's Present mode.

  doc/presentations/spyde-overview.spyde-report   the deck (18 slides, 499 KB)
  doc/presentations/build_spyde_overview.py       the generator (the real source)
  doc/presentations/media/                        real app screenshots it embeds
  doc/presentations/README.md                     how to open, edit, re-capture

The deck covers what SpyDE is, HyperSpy as the data model, the signal tree,
the expansion packages (pyxem core; exspy/kikuchipy/atomap extras; orix
transitive), anyplotlib, the Electron/Python split, the live-navigator and GPU
work, and the report builder itself. Two "Active development" slides are left
as OPEN placeholders, and every unverifiable claim is marked [TODO: ...] in the
slide or its speaker notes rather than guessed at.

Slides are markdown/image/split cells only — no live figure bindings — so the
deck opens standalone with no data loaded. Each slide carries speaker notes and
a time budget; the generator prints the total so the talk stays in its slot.

Screenshots come from the real app via electron/tests/talk_screenshots.spec.ts
(a capture run). electron/tests/talk_present.spec.ts opens the committed deck in
the app, pages through all 18 slides in Present mode, and fails on a slide that
renders no text or overflows — all 18 verified visually.
@CSSFrancis
CSSFrancis force-pushed the docs/spyde-presentation branch from 1aa7cac to acf20e0 Compare July 31, 2026 14:11
…t, a theme

The deck read like a feature tour written by someone who had never given the
talk: on-slide [TODO] markers, two slides that were deliberately blank, and no
answer to the question a conference audience actually asks first, which is who
is this person and why did they build it.

WHERE IT COMES FROM is now slide 2, because the credibility argument is
biographical: a PhD measuring disorder in glasses with 4D-STEM, then HyperSpy
and pyxem maintenance because the analysis did not exist yet, then Direct
Electron and the same complaint from the detector side.

OPEN, OR IT ISN'T REPRODUCIBLE and DIRECT ELECTRON'S BET ON OPEN SOURCE are the
two slides the old deck was missing entirely. The first is the Apple-vs-Linux
framing — polished-and-closed against readable-and-still-running-in-ten-years —
landing on the point that a number you cannot re-derive is an anecdote. The
second says plainly that DE funds this and gives it away, GPL-3.0, for any
detector, with the work going upstream. Both are positions, so they are stated
as positions rather than implied.

ANYPLOTLIB gets two slides instead of half of one: what it is (matplotlib's OO
API rendered in the browser, deliberately light, no matplotlib required) and
what it is FOR — a bridge that runs in Jupyter, in Electron, behind deapi, as a
HyperSpy backend, and as a self-contained HTML export. The HyperSpy backend is
labelled a goal, not a feature, because it is one.

ROADMAP is quantem: PyTorch-native ptychography and tomography, which makes the
same bet SpyDE's GPU paths already make. ACTIVE DEVELOPMENT is now the real tree
as of 0.3.0 rather than four empty bullets.

A DECK THEME, using the front-matter support that landed on main: the app's own
dark palette (so the embedded screenshots aren't framed in bright boxes), a
footer bar carrying name / email / affiliation / slide number, and the SpyDE
mark embedded as a data: URL so the file survives being emailed. Title cards
carry no footer.

NOTHING ON A SLIDE IS A PLACEHOLDER any more. Everything still to be decided
moved into the speaker notes, where a projector can't show it to the room.
SPEAKER is one table at the top of the generator — venue and date included, and
a blank one drops its line instead of leaving a dangling separator.

Held to the slot: 21 slides, 745 s = 12.4 min against the old 18 / 12.3, by
merging the data-model and tree slides and trimming the engineering ones. The
build now ASSERTS the budget rather than only printing it.

Two things found by looking rather than by asserting:
  * the repo-root path was one dirname short, so the logo lookup silently
    missed and the theme serialized with no logo. The import worked anyway
    because the script is normally run from the repo root.
  * present-footer matches EVERY mounted slide, not the active one — Present
    mode keeps them all in the DOM. The first version of the assertion passed
    for the wrong reason. It is now scoped to the stage, plus a count check
    that pins footers to exactly the non-title slides.

Verified in the real app: talk_present.spec.ts pages all 21 slides, asserts the
themed background / accent / heading colour / footer / logo, and every shot was
looked at. 76 report tests still pass.
@CSSFrancis CSSFrancis changed the title docs(presentations): "SpyDE — an overview" — a 12-min talk authored in SpyDE docs(presentations): "SpyDE — an overview" — a 12-minute talk, authored in SpyDE Aug 4, 2026
…NG that lands where you drop it

Three things, all reported from real use.

SPLIT LAYOUTS — the model has had four (`text-left`, `text-right`, `text-top`,
`text-bottom`) and the EDITOR has offered all four in its layout picker, but
Present mode tested the value with a bare `!== 'text-right'`. So a stacked split
round-tripped through the document perfectly and then rendered SIDE BY SIDE: the
editor offered a layout the deck could not show. Present now resolves the same
two booleans the editor does (`stacked`, `textFirst`) through one shared helper,
and a stacked split gets one column with the text row sized to its content —
`1fr 1fr` there would hand a two-line caption half the slide.

The HTML export already handled all four correctly; only its docstring (and
`report_set_split_layout`'s) still claimed two. Fixed so they stop misleading.

The GENERATOR gated on the left/right pair too, so `text-top` fell through to the
full-width path — a markdown cell plus a detached image cell, with no error to
say why. It now accepts `_SPLIT_LAYOUTS` and RAISES on an unrecognised value,
because the silent fallback is the whole complaint.

TEXT SLIDES FILL THE STAGE. A heading and four bullets rendered at prose size,
vertically centred in a 60rem column — a small block adrift in a large dark
rectangle on a projector. Content slides now anchor their heading at the TOP
(centring is right for a title card, but on a content slide it floats the
heading at a height that depends on how much text follows, so consecutive slides
visibly jump), and a text-only slide scales its type by CONTENT LENGTH: sparse
gets the large tier, dense keeps prose size, which is what stops it overflowing
into the pager. Scaled by length rather than by measuring — deterministic, no
reflow loop. The column widens with the type, because `max-width` is in `rem`
and leaving it at 60rem while the body goes 18px to 27px would SHORTEN the
measure to ~45 characters and undo the point.

A DROPPED PNG NOW FILLS THE SLOT. Dragging an image onto a split cell's figure
side embedded it BELOW instead. Both drop zones gated on `isComposeDrag`, which
only tests the figure/window pill mimes — a file drag carries `Files`, failed
the test, bubbled to the sidebar body, and was appended as a new image cell. And
there was no verb that COULD fill a slot that already existed.

  * `report_set_cell_image` — the image counterpart of report_set_split_figure.
    A split takes the photo as its figure side, text and layout untouched. A
    figure PLACEHOLDER converts IN PLACE to an image cell: same cell id, so the
    slide attributes riding on a slide's first cell (break, kind, style, speaker
    notes) survive — re-creating the cell would silently drop the notes and
    merge the slide into the previous one. There is a test for exactly that.
  * The image-file helpers move to `components/imageDrop.ts`. While they were
    private to ReportSidebar, only the sidebar BODY could recognise a dropped
    PNG, which is the root of the bug. The `items` fallback in `hasImageFiles`
    is load-bearing: during dragover the browser does not expose `files` yet, so
    a target testing `files` alone never calls preventDefault and therefore
    never RECEIVES the drop.

VERIFICATION. `present_layout.spec.ts` drives the real app: it seeds a deck
through the real backend verbs, then asserts a stacked split renders as ONE grid
column with the text genuinely above the figure, that a sparse slide's computed
font size is >1.25x a dense one's, and that the dense slide still fits its stage.
The fixture image is 480x300, not 1x1 — a single pixel satisfies every assertion
while showing nothing, so it would prove the slot was filled without proving
anything was DRAWN in it. Screenshots looked at. 14 new backend tests; 570
report tests pass; tsc clean.

talk_present.spec.ts no longer hard-codes the slide count or the theme hexes. It
asserts against a deck that is EDITED as well as generated — that is the point of
shipping a talk as a SpyDE file — so a fixed 21 failed with a different number
every run while the file was being worked on. The count is now discovered by
waiting for it to settle, and the theme is checked RELATIVELY (the painted
background must equal the theme variable the deck publishes), which is the
invariant that actually broke before and holds for any palette.

Note: doc/presentations/spyde-overview.spyde-report is deliberately NOT in this
commit. It currently holds hand-edits made in the app, not generator output.
A figure cell persisted as a RECIPE (figures/<id>.yaml) plus a baked still
(assets/<id>.png). The recipe points at the SOURCE signal, so reopening a report
without that data loaded gave you a picture: you could look at the figure but not
pan it, zoom it, or touch a widget. The odd part is how close it already was —
figure_builder.build_figure wants a spec AND a per-layer snapshot map, the spec
already round-tripped, and only the pixels were missing.

data/<id>.npz supplies them. On open, a cell whose sources don't resolve but
whose pixels WERE saved is rebuilt into a real anyplotlib figure and marked
DETACHED: every interaction works, and the one thing it cannot do is refresh from
a signal that isn't there — so that is the one affordance the UI withholds (the
⟳ button becomes a non-interactive badge explaining why). `data_detached` is
deliberately NOT `data_offline`; offline still means "there are no pixels, show
the PNG", and that path is unchanged and still tested.

Purely additive on disk. A reader that doesn't know about data/ ignores it, and a
report written before this loads exactly as it did — there is a test for each
direction. read_report keeps its 2-tuple (unpacked at ~20 call sites); the pixels
come back through a sibling read_report_snapshots rather than churning all of
them to carry something most don't want.

np.load runs with allow_pickle=False and object arrays are dropped at pack time
rather than pickled: a report is a file people email each other, and opening one
must never be a code-execution path. A corrupt blob degrades to "static PNG",
never to a failed open. Per-cell cap of 32 MB, over which the cell saves without
data and reopens as the still — a multi-panel figure of 4096² float64 layers
would otherwise quietly add hundreds of MB to a file meant to be shared.

ALSO — the dead box in the screenshot that started this. A split cell whose
figure side was offline fell through to "rendering…" and sat there forever:
ReportSplitCell tested `cell.image` and `fig` but never `cell.png`, so the baked
still the backend was already shipping went unread. Present mode has always
checked both; only the editor didn't.

AND — split slides now fill the stage like text slides do. The fill tiers were
gated on `visualCells === 0`, so a split kept prose sizing; its text side is its
own column, so scaling it cannot crowd the picture. `splitText` also moves from
alignSelf:center to start — the row is stretched to the stage, so centring
floated the heading at a height that depended on how many bullets followed and
put it out of line with the top of the picture beside it. A slide with a
full-width figure still does NOT qualify: there the text shares the vertical
budget with the visual and bigger type would push it off the stage.

Two existing tests changed contract rather than broke: a figure whose source is
gone, and a scene3d cell whose orientation result is gone, now reopen DETACHED
instead of offline. Both were asserting the fallback this change exists to
replace, and a spinnable 3-D scene beats a picture of one. The offline-PNG path
keeps explicit coverage via a new sibling test that strips data/ from the zip
first, so nothing lost its guard.

16 new backend tests; 702 report/movie tests pass; tsc clean; both present specs
pass against the real app.
…yle conflicts

THE DASHBOARD. Two different backend messages dispatch the renderer's READY
action: `ready` (the stdin loop is up — carries NO dashboard field) and
`dask_ready` (the cluster is up — carries the URL). The reducer wrote
`dashboardUrl: action.dashboardUrl ?? null`, so a `ready` arriving after a
`dask_ready` ERASED the URL that had just been delivered. With it null the File
menu item is `disabled: !state.dashboardUrl` and DaskMonitor renders no button
at all, so there is no way left to open the dashboard.

Ordering is not fixed and not something either side guarantees: `ready` is
emitted in app.py only after _prewarm_io() and prewarm_torch_cuda(), which cost
seconds, while the cluster comes up on a background thread and emits from there.
I did not capture a log proving which order this machine hits — the fix does not
depend on it. A READY that carries no URL carries no information ABOUT the URL,
so it must not overwrite one; that is correct under every interleaving.

THE STYLE WARNING. "Removing a style property during rerender (borderColor) when
a conflicting property is set (border)" — React then drops one of the two and
the element renders with the wrong border. Always the same base+modifier spread:

    seg:     { border: '1px solid #313244' }        // base — SHORTHAND
    segOpen: { borderColor: '#45475a' }             // modifier — LONGHAND
    style={{ ...S.seg, ...(open ? S.segOpen : {}) }}

DaskMonitor's own toggle was one of them, which is why the two turned up
together — but they are independent bugs and the warning was not what broke the
dashboard. There were FIFTEEN across the app (compose drop zones, the console
input, dropdowns, the movie editor, present-mode icon buttons, figure/movie drop
zones, sidebar cards, slide thumbnails, window overlays); each fires on every
toggle. All converted to the full shorthand.

A node:test guard now scans the renderer sources for the pattern, because it is
a property of the style objects and needs no DOM. It earned itself immediately:
it found a FIFTEENTH instance my own grep had missed, ReportMovieCell's
`dropZoneReady`, which uses borderStyle rather than borderColor. It lives in
src/main/ — it only READS the renderer tree, and the renderer tsconfig has no
node types.

Verified with a real cluster: dask_dashboard_link.spec.ts drives the app,
opens the monitor and asserts the button is there. 15 unit tests pass; both
tsconfigs clean.
…ive figure

A picture already in a report could not be swapped. An image cell carried only
its REORDER drag wiring, and a split cell's drop zone existed solely while the
figure side was EMPTY — so a file dragged onto an existing picture failed both
targets' tests, bubbled to the sidebar body, and was APPENDED as a new cell
below the one it was aimed at. Changing a picture meant deleting the cell and
re-adding it, which loses the caption, the display width, and — on a slide's
first cell — the slide break and the speaker notes.

Both sources now land on the same target:

  * an image FILE from the OS  -> report_set_cell_image
  * a figure/window PILL       -> report_add_figure {at_cell}

Both verbs address the EXISTING cell id, so the cell keeps its identity and
everything hanging off it. Two backend gaps had to close for that:

  * report_set_cell_image refused an `image` cell ("no figure slot to fill").
    It now swaps the bytes in place — same cell, same caption, same slide role.
  * report_add_figure's at_cell knew `figure` and `split` targets but not
    `image`, so it fell through to the APPEND branch: the photo stayed put and
    an unrelated figure landed underneath it. An image target now converts IN
    PLACE to a figure cell, which is the only way the slide attributes riding on
    it can survive — there is no "image cell holding a figure" in the model.

The renderer side is one shared hook (useReplaceDrop) rather than two copies,
because the split cell and the image cell want identical behaviour and the
earlier duplication is what let the split's FILLED branch get missed. It
stopPropagations so a drop never also reaches the sidebar body — that
double-handling is the append bug itself.

VERIFIED IN THE APP, not just headlessly: report_replace_image.spec.ts
synthesizes a real DataTransfer carrying a real File and dispatches
dragenter/dragover/drop at the element, so the handlers under test are the ones
a user hits. It asserts the SAME cell shows a DIFFERENT image, that no second
cell appeared, and that the caption survived. Screenshots looked at.

Scope, stated plainly: the FILE path is driven end-to-end in the app for both an
image cell and a split's filled photo side. The PILL path is covered by backend
tests (report_add_figure at_cell on an image cell, twice) and shares the same
renderer handler, but is not driven with a synthesized pill drag. A filled
FIGURE cell still has no file-drop target either — its centre zone replaces with
another figure, and a dropped file there still falls through to the body. Both
are the same shape as what this fixes and are worth doing next.

4 new backend tests; 706 report/movie tests pass; 15 unit tests; tsc clean.
…it slide

Turning a text slide into a text-beside-picture slide meant rebuilding it: add a
split block, retype or paste the prose, delete the original. That loses whatever
was riding on the cell — and on a slide's FIRST cell that is the slide break, the
kind/style and the speaker notes, so the "rebuild" also quietly merged the slide
into the one above it.

Dropping a figure/window pill or an image file straight onto the text now
converts the cell IN PLACE: the prose it already had becomes the text side and
the picture fills the other. Same cell id, so the slide stays ONE slide and keeps
its notes — there is a test for exactly that, because it is the part a rebuild
gets wrong.

Both verbs learned the markdown target, mirroring each other:
  * report_set_cell_image  — a dropped FILE
  * report_add_figure {at_cell} — a dropped WINDOW (a live figure side)
Each takes an optional `layout` (default text-left), so the caller can choose
which side the picture lands on without a follow-up round trip.

The renderer reuses useReplaceDrop — the same hook the image cell and the split's
photo side use, since the verbs already address the cell by id and the backend
decides what the conversion means. The markdown cell is the one place where two
kinds of drag land on the SAME element and must not fight: a pill/file converts
the cell, a cell-REORDER drag moves it. `replace` claims only the former (it
preventDefaults when it does), so anything it declines falls through to the
reorder wiring untouched.

The old "a slot-less cell must report why" test now uses a movie cell, since
markdown is a legitimate target — the guard it was written for is still there,
just not aimed at markdown any more.

Verified in the app: report_replace_image.spec.ts drops a real File on a real
text cell and asserts the markdown cell BECAME a split (by testid), that the
figure side draws, that the prose came with it, and that no cell was added.
Screenshot looked at — prose left, picture right, still Slide 1 with its notes
chip. 5 new backend tests; 711 report/movie tests pass; tsc clean.

Still open, same shape as before: a filled FIGURE cell has no file-drop target
(its centre zone replaces with another figure, but a dropped file falls through
to the body), and the PILL path is covered by backend tests rather than driven
with a synthesized pill drag.
The last of the drop gaps. A filled figure cell's centre zone replaced it with
another FIGURE, but a dropped FILE fell through to the sidebar body and was
appended as a new image cell below.

The verb already handled it — report_set_cell_image accepts a `figure` target
and converts it to a photo cell, tearing down the live window — so this was
almost entirely a renderer problem, and a specific one: a report figure is an
OUT-OF-PROCESS IFRAME that swallows drag events over itself. The cell only ever
sees them through a transparent shield, and that shield was gated on
`dragKind`, which is set at dragstart of an IN-APP pill. An OS file drag never
sets it, so nothing covered the iframe, the iframe ate the dragover, and no drop
event reached React at all.

`useFileDragActive` supplies the missing signal. It watches window-level
`dragover` and refreshes a short timer rather than waiting for `dragend`,
because for a drag originating OUTSIDE the page dragend fires on the source —
which is not in this document — and never arrives. The timer lapsing is the only
reliable "the drag has gone".

The docstring claimed report_set_cell_image took a figure PLACEHOLDER; it takes
either, and now says so.

VERIFICATION, honestly: the backend half is pinned by a new test — the figure
cell converts to a photo cell, its spec is cleared, and its live window and
snapshots are torn down rather than orphaned. The RENDERER half is NOT verified
end-to-end. The spec I wrote for it resolved a window id of 0 from the first
breadcrumb (the NAVIGATOR window, not the signal one) and report_add_figure then
produced no figure cell with no backend error — so it was measuring nothing, and
a test that passes for the wrong reason is worse than an absent one. It is
removed, with a comment in the spec saying exactly what is uncovered and why.
Getting a real painted signal window into that fixture is the remaining work.

The other three cases in that spec were made self-sufficient about the sidebar
being open — run alone, they depended on the first test having clicked it.

1 new backend test; 712 report/movie tests pass; 3 e2e cases pass; tsc clean.
DIAGNOSTICS ONLY — no behaviour change, nothing recovers yet.

Symptom: close and reopen a Mac laptop and every plot window is gone from the
workspace, but the app still works and you just re-load the data.

Two facts narrow that a long way, and both argue against the obvious suspects.
The Python backend is NEVER respawned — runner.ts sets `proc = null` on close and
every later sendAction no-ops — so if it had died, re-loading data afterwards
would be impossible. It isn't, so the backend is ALIVE. And the workspace's
window list lives ONLY in the renderer's React state (SpyDEContext `windows:
Map`); nothing persists it or rebuilds it from the backend. So the renderer lost
its state while Python kept running.

There are only two ways that happens, and these listeners tell them apart:
  * the renderer PROCESS was recreated — render-process-gone, or a navigation the
    app never asked for;
  * or it survived and something reset the state, in which case none of these
    fire and the answer is in the renderer.

child-process-gone is here for the Mac case specifically: the GPU / utility
processes are the usual casualty across a lid-close, and a dead GPU process takes
the WebGPU device with it — Chromium may then recreate the renderer to recover,
which is exactly how in-memory React state vanishes while Python lives on.

powerMonitor is logged purely to timestamp the sleep boundary so the log reads as
a story. Nothing acts on it. There is currently NO powerMonitor and NO
render-process-gone handling anywhere in the app, which is why this has been
invisible.

To reproduce: run from source, close the lid, reopen, and send the main-process
console output. The [spyde lifecycle ...] lines around power:resume are the
answer.
The workspace vanishing across a laptop sleep is not a crash: the Python backend
survives (runner.ts never respawns it, and sendAction would no-op forever after a
death — but re-loading data works), and the window list lives ONLY in the
renderer's in-memory React state with nothing able to rebuild it.

The plan is a consistency fix rather than new architecture. The Report Builder
already does exactly the right thing — report_state is a full-document snapshot
the renderer mirrors, 41 emit_state() call sites — and the window layer never got
it: it accumulates from incremental messages with no snapshot and no way for the
renderer to ask for one.

Phased so the guesswork is isolated: a probe answers whether the figure HTML
files and the awi_state replay stash survive a renderer loss, which is what
decides whether Phase 2 is cheap or expensive. I deliberately did not estimate
past that probe.

Three decisions listed for review — geometry restore vs fresh layout, how far
'authoritative' should go, and automatic vs explicit trigger — plus what is out
of scope (disk persistence, Dask reconnect, anything in the nav read path).
I asserted the cluster survived 'which is why re-loading data works'. That was
wrong, and neither symptom supports it.

Re-loading data proves nothing about the cluster: _await_dask gates on
_dask_ready, a threading.Event LATCH set once at first startup and cleared in
exactly one place (compute_config.py, on an explicit user-driven restart).
Nothing clears it when a cluster dies, so a load sails through the gate and runs
against a dead client indistinguishably from a live one.

The dashboard disappearing proves nothing either: dashboardUrl lives in the SAME
SpyDEContext React state as the window list, so whatever loses the workspace
loses the link with it.

That leaves two independent unknowns rather than one, and surfaces a latent bug
worth more than the original: NOTHING in SpyDE detects cluster death — no
liveness check, no re-arm of the gate — so after any cluster loss the app keeps
accepting work against a dead client instead of waiting, restarting or saying so.
A vanished window is obvious; a silently dead compute backend is not.

Added discriminators that don't route through the state we know gets lost (look
for the worker processes; probe from the backend; run a compute rather than a
load), and kept the cluster workstream explicitly OUT of the resync scope rather
than quietly merging them.
Requested follow-on to the section 8 correction. Kept SEPARATE from the resync
work — different trigger, different code, different failure mode — and shippable
on its own.

The useful finding: the gap is DETECTION, not recovery. compute_config.py's
restart path already does exactly the right three things (clear _dask_ready so
loads wait rather than racing a dead client, dask_manager.restart(), let
_on_dask_ready re-open the gate). It is proven code with one trigger: a user
changing compute settings. Nothing calls it when a cluster dies on its own. So
the work is notice-then-reuse, not build.

Detection options compared; DaskStatsSampler flagged as the one worth checking
first, since it is already talking to the cluster on a cadence for the StatusBar
HUD and its failures may BE the signal for free.

Phased so the smallest useful diff lands early: re-arming the gate alone converts
'silently computes against a dead client' into 'waits, then fails with a real
message'.

Traps recorded because they are what makes this risky rather than easy: a false
positive restarts a healthy cluster and cancels in-flight work, a lost WORKER is
not a lost CLUSTER (distributed replaces workers routinely), probes must stay off
the asyncio main thread, auto-resubmit is refused outright, and threaded /
SPYDE_NO_DASK mode has no cluster to be dead.
Two probes replace the plan's guesswork with numbers.

resume_probe.spec.ts drives the real app. A lid-close can't be driven from CI, so
it uses the faithful proxy — reload the renderer with data loaded, which destroys
the in-memory React state exactly as a renderer-process death would while leaving
Python and its cluster untouched.

  BEFORE  windows=2 iframes=2 dashboardLink=true  figureFiles=2
  AFTER   windows=0 iframes=0 dashboardLink=false figureFilesStillOnDisk=2/2
  BACKEND exited=false logGrew=true
  RELOAD  worked=true windows=2

The plan's premise is confirmed rather than assumed: windows and the dashboard
link die together (same React state, which is why the link proves nothing about
the cluster), Python survives, and re-loading data rebuilds everything.

THE finding is the last column. The figure HTML files OUTLIVE the renderer, 2/2 —
they are served over the spyde-fig:// protocol from the OS tmpdir and the MAIN
process writes them. That answers the plan's open question 3.3-Q1 and puts Phase
2 on its cheap branch: a resync re-sends the same file_urls and the iframes
remount from files already on disk. No figure re-render.

test_dask_ready_latch.py characterises the load gate: _await_dask() is a pure
Event read that consults no cluster; with the client torn out from under the
session it STILL opens instantly, so a load runs against a dead client and cannot
tell; and structurally there is exactly one _dask_ready.clear() (the user-driven
restart) and no liveness path of any shape. Written so it FAILS when a detector
is added — that is the point, not an accident.

Assertions are deliberately narrow: only the backend surviving and the app
staying usable are asserted. Window survival is RECORDED and loudly logged if it
ever happens, because that would mean the plan is wrong, and a probe should be
able to say so.
Observed directly on the failing machine, which upgrades the plan's load-bearing
assumption from an inference to a measurement.

Section 1 had reasoned it out from the code — runner.ts never respawns the
backend, so a post-sleep load succeeding meant it could not have died. That is
now corroborated on the real platform and the real failure, which also validates
the Phase 1 proxy: reloading the renderer stands in soundly for this bug, since
both agree on what lives and what dies.

Recorded the distinction that survives this, because it is easy to lose: a
RUNNING PYTHON PROCESS IS NOT A HEALTHY CLUSTER. The Dask workers are separate
child processes and the scheduler its own endpoint, so 'the backend is alive'
says nothing about them. Section 8's two unknowns collapse to one, not zero — and
section 10.3 is precisely why the app cannot answer the survivor for us, since
the latch makes a load succeed either way. The section 8.2 discriminators (look
for the worker processes, probe from the backend rather than the UI, run a
compute rather than a load) remain the only way to tell.

Also fixed a duplicated 10.5 heading from the previous append.
…thesis is in doubt

Real Mac lid-close: worker count unchanged, a genuine distributed compute worked,
and neither render-process-gone nor child-process-gone fired.

The cluster question is settled by the right test — a compute that round-trips
through the scheduler rather than a load the section 10.3 latch waves through
regardless. Section 9 drops in priority but does not go away: nothing detects
cluster death from any cause, which is still a real latent bug, just not this one.

The awkward part is the third result. Section 1 concluded the renderer lost its
state and the natural mechanism was the process being recreated — and neither
process-gone event fired, which argues against precisely that. If the renderer
really did survive, something inside the app CLEARED the workspace, which is a
different and probably much cheaper bug than this document scopes.

So I have NOT marked this good to go. One control question decides it: did
power:suspend / power:resume print at all? If they did, the finding is real and
there is a root cause still to find. If nothing printed, the diagnostics were not
live and the test is inconclusive — absence of evidence rather than evidence of
absence, and those are not interchangeable.

The resync work stands either way as the robustness fix (section 10.1 measured
the workspace dying under a plain renderer reload). But if the renderer survives
a sleep, resync would paper over a state-clearing bug that would still be there
afterwards.
…chines

NOT generator output — these are hand-edits made in the app, committed only so
they survive a move to another computer. build_spyde_overview.py is therefore
STALE relative to spyde-overview.spyde-report; regenerating would overwrite this
work, which has already happened once. Decide which is the source of truth before
running it again.

  spyde-overview.spyde-report   13 slides, "SpyDE — an overview2"  12.4 MB
  spyde-overview2.spyde-report  14 slides, "SpyDE — an overview2"  107 KB
  SpyDE_an_overview2.html       an interactive HTML export         749 KB

The size difference is not noise and is worth knowing: the 12.4 MB file was saved
AFTER the detached-figures change (38bf08f) and carries data/<id>.npz — the
figure pixels that make a reopened figure interactive instead of a flat PNG. The
107 KB file predates it. So the real-world cost of that feature on this deck is
roughly 117x, well under the 32 MB per-cell cap but far more than I guessed when
I set it.

Two things follow. A deck meant for emailing may want the pixels dropped, and
there is currently NO way to ask for that — the choice is all-or-nothing at save
time. Worth a "save without figure data" option, or a smaller default cap.
power:resume printed, so the diagnostics were live and the absence of
render-process-gone / child-process-gone is a real negative. The renderer process
survived the sleep.

That matters more than it sounds. SpyDEProvider's state is a useReducer
initialised once and nothing in the reducer clears  wholesale, so with
the process alive there are only two mechanisms left — and they are very
different jobs:

  A. the page reloaded (would show as renderer-navigation /
     renderer-did-finish-load near power:resume) — resync is the right fix;
  B. the windows were never lost at all. MDIArea positions subwindows at
     ABSOLUTE pixel coordinates derived from areaRef.clientWidth/clientHeight and
     caches them in placedRef. A Mac lid-close routinely changes the display
     configuration; if the area measures 0 or smaller during resume, cached
     placements land outside the visible region. The windows would still be in
     state, still streaming, just not where you can see them.

If B, this is a layout bug and the fix is re-clamping placements on an area-size
change — dramatically smaller than the resync, which would have papered over it.

The discriminator costs two seconds and needs no tooling: MDIArea keeps hidden
windows LISTED IN THE TOP BAR. If the bar still lists them, state is intact and
it is B. If the bar is empty too, it is A.

Phase 2 goes on hold. Not because resync is wrong — section 10.1 measured the
workspace dying under a plain renderer reload, so it stays a real gap — but
because building it now risks fixing the symptom while leaving the cause.
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