Move interactive JS to ES modules with live-server cache busting#663
Move interactive JS to ES modules with live-server cache busting#663
Conversation
…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.
Hickey/Lowy Analysis
Hickey rationaleFour 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 Lowy rationaleTwo findings, both deferred. The implementation matches the design sketch on every structural axis: |
|
| 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+lowydominated (27% of wall-clock). The two parallel sub-agent reviews ran onsonnetagainst 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.policewas 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-onlyto skip everything before the CI step.
Workflow completed at 2026-04-25T23:19Z.
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.
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.
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.
… 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.
Hickey/Lowy Analysis (resumed run)
Hickey rationaleFour findings, all addressed. (1) (2) (3) A scenario using (4) Lowy rationaleFour findings, all addressed (the (5) (6) The (7) The dual-manifest hand-bookkeeping is gone: (8) E2e remains 17/17 in live, 16+1-skipped in static. |
|
| 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+lowyandpolicetogether = 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.testwas 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.
) **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`)._
#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 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`)._
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 amorph.jshelper providingready()andonElement(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/morphetc.) to URLs throughsiteRouteUrl, 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 taggedim-preserve+data-ema-skipso 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. Thetoc-spymodule captures DOM refs in a closure, so it now also subscribes to Ema'sEMAHotReloadevent to tear down + re-init on each morph (closes #667).Static mode also gained a fix: the production Tailwind compile now adds
_emanote-static/jsas an@source, so utility classes applied by JS at runtime (POPOVER_CLASS,BODY_CLASSinfootnote-popup.js) are visible to the scanner and don't drop out oftailwind.css. Pre-fix, the popup rendered as plain text inside a thin border onemanote genoutput. Also strips Tailwind's//# sourceMappingURL=/sm/<hash>.maptrailer to silence the per-pageload 404 in dev logs.E2e: 16 scenarios, live=16/16, static=15+1-skipped (
@morphtag 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 intoEmanote.View.JsBundlemirrors the existingEmanote.View.Tailwindshape.Try it locally
nix run github:srid/emanote/js-module-boundary -- -L $YOUR_NOTEBOOK run