Stork: drop inline IIFE for an ES module under _emanote-static/js/#672
Stork: drop inline IIFE for an ES module under _emanote-static/js/#672
Conversation
The Stork controller was the last interactive behavior still living as an inline IIFE in a Heist template — it was named in #663's PR description as 'kept inline because its onclick="window.emanote.stork.toggleSearch()" call sites span four templates'. This is that follow-up: stork.js takes the same shape as theme-toggle, code-copy, toc-spy, and footnote-popup, and the four onclick attrs flip to a data-emanote-stork-toggle attribute consumed via event delegation. Notable bits: - Vendor stork.js (the WASM loader that defines window.stork) stays loaded as a non-module <script src> from the head — blocking, so it parses before our module evaluates. - WASM init runs once per session (top-level module evaluation, on window.load if document isn't complete yet). Same memory-leak trade-off documented in #411 — index re-registration on each morph would leak, so onMorph(markIndexAsStale) defers the refresh until the user actually opens search. - The dark-mode mirror (.dark on <html> → .stork-wrapper-edible-dark on #stork-wrapper) was a separate inline <script> in stork-search.tpl; it folded cleanly into stork.js as a single MutationObserver on documentElement that re-resolves the wrapper on each fire (so it survives morph without re-attaching). - Base URL comes from document.baseURI (the <base href=…> in head) instead of a marker <script id="emanote-stork" data-…> attribute — same value, no need for a marker element. E2e: three new scenarios cover Ctrl+K open, Esc close, and click- to-open via the search-icon button. The 'I press' step uses Playwright's keyboard-combo syntax for both the modifier shortcut and the bare key.
The searchShown module flag duplicated the body's .stork-overflow-hidden-important class — the open-state truth was already in the DOM. The keydown handler read the flag to decide whether Esc should close the modal; if anything outside this module ever mutated the body class (a future utility, devtools, an adjacent script), the flag would drift and Esc would silently no-op while the modal looked open. Replace with isSearchShown() that queries the body class. One extra classList.contains call per Esc keypress; negligible.
Without the guard, a future template tweak that reorders or removes the vendor <script src=".../stork/stork.js"> in stork-search-head.tpl would surface as a cryptic 'stork is not defined' on the user's first search click, far from the load-order cause. Throw at module evaluation with a pointer to the failing template instead.
The comment named the still-inline script as the 'Stork controller', but Stork's controller is now an ES module under _emanote-static/js/stork.js — the inline script is just the vendor WASM loader (window.stork). Reword to name what's actually inline and why each piece is.
The original 'a behavior must choose ONE strategy' phrasing was true when only toc-spy used onMorph (exactly one strategy). Stork now combines event delegation (clicks), onMorph (index staleness), and a documentElement MutationObserver (theme mirror) in one module — the prior wording would mislead a future behavior author into thinking combined use is prohibited. Reword to allow it explicitly with the stork example as the concrete shape.
The bare-fallback shape (try { … } catch { return '/' }) hid the case
where document.baseURI was unparseable. Reaching that path means the
index + WASM fetches go to '/' which is almost certainly wrong, with
no console signal. Add a warn that names the cause.
The 'stork-overflow-hidden-important' literal sat at three sites (toggleSearch, clearSearch, isSearchShown) — past the rule-of-three threshold and crossing the JS↔CSS boundary (the class is defined in stork-search-head.tpl's <style> block). Lift to a module constant.
…ns bag
The options bag had only one consumer of one field — refreshIndex
passed { forceOverwrite: true }, the two startup callers passed
nothing. An explicit boolean parameter is clearer at both call sites
(registerIndex() and registerIndex(true)) and removes the parameter-
sprawl shape that invites future ad-hoc fields.
Two e2e mismatches:
- The 'Stork modal is hidden' / 'is visible' assertions used unquoted
state words but the step pattern is {string}. Cucumber routed them
as undefined. Quote the gherkin literals so they match.
- The 'click Stork search trigger' step picked the first matching
button via .first(), which at the 1280x720 test viewport is the
breadcrumbs button (md:hidden, mobile-only). Filter to :visible so
the sidebar button (the actual md+ trigger) is selected.
E2e now 21/21 in live, 20+1-skipped in static.
Hickey/Lowy Analysis
Hickey rationale(1) (2) Lowy rationale(3) (4) The No-op findings (kept verbatim from sub-agent review)
|
|
| Step | Status | Duration | Verification |
|---|---|---|---|
| sync | ✓ | 0s | forge=github; lost-crash worktree |
| research | ✓ | 1m 24s | 4 onclick call sites + inline theme-mirror + vendor <script src> (stays) |
| branch | ✓ | 6s | stork-es-module from origin/master |
| implement | ✓ | 3m 17s | stork.js (~125 LOC) + 4 attr swaps + theme-mirror folded in + 3 e2e scenarios |
| check | ✓ | 40s | cabal build all |
| docs | ✓ | 0s | CHANGELOG sub-bullet under existing #643 entry |
| fmt | ✓ | 16s | pre-commit clean |
| commit | ✓ | 33s | primary feature commit |
| hickey+lowy | ✓ | 6m 0s | 4 findings, 4 fixed; 9 No-ops |
| police | ✓ | 4m 0s | rules clean; fact-check 1 fix; elegance 2 fixes |
| test | ✓ | 5m 11s | e2e live 21/21; static 20+1-skipped |
| create-pr | ✓ | 1m 21s | draft PR + hickey/lowy comment |
| ci | ✓ | 1m 23s | vira ci passed on 7fac1359 |
| Total | 24m 29s |
Slowest step: hickey+lowy (6m 0s) — two parallel sub-agent reviews dominated, similar shape to #663's resumed run.
Optimization suggestions
testran 5m 11s — both modes had to rebuild after the dirty-tree warning interrupted Nix's cached output. A subsequent run of juste2e-static+e2e-liveafter the first build cache was warm took ~7s combined; the cold first-run cost is the real story.hickey+lowyandpolicereviewed a much smaller diff than Move interactive JS to ES modules with live-server cache busting #663 (10 files / +213 −108 vs ~900 LOC) but ran for 10m combined. The cost is the agent latency itself, not anything about the diff size — running them in parallel inside a single response (which I did) is the only knob.- No CI retries — five rounds of fixes (4 hickey/lowy + 3 police) all landed clean and
vira cipassed first try.
Workflow completed at 2026-04-26T17:20Z. PR is ready for review: ships the last interactive behavior into the ES-module pattern established by #663, closes the inline-IIFE inventory named in #643.
Reproduce a post-morph regression in stork.js: the dialog renders
unstyled after a route switch because the new #stork-wrapper element
gets no stork-wrapper-edible{,-dark} class. The MutationObserver
on <html>.class only fires when the theme is toggled; a plain
navigation produces no observer fire, so the fresh wrapper stays
un-classed and edible.css rules don't apply.
This commit adds the @morph-tagged scenario asserting the wrapper
carries one of the edible classes after navigate-then-search; it
fails on the current code. Fix lands in the next commit.
…fter route switch)
The MutationObserver on <html>.class only fires when the theme
toggles. A plain morph navigation doesn't change <html>.class, so
the new #stork-wrapper (a fresh element from the morphed-in body)
never gets stork-wrapper-edible{,-dark} applied — and edible.css /
edible-dark.css have no class to attach to. The user sees an
unstyled <input> + <output> on every search opened after the first
route switch.
Fold applyStorkTheme() into the existing onMorph(...) callback that
already marks the index stale. Same hook, same morph event — both
concerns are 'something post-morph the new DOM needs from us'. The
test added in da40d32 flips green.
Issue #673 tracks the broader auto-morph e2e infrastructure that
would surface this entire regression class automatically. This
targeted fix is in scope for #672; the auto-morph mode lands separate.
#675) **Morph-survival of every behavior is now a structural property of the e2e suite, not an opt-in `@morph` tag.** Before this PR, exactly one path was tested via Ema's in-app morph nav (the toc-spy scenario from #663); every other scenario reached its target via `page.goto`, so the morph code path was uncovered for everything else. That gap is how both the toc-spy bug (#667) and the Stork theme-mirror-after-morph regression ([#672 review](#672)) shipped — each was invisible to the existing fresh-load tests. `EMANOTE_MODE=morph` reuses the live backend but rewrites every `When I open` step into an Ema-internal route switch. A `Before` hook primes each scenario with `page.goto("/")` + `await window.ema.ready`, then subsequent `I open` calls go through `window.ema.switchRoute(...)` and wait for `EMAHotReload`. *Every behavior assertion in the suite now implicitly runs against "the page that landed via morph", not via fresh load.* `just e2e-morph` and a third `E2E (morph)` CI step join the existing `live` and `static` runs. The mode-axis volatility is encapsulated in two new tiny modules: `tests/support/mode.ts` for env validation and the `Mode` union, `tests/support/navigation.ts` for `openRoute` / `morphNav` / `primeMorph`. *Step definitions stay completely mode-agnostic — they call `openRoute(page, url)` and the dispatch happens behind the boundary.* `hooks.ts` is now just Cucumber lifecycle plumbing. > **Note for reviewers**: a subtle bug surfaced once two morph navigations ran back-to-back in a single scenario (which never happened before — `live` only morphed once per `@morph` scenario). Splitting `await ema.ready` and the `addEventListener("EMAHotReload") + switchRoute(...)` into separate `page.evaluate` round-trips opens a Node-side gap where the script-reload that morph triggers can destroy the execution context before the listener is registered. The fix in `44ac526e` keeps the entire ready→listener→switchRoute sequence in one async evaluate so it runs back-to-back in browser context. Closes #673. _Generated by [`/do`](https://github.com/srid/agency) on Claude Code (model `claude-opus-4-7`)._
The Stork ES-module PR (#672) accumulated seven follow-up commits across /code-police and /hickey+/lowy. Three patterns recur often enough to warrant a project rule: - one-field options bags (replace with a positional param) - mirror flags shadowing DOM state (query the DOM) - vendor globals from separate <script src> (guard at module load) Plus two calibration examples for built-in rules: silent URL/parse fallbacks must log, and string literals across hot-path methods hit the rule-of-three at exactly three sites.
The Stork search controller was the last interactive behavior still living as an inline
<script>IIFE in a Heist template. This is the follow-up named in #663's body — Stork was deliberately deferred there because itsonclick="window.emanote.stork.toggleSearch()"call sites span four templates and the migration warranted a focused change. Now it takes the same shape as the other behaviors landed in #663: a module under_emanote-static/js/, picked up by the importmap, nowindow.emanote.storkglobal, no marker<script id="emanote-stork">element.Templates expose the search trigger via
data-emanote-stork-toggle; a single delegated click listener ondocumentcovers the four sites (sidebar + breadcrumbs + minimal-layout header + modal backdrop) — the backdrop's role is "close" rather than "open", which works because the backdrop is only visible while the modal is open. The previously-separate inline theme-mirror script instork-search.tpl(aMutationObserverre-skinning the search dialog when the user flips the dark-mode toggle) folded into the module too, sincedocument.documentElementis never replaced by morph and the wrapper element re-resolves on each fire. Base URL comes fromdocument.baseURIinstead of a custom data attribute — same value, no marker element to maintain.A morph-time regression surfaced during review and is fixed in-PR: the dialog rendered unstyled after a route switch because the new
#stork-wrapperelement gets nostork-wrapper-edible{,-dark}class — theMutationObserveronly fires when<html>.classactually changes, and a plain morph nav doesn't toggle the theme. The fix foldsapplyStorkTheme()into the existingonMorphcallback that already marks the index stale, so both post-morph concerns hang off the same hook. A new@morph-tagged scenario navigates-then-searches and asserts the wrapper carries one of the edible classes; it failed on the original code, passes now. Issue #673 tracks the broader auto-morph e2e mode that would surface this entire class of regression automatically across every behavior.E2e: four scenarios cover Ctrl+K opens, Esc closes, the sidebar search button opens, and the morph-then-search styling assertion. The
Then("the Stork search modal is {string}", …)assertion checks the body'sstork-overflow-hidden-importantclass (the actual open-state truth, not computed visibility — the container isposition:fixedwith a backdrop, sogetComputedStylereadsdisplay: blockeither way; only the class flips).Eleven commits: the primary migration plus four hickey/lowy fixes (
searchShownmirror flag → DOM-derived getter; vendorwindow.storkguard; comment correctness inbase.tpl; morph-survival docstring allows combining strategies), three police fixes (silent URL-parse warn ingetBaseUrl,MODAL_HIDDEN_CLASSconstant,registerIndex(forceOverwrite)over options bag), an e2e step fixup, the morph-regression test + fix, and a merge from master. With this in, the inline-IIFE inventory named in #643 is fully closed.Try it locally
Generated by
/doon Claude Code (modelclaude-opus-4-7).