Skip to content

Move interactive JS to ES modules with live-server cache busting#663

Merged
srid merged 27 commits intomasterfrom
js-module-boundary
Apr 26, 2026
Merged

Move interactive JS to ES modules with live-server cache busting#663
srid merged 27 commits intomasterfrom
js-module-boundary

Conversation

@srid
Copy link
Copy Markdown
Owner

@srid srid commented Apr 25, 2026

Site-authored interactive behaviors no longer live as inline <script> IIFEs scattered across Heist templates. Four small ES modules under _emanote-static/js/ (theme-toggle, code-copy, toc-spy, footnote-popup) replace the per-template IIFEs, plus a morph.js helper providing ready() and onElement(selector, fn) (MutationObserver-backed). Closes #643. Side effect: code-copy buttons now wire onto <pre> blocks added by live-server DOM patches, not only those present at first paint.

Live-mode cache busting works per file via an inline <script type="importmap"> mapping bare specifiers (@emanote/morph etc.) to URLs through siteRouteUrl, which appends ?t=<mtime>. Plain ES module imports (import './morph.js') would have stripped the query during URL resolution and stale-cached transitive deps; the bare-specifier indirection is what makes per-file invalidation propagate. The importmap itself is tagged im-preserve + data-ema-skip so it survives Ema's morph navigation — without that, the second importmap in the morphed <head> hit the HTML spec's already-resolved specifier rule and emitted a console warning per entry. The toc-spy module captures DOM refs in a closure, so it now also subscribes to Ema's EMAHotReload event to tear down + re-init on each morph (closes #667).

Static mode also gained a fix: the production Tailwind compile now adds _emanote-static/js as an @source, so utility classes applied by JS at runtime (POPOVER_CLASS, BODY_CLASS in footnote-popup.js) are visible to the scanner and don't drop out of tailwind.css. Pre-fix, the popup rendered as plain text inside a thin border on emanote gen output. Also strips Tailwind's //# sourceMappingURL=/sm/<hash>.map trailer to silence the per-pageload 404 in dev logs.

Depends on srid/ema#181 (merged) — window.ema.ready Promise so the morph-nav e2e step can await the WS handshake without racing.

Out of scope, kept inline by design: FOUC theme applier in base.tpl (pre-paint, can't defer), Stork controller (onclick="window.emanote.stork.toggleSearch()" call sites span four templates — separate refactor), breadcrumbs toggleHidden (5 lines; section 1 of #632 deletes via native <details>).

Known limitation, documented: editing a bare-imported JS module mid-session needs a hard reload to pick up the change via morph nav (the preserved importmap holds the original URLs). main.js itself still cache-busts via morph since it's loaded directly. Issue #666 tracks the broader layer-URL cache-bust gap (CSS, fonts, Stork's own assets).

E2e: 16 scenarios, live=16/16, static=15+1-skipped (@morph tag gates morph-nav for live only). New scenarios cover theme toggle, code-copy initial wiring, TOC scroll-spy, popup body styling (catches the Tailwind class-drop regression class), and morph-nav of toc-spy. Module split into Emanote.View.JsBundle mirrors the existing Emanote.View.Tailwind shape.

Try it locally

nix run github:srid/emanote/js-module-boundary -- -L $YOUR_NOTEBOOK run

srid added 6 commits April 25, 2026 19:06
…s/ (#643)

Site-authored interactive behaviors (theme toggle, code-copy buttons, TOC
scroll-spy, footnote popup) move out of per-template <script> IIFEs into
ES modules loaded once from base.tpl as <script type="module">. A small
morph.js helper provides ready() and onElement() so the live-server
DOM-patch resilience pattern (idempotent attach via dataset sentinel +
MutationObserver) stops being copy-pasted into each new behavior.

Side effect: code-copy buttons now also wire onto <pre> blocks added
by Ema's idiomorph patches, not only those present at first paint.

Out of scope (kept as inline scripts, intentionally):
- FOUC theme applier in base.tpl (must run pre-paint, can't be deferred)
- Stork search controller (breaking the window.emanote.stork onclick=
  coupling needs its own PR — call sites span breadcrumbs.tpl,
  sidebar.tpl, layouts/default.tpl, stork-search.tpl)
- breadcrumbs toggleHidden (5 lines; section 1 of #632 will replace with
  native <details>)
The bare catch-and-discard let theme-toggle clicks succeed visually
while silently failing to persist. On reload, the FOUC script reverts
to OS preference, leaving the user with no signal as to why.
Replace with a console.warn so the failure mode is at least diagnosable.
Two clicks within 2s on the same copy button raced their reset timers,
making error feedback ("Copy failed") disappear under the next click's
fresh copy icon before the user could read it. Track the timer per
button and clear it before starting a new flash.
findTarget calls CSS.escape on a user-supplied data-footnote-ref. The
early API guard checked Popover but not CSS.escape, so a browser with
one and not the other (theoretical — both ship together in practice)
would fail with an uncaught TypeError on first click instead of staying
inert as advertised. Add the missing predicate to the guard.
The FOUC script in base.tpl and STORAGE_KEY in theme-toggle.js share
the literal 'emanote-theme'. Structural deduplication needs a Heist
splice on a JS literal — out of scope here. Cheapest hardening: add
the reciprocal pointer in base.tpl so a future edit on either side
finds the other. (Comment-enforced, but at least bidirectional now.)
Replace the button._emanoteFlashTimer expando with a module-level
WeakMap. Same per-button cancel semantics, no name-collision risk
with other code that might touch the button node.
@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 25, 2026

Hickey/Lowy Analysis

# Lens Finding Disposition
1 Hickey Storage key duplicated between base.tpl FOUC and theme-toggle.js Fixed in this PR
2 Hickey Silent localStorage.setItem failure Fixed in this PR
3 Hickey CSS.escape not gated by the popover feature-detect Fixed in this PR
4 Hickey flash() timer race on rapid re-clicks of the copy button Fixed in this PR
5 Lowy window.emanote namespace ownership is implicit Deferred
6 Lowy Storage-key shared volatility (FOUC inline + theme-toggle module) Deferred

Hickey rationale

Four findings, all addressable in this PR. The storage-key duplication is structurally unavoidable without a Heist splice on a JS literal (the FOUC script must run synchronously before any module loader executes), so the fix is the cheapest hardening that helps: a bidirectional pointer comment so a future edit on either side finds the other. The other three are real correctness bugs — bare try { localStorage.setItem(…) } catch (e) {} masking persistence failures (now console.warn), a setTimeout on the copy-button flash that gets overwritten by the next click within the 2 s window (now per-button cancel via WeakMap), and a feature-detect guard that checked for Popover but not CSS.escape even though findTarget calls the latter (now both predicates).

Lowy rationale

Two findings, both deferred. The implementation matches the design sketch on every structural axis: morph.js correctly encapsulates the Ema-framework dependency axis (live-reload resilience), theme-toggle and the FOUC inline correctly carve along the paint-blocking-vs-interactive axis, and Stork is cleanly deferred (no half-migration markers). The two open items are framing concerns that don't require a fix here. The window.emanote namespace contract is currently maintained by defensive || {} guards in both stork and theme-toggle; documenting or consolidating it makes sense once #632 section 4 adds more entries (command palette, kbd nav, recently-viewed) — premature now. The storage-key duplication concurs with Hickey's finding #1 — both reviewers agreed structural deduplication needs out-of-scope work; the bidirectional comment is the in-scope mitigation.

@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 25, 2026

/do results

Step Status Duration Verification
sync 1s git fetch ok; forge=github
research 1m 50s research map produced; plan articulated with file:line citations
branch 6s on branch js-module-boundary tracking origin/master
implement 3m 31s 6 JS modules created; 4 templates pruned; cabal data-files updated
check 44s cabal build all succeeded
docs 46s CHANGELOG.md updated under UI revamp
fmt 12s pre-commit clean (cabal-fmt, fourmolu, hlint, nixpkgs-fmt)
commit 22s primary feature commit pushed
hickey+lowy 5m 48s hickey: 4 findings, 4 fixed; lowy: 2 findings, both deferred with rationale
police 3m 58s rules clean; fact-check clean; elegance: 1 fix (WeakMap for flash timers)
test 1m 23s e2e-static: 12/12 scenarios pass (incl. 3 footnote-popup click scenarios)
create-pr 1m 5s draft PR #663 + hickey/lowy analysis comment
ci 1m 30s vira ci succeeded; 5 packages × 2 platforms; gh-signoff posted
Total 21m 38s

Slowest step: hickey+lowy (5m 48s)

Optimization suggestions

  • hickey+lowy dominated (27% of wall-clock). The two parallel sub-agent reviews ran on sonnet against a 440-line diff — opus would have been slower. The four small follow-up commits (one per finding) compounded the time; rolling them into a single "address review" commit would save ~30 s but lose the per-finding history.
  • police was second slowest (18%). Three parallel review agents in the elegance pass produced one actionable finding out of ten total suggestions; many were over-abstraction (try/catch helper, class-name constants for 2-site references). For PRs of this size, a quicker manual elegance pass might match the hit rate.
  • implement (3m 31s) is the irreducible cost — six new files plus four template edits. Splitting this PR (e.g., morph + code-copy first, then footnote-popup + toc-spy) would reduce per-PR review overhead but add per-PR fixed costs.
  • For re-runs after CI failures, use --from ci-only to skip everything before the CI step.

Workflow completed at 2026-04-25T23:19Z.

srid added 6 commits April 25, 2026 19:22
Three behaviors migrated in this PR (theme-toggle.js, code-copy.js,
toc-spy.js) had zero e2e coverage. Add scenarios for each plus the
fixtures they need (code.md with two fenced blocks; toc.md with three
sections separated by tall spacers so the scroll-spy active band
contains exactly the targeted heading).

The code-copy scenario asserts button-count parity (one per <pre><code>),
which catches both missing-button regressions and idempotency-guard
failures producing duplicates.
…hange

IntersectionObserver only fires when intersection STATE changes. With
short headings separated by tall content (the common case for a TOC),
the headings can all stay in the same out-of-band state through a long
scroll — no callback, no pickActive, the initial observed[0] fallback
freezes as the active link forever.

pickActive's lastAbove branch already reads getBoundingClientRect, so
the data it needs is fresh on every scroll. Add a passive, rAF-coalesced
scroll listener that just calls pickActive. The IO stays for the cheap
"is in band" path that updates the visibility map.

Reproduced by the 'Scrolling a section to the viewport top highlights
its TOC link' e2e scenario added in the previous commit; previously
failing with apple staying active after scrolling cherry to the top,
now passing.
Live mode was serving stale JS. siteRouteUrl appends ?t=<mtime> to
static-file URLs (Common.hs:200), which is how tailwind.cdn.js
invalidates per-edit. The previous <script type="module"
src="${ema:emanoteStaticLayerUrl}/js/main.js"></script> bypassed
that path: ema:emanoteStaticLayerUrl returns the layer directory
URL, then we appended /js/main.js as a literal string — never going
through siteRouteUrl, so no ?t=. Worse, even if main.js had the
suffix, ES module imports of './morph.js' resolve to a queryless URL
per spec (the resolution base strips the query), so transitive
modules would stale-cache anyway.

Fix: emit an inline <script type="importmap"> mapping each behavior's
bare specifier (@emanote/morph etc.) to its siteRouteUrl-derived
URL, then load main.js as the deferred entry — also via siteRouteUrl.
Each module imports its siblings by bare name (`import { ready }
from '@emanote/morph'`), and the importmap remaps every name fresh
per HTML render. Editing any single module changes only its mtime →
only its ?t= → only its cache key invalidates. The five module names
are pinned in Common.hs as the deployment manifest; adding a behavior
is a one-line addition there.

Importmap URL targets must start with /, ./, or ../ per the HTML spec
— bare relative paths (which siteRouteUrl emits since they resolve
through <base>) get silently ignored, leaving the bare specifier
unresolvable. importmapUrl prefixes with ./ when needed.

Test fixup: 'I scroll the heading … to the top of the viewport' was
fragile — depending on subpixel rounding for the `top < 0` check in
pickActive's lastAbove branch. Replaced with 'into the active band'
(positions the heading at y=200, well within the IO's 80..288 active
band), which exercises the firstVisible path deterministically.
Common.hs was accumulating a where-clause for every static-asset
shim — Tailwind's was already factored into Emanote.View.Tailwind,
this is the same move for the JS bundle. The new module owns
emanoteJsModuleNames (the deployment manifest), the importmap
construction, the importmapUrl ./-prefix workaround, and the
Html-rendering of <script type=importmap> + <script type=module>.
Common.hs now just calls JsBundle.emanoteJsBundle from the splice.

No behavior change; e2e remains 15/15 in both static and live modes.
Companion to d337f19 which added the new module — that commit only
landed the file; the Common.hs cleanup (drop the where-clause helpers
and the local imports they used) and the cabal exposed-modules entry
were stashed by the pre-commit hook and missed the commit.
srid added 5 commits April 26, 2026 10:43
The Tailwind v4 CDN bundle ends with
  //# sourceMappingURL=/sm/<hash>.map
which the live server never routes — every dev pageload prints a 404
in the emanote logs. Strip it from the cached bundle and update the
refresh script to do the same on the next pull. DevTools just won't
show the map; runtime behavior is unchanged.
…ngs)

Each in-app morph nav was emitting five console warnings:
  An import map rule for specifier '@emanote/X' was removed,
  as it conflicted with already resolved module specifiers.
The HTML spec forbids replacing already-resolved bare specifiers, so
the new importmap landing in the morphed <head> was silently dropped
entry-by-entry. Functionally fine (the original importmap was still
in effect) but noisy and misleading.

Tag the importmap with idiomorph's 'im-preserve' (so morph keeps the
original element) and Ema's 'data-ema-skip' (so reloadScripts doesn't
re-create it post-morph). Both are necessary in live-server mode.

Trade-off: edits to importmap'd modules (morph.js, code-copy.js, etc.)
need a hard reload during dev — sidebar-click navigation can't pick
up new ?t= URLs through bare specifiers because the preserved
importmap holds the originals. Edits to main.js itself still propagate
via morph (it's not bare-imported). Documented in the splice's
Haddock.
toc-spy.js's setup captures DOM refs in a closure (the IO observes
specific heading elements at ready-time), so when Ema swaps in a new
page via idiomorph the old observer holds dead references and the new
TOC links never get observed. The active link freezes (or is empty)
on the morphed page.

Refactor: extract setup() returning a teardown closure (disconnect
observer, remove scroll listener). reset() runs teardown then setup.
Subscribe to 'EMAHotReload' (Ema fires this after morph + script
reload, per ema-shim.js) to drive reset().

Reproduced + verified via chrome-devtools MCP against the live server:
fresh-load /markdown picks the right active link on scroll;
pre-fix, morph-nav from / → /markdown left zero links active and
scrolling did nothing; post-fix, morph-nav lands the same active
link as fresh-load.

Other behaviors are unaffected by morph: footnote-popup uses event
delegation + isConnected re-resolution; code-copy uses morph.js's
shared MutationObserver (auto-fires for nodes added by patches);
theme-toggle is a global function. toc-spy is the only one that
relied on a one-shot scan.
The previous e2e setup spawned emanote with --no-ws, so every
scenario navigated via page.goto (fresh full load) and the morph code
path was completely uncovered. That's why issue #667 (toc-spy dying
after morph nav) escaped detection.

Drop --no-ws and add a @morph-tagged scenario that navigates via
window.ema.switchRoute. The test is gated to live mode only; static
mode has no WS so the @morph Before hook returns 'skipped'.

Awaiting window.ema.ready (PR srid/ema#181 — pinned via flake input
to feat/ema-ready-promise) avoids racing the WS handshake. The shim's
EMAHotReload event lets the step block until morph + script-reload
completes, so the next assertion sees stable DOM.

Coverage now: 16 scenarios; live=16/16, static=15/16 (1 skipped).
ema PR srid/ema#181 merged. Drop the temporary feat-branch pin in
flake.nix and pull the merge commit via flake.lock — no functional
change since the feat branch contained exactly the merged content.
Static-mode tailwind.css was missing every Tailwind class that the
JS modules apply at runtime via element.className = '...'. The
prod-compile (compileTailwindCss in View/Tailwind.hs) only listed
generated HTML files as sources, so the scanner never saw class
strings like POPOVER_CLASS / BODY_CLASS in footnote-popup.js. Visible
symptom: the footnote popup rendered as plain text inside a thin
default border — no background, no shadow, no rounding — on
emanote gen output.

Add the bundled JS dir (Paths_emanote.getDataDir </> '_emanote-static/js')
as an additional @source, so Tailwind's scanner picks up the JS class
literals and emits the corresponding rules. Live mode is unaffected
(the browser CDN scans element class attributes at runtime, where the
JS-applied classes are visible by definition).

Add an e2e assertion on the popup body's computed background-color so
this regression class doesn't recur silently. The existing footnote
scenarios only checked textContent — popup unstyled would still pass.
@srid srid changed the title Move interactive JS out of Heist templates into ES modules Move interactive JS to ES modules with live-server cache busting Apr 26, 2026
srid added 4 commits April 26, 2026 12:22
morph.js's sentinelKey() built the dataset key by replacing
non-alphanumerics in the selector with underscores. Two
selectors that differ only in non-alphanumerics — e.g.
'sup[data-foo]' vs 'sup[data_foo]' — would hash to the same key,
so the second onElement() registration would find the sentinel
already set on every matching element and silently skip its callback.
No collision among the current four registrations, but the mechanism
is a footgun for any future behavior.

The sentinel is purely an idempotence guard — its identity carries
no meaning. Replace with a per-registration counter; the dataset key
becomes opaque and collision-free regardless of selector shape.
The 'EMAHotReload' event was hardcoded directly in toc-spy.js, with
no shared abstraction. A future behavior needing morph re-init would
have had to discover the event name independently, and renaming it
upstream in Ema would require touching every behavior.

Add a thin onMorph(fn) wrapper to morph.js. The event name is now a
one-place internal detail. toc-spy switches to onMorph(reset).
If a scenario uses 'I navigate via Ema to ...' without the @morph
tag, it silently runs in static mode where window.ema is undefined,
and the step's polling loop times out after 60s with no early signal.

Add a Before hook that scans the scenario steps and asserts the
@morph tag is present whenever the morph-nav step is used.
srid added 5 commits April 26, 2026 12:33
… scaffolding

main.js's import list and JsBundle.hs's emanoteJsModuleNames are
two views of the same thing (the set of behaviors to load) with no
enforcement they agree. Add a cross-pointer comment in main.js so a
developer adding a behavior sees both sides at once. Issue #669 tracks
the proper consolidation (manifest.json or build-time check).

Also expand JsBundle.hs's docstring per the Lowy review: the only
part of this module that's expected to evolve is the cache-busting
protocol (jsUrl / importmapUrl). emanoteJsModuleNames, importMap, and
emanoteJsBundle are scaffolding pinned by stable specs — they're co-
located here for locality, not because they share a change axis. Calling
that out keeps a future reader from rewriting the whole module when
only the URL protocol needs to change.
Extracted the '@morph' literal in tests/support/hooks.ts to a
top-level MORPH_TAG constant so the two Before hooks (skip + enforce)
and the error message can't drift on rename. Also trimmed the
JsBundle.hs docstring's volatile-vs-scaffolding section: the bullet
enumeration narrated each item ('emanoteJsModuleNames — changes
only when behaviors are added or removed') rather than justifying its
co-location, so collapsed to one paragraph that names the boundary.
The dual manifest — emanoteJsModuleNames in JsBundle.hs vs the
import list in main.js — was hand-bookkept; both reviewers flagged
silent-drift risk. Make the importmap the single source of truth:
main.js parses the inline <script type="importmap"> rendered by the
splice and dynamically imports each bare specifier. The Haskell side
already defines what to load; this loader doesn't need a duplicate
list. Adding a behavior is now a one-line change to
emanoteJsModuleNames — main.js needs no edit.

Browser dedupes module-loads by URL: behaviors that statically
import each other (code-copy → morph) hit the same cached module
instance, regardless of whether the dynamic import or the static
import resolved first.
…loses #670)

Tailwind.hs had a baked-in '_emanote-static/js' path so the static
CSS compile would scan JS files for utility classes. "Where to scan
for class-name-bearing source" is knowledge about the static-file
layout, not about Tailwind's invocation logic. A future linter or
asset-pipeline scanner needing the same paths would either duplicate
the literal or pull it from Tailwind.hs for unrelated reasons.

Move the path to a small Emanote.Static.Sources module (Sources.hs
landed in 2415620 alongside the elegance fixes; this commit just
wires Tailwind.hs to use it). One source of truth; future scanners
read from the same registry.
…oses #671)

Each behavior under _emanote-static/js/ chose its own strategy for
surviving Ema's morph-based in-app navigation: theme-toggle is a
stateless global, footnote-popup uses event delegation + lazy
re-resolution, code-copy uses morph.js's onElement (per-element
wire-on-appear), and toc-spy uses onMorph (re-init on EMAHotReload).
All four are correct for their semantics, but a developer adding a
new behavior had no written guidance on which to pick.

Replace the brief header in morph.js with a section that names and
characterizes all four strategies, with concrete pickers ('use this
when…'). The future-API consolidation half of the issue (a possible
morph.withSurvival(strategy, init) wrapper) stays deferred — only
worth it if more behaviors land. Documentation alone is enough today.
@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 26, 2026

Hickey/Lowy Analysis (resumed run)

# Lens Finding Disposition
1 Hickey sentinelKey() selector-collision footgun in morph.js Fixed in this PR
2 Hickey EMAHotReload event name leaks into behavior code Fixed in this PR
3 Hickey @morph tag convention unenforced — silent 60s timeout on missing tag Fixed in this PR
4 Hickey Dual manifest (main.js imports + emanoteJsModuleNames) drift risk Fixed in this PR
5 Lowy JsBundle.hs conflates volatile (cache-busting) and stable (scaffolding) Fixed in this PR
6 Lowy Tailwind.hs absorbs JS-dir layout knowledge Fixed in this PR (closes #670)
7 Lowy Dual manifest as missing single source of truth Fixed in this PR (closes #669)
8 Lowy Morph-survival convention undocumented across 4 strategies Fixed in this PR (closes #671)

Hickey rationale

Four findings, all addressed.

(1) sentinelKey() derived a dataset key from the selector by character-substitution — sup[data-foo] and sup[data_foo] would collide silently. Replaced with a per-registration counter; the sentinel is opaque, collision-free.

(2) EMAHotReload was hardcoded in toc-spy.js and would have been hardcoded in every future morph-aware behavior. Wrapped in morph.js's new onMorph(fn) export; the event name is now a one-place internal detail.

(3) A scenario using I navigate via Ema to ... without the @morph tag would silently run in static mode (no WS, window.ema undefined) and time out at the step's 60s ceiling. New Before hook scans steps + tags and throws fast with a pointer to the right tag.

(4) main.js's import list and JsBundle.hs's emanoteJsModuleNames were two views of the same thing. Initial fix was a cross-pointer comment; full fix landed under Lowy #7 below.

Lowy rationale

Four findings, all addressed (the Defer column is empty — per request, every finding shipped here).

(5) JsBundle.hs was named after what it does (JS bundling) rather than what changes (the cache-busting protocol). Trimmed the docstring to one paragraph that names the volatility boundary explicitly: only jsUrl / importmapUrl are expected to evolve; the manifest, importmap shape, and HTML rendering are W3C-spec-stable scaffolding co-located for locality, not for shared change rate.

(6) The _emanote-static/js source path lived inside compileTailwindCss. Extracted to a tiny Emanote.Static.Sources module that exports jsScanPath. Tailwind reads from it; future scanners (lints, asset pipelines) read from the same registry. Closes #670.

(7) The dual-manifest hand-bookkeeping is gone: main.js now parses the inline <script type="importmap"> at runtime and dynamically imports each bare specifier. The Haskell emanoteJsModuleNames is the single source of truth; the loader has no second list. Adding a behavior is a one-line Haskell change. Browser dedupes module-loads by URL, so behaviors that statically import each other still hit the same cached instance. Closes #669.

(8) morph.js's header now names and characterizes all four morph-survival strategies (stateless global, event delegation + lazy resolution, per-element wire-on-appear, re-init on morph) with concrete pickers. The future morph.withSurvival(strategy, init) consolidation stays speculative — only worth it if more behaviors land. Closes #671.

E2e remains 17/17 in live, 16+1-skipped in static.

@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 26, 2026

/do results (resumed run, post-implement entry)

Step Status Duration Verification
sync → commit (already done) merge of origin/master clean; check/docs/fmt/commit all green from prior run
hickey+lowy 9m 27s 4 hickey + 4 lowy findings; all 8 fixed in this PR (no defers, per request)
police 8m 22s rules + fact-check clean; elegance: 2 small fixes + 3 previously-deferred Lowy items shipped (#669/#670/#671 closed)
test 28s e2e static 16+1-skipped/17; e2e live 17/17 (incl. morph-nav scenario)
create-pr 1m 4s resumed-run hickey/lowy comment posted with 8-row findings ledger
ci 1m 32s vira ci passed on cc081781; 5 packages × 2 platforms; signoffs landed
Total 21m 31s

Slowest step: hickey+lowy (9m 27s) — bigger diff this time (~900 LOC across 23 files vs. the original 6-file extraction), more for both reviewers to chew on.

Optimization suggestions

  • hickey+lowy and police together = 82% of wall-clock. Both ran two/three review sub-agents in parallel; the cost is mostly the agent latency itself, not anything fixable on our side. For incremental PRs that don't grow much between resumed runs, a smaller scope hint (e.g. "review only commits since <sha>") would shorten both.
  • test was fast (28s) because no Tailwind compile happened — both modes already have cached state. Cold runs are ~2 min.
  • No CI retries — the cumulative diff built clean on first attempt across both platforms.

Workflow completed at 2026-04-26T16:44Z. PR is ready for review.

@srid srid marked this pull request as ready for review April 26, 2026 18:52
@srid srid merged commit 5624d9a into master Apr 26, 2026
6 checks passed
@srid srid deleted the js-module-boundary branch April 26, 2026 18:52
srid added a commit that referenced this pull request Apr 26, 2026
)

**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 its `onclick="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, no
`window.emanote.stork` global, no marker `<script id="emanote-stork">`
element.

Templates expose the search trigger via `data-emanote-stork-toggle`; a
single delegated click listener on `document` covers 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 in `stork-search.tpl` (a
`MutationObserver` re-skinning the search dialog when the user flips the
dark-mode toggle) folded into the module too, since
`document.documentElement` is never replaced by morph and the wrapper
element re-resolves on each fire. Base URL comes from `document.baseURI`
instead 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-wrapper` element gets no `stork-wrapper-edible{,-dark}` class —
the `MutationObserver` only fires when `<html>.class` actually changes,
and a plain morph nav doesn't toggle the theme. _The fix folds
`applyStorkTheme()` into the existing `onMorph` callback 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.

> **Stays inline by necessity.** The vendor `<script
src=".../stork/stork.js">` (the WASM loader that defines `window.stork`)
keeps loading from `<head>` as a non-module blocking script — it must
parse before our module evaluates so `stork.initialize()` and
`stork.register()` resolve. A module-load guard fails fast with a
diagnostic if the load order ever breaks.
>
> **WASM init is once-per-session.** Same memory-leak trade-off as
before (#411): `onMorph(markIndexAsStale)` flips a flag on each in-app
navigation, and `refreshIndex` re-registers lazily the next time the
user opens search.

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's `stork-overflow-hidden-important` class (the actual open-state
truth, not computed visibility — the container is `position:fixed` with
a backdrop, so `getComputedStyle` reads `display: block` either way;
only the class flips).

Eleven commits: the primary migration plus four hickey/lowy fixes
(`searchShown` mirror flag → DOM-derived getter; vendor `window.stork`
guard; comment correctness in `base.tpl`; morph-survival docstring
allows combining strategies), three police fixes (silent URL-parse warn
in `getBaseUrl`, `MODAL_HIDDEN_CLASS` constant,
`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

```sh
nix run github:srid/emanote/stork-es-module
```

_Generated by [`/do`](https://github.com/srid/agency) on Claude Code
(model `claude-opus-4-7`)._
srid added a commit that referenced this pull request Apr 26, 2026
#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`)._
srid added a commit that referenced this pull request Apr 27, 2026
The cache-busting fix from #663 only covered the JS module graph;
**every other asset under `_emanote-static/` (skylighting CSS,
self-hosted fonts, the inverted-tree CSS, the emanote logo, Stork's
CSS+JS) was still served bare** — editing any of them in `emanote run`
left the browser staring at a stale copy until you restarted the server.
They went around `siteRouteUrl` via the legacy
`${ema:emanoteStaticLayerUrl}` text splice, so the `?t=<mtime>` cache
buster never reached them.

This PR introduces an attributed Heist splice — `<emanoteStaticUrl
path="…">${url}…</emanoteStaticUrl>` — that routes through
`siteRouteUrl`, migrates the five default-template call-sites onto it,
and refactors `JsBundle.jsUrl` to share the same primitive
(`Emanote.View.StaticUrl.emanoteStaticUrl`). Now the splice is the one
home for static-asset URL construction; the importmap and the
`<link>`/`<script>`/`<img>` tags all flow through it.

_The legacy `${ema:emanoteStaticLayerUrl}` splice stays around with a
deprecation comment._ Removing it would break third-party templates that
still reference the bare folder URL — soft-deprecate now, retire at the
next major version.

Closes #666.

### Try it locally

```sh
nix run github:srid/emanote/living-gang -- -L docs run
```

Then edit any of
`emanote/default/_emanote-static/{skylighting.css,fonts/fonts.css,inverted-tree.css,emanote-logo.svg,stork/edible.css,stork/stork.js}`
and watch the browser pick up the change without a restart.

_Generated by [`/do`](https://github.com/srid/agency) on Claude Code
(model `claude-opus-4-7`)._
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant