diff --git a/containers/libreportal/frontend/features/dashboard/index.js b/containers/libreportal/frontend/features/dashboard/index.js
new file mode 100644
index 0000000..19e978a
--- /dev/null
+++ b/containers/libreportal/frontend/features/dashboard/index.js
@@ -0,0 +1,42 @@
+// features/dashboard/index.js — the landing Dashboard as a feature module.
+//
+// dashboard.js exposes bare global functions (loadInstalledApps,
+// loadDashboardData live in dashboard.js / data-loader.js, both eager core
+// scripts) — there is no controller class to lazy-load, so scripts is empty.
+// system-loader's 'dashboard' component already ran initializeData()/
+// loadSystemInfo()/setupEventListeners() once at boot; mount() only does the
+// per-navigation refresh (mirrors the old handleDashboard + the navigate()
+// data-reload special-case, which has been removed from spa.js so the reload
+// now fires exactly once here).
+LP.features.register({
+ id: 'dashboard',
+ routes: ['/', '/dashboard'],
+
+ async mount(ctx) {
+ const html = await ctx.loadFragment('/html/dashboard-content.html');
+ ctx.setContent(html, 'Dashboard');
+
+ // Render the installed-apps icon grid (handleDashboard's only post-render call).
+ if (typeof loadInstalledApps === 'function') loadInstalledApps();
+
+ // Repaint the stat cards / disk donut + (re)wire the 1 Hz live SSE. The
+ // 100 ms cushion lets the freshly-injected DOM settle (loadSystemInfo also
+ // guards on element presence). ctx.sub() cancels it if we navigate away
+ // before it fires.
+ const reloadTimer = setTimeout(() => {
+ if (typeof loadDashboardData === 'function') loadDashboardData();
+ }, 100);
+ ctx.sub(() => clearTimeout(reloadTimer));
+ },
+
+ async unmount() {
+ // Nothing this view owns is destroyable from here: the dashboard has no
+ // handler-newed controller and no system-loader singleton to tear down.
+ // The pending reload timer is cancelled via the ctx.sub() above (ctx.teardown).
+ // The 1 Hz LiveSystem SSE sub (attachDashboardLive) self-releases on its
+ // next sample once the dashboard DOM is gone, and the 1 s update countdown
+ // is a module-private interval with no exported stopper (pre-existing; it
+ // self-clears on the next dashboard mount). Both are noted for the Phase 5
+ // dashboard cleanup; neither is reachable here.
+ },
+});
diff --git a/containers/libreportal/frontend/features/tasks/index.js b/containers/libreportal/frontend/features/tasks/index.js
new file mode 100644
index 0000000..5899c18
--- /dev/null
+++ b/containers/libreportal/frontend/features/tasks/index.js
@@ -0,0 +1,45 @@
+// features/tasks/index.js — the Tasks page as a feature module.
+//
+// window.tasksManager is a system-loader singleton (its "task-system" component
+// loads all task scripts, starts the shared SSE bus, then news TasksManager).
+// So mount() does NOT new it or load scripts — it renders the fragment and
+// re-inits the view, exactly like the old handleTasks(). unmount() releases
+// only this view's per-mount leaks (the 30s auto-refresh interval + open log
+// streams); it must never stop the shared taskEventBus or null the singleton.
+LP.features.register({
+ id: 'tasks',
+ routes: ['/tasks', '/tasks*'],
+
+ async mount(ctx) {
+ const html = await ctx.loadFragment('/html/tasks-content.html');
+ ctx.setContent(html, 'Tasks');
+
+ if (window.tasksManager) {
+ await window.tasksManager.init();
+ } else {
+ // Don't throw — matches handleTasks: the page still renders, task
+ // functionality is just limited until the task-system component is ready.
+ console.warn('TasksManager not available yet, task functionality will be limited');
+ }
+ },
+
+ async unmount() {
+ const tm = window.tasksManager;
+ // The one per-view leak init() opens: the 30s auto-refresh interval stored
+ // on the singleton. Each init() recreates it, so clearing here is idempotent.
+ if (tm && tm.refreshInterval) {
+ clearInterval(tm.refreshInterval);
+ tm.refreshInterval = null;
+ }
+ // Stop any open per-task log streams this view started (each removes its own
+ // SSE listeners + map entry). Snapshot keys first — stopLogStreaming mutates
+ // the map. Does NOT touch the shared bus.
+ if (tm && tm.activeLogStreams && typeof tm.stopLogStreaming === 'function') {
+ for (const id of Array.from(tm.activeLogStreams.keys())) {
+ try { tm.stopLogStreaming(id); } catch (_) {}
+ }
+ }
+ // DO NOT: stop window.taskEventBus (shared SSE singleton), remove the
+ // singleton's once-bound task listeners, or null window.tasksManager.
+ },
+});
diff --git a/containers/libreportal/frontend/index.html b/containers/libreportal/frontend/index.html
index 3f1890d..66021e9 100755
--- a/containers/libreportal/frontend/index.html
+++ b/containers/libreportal/frontend/index.html
@@ -111,7 +111,9 @@
+
+