chore(bundle): add bundle-size audit + snapshot inventory

scripts/audit-bundle.mjs reads `.svelte-kit/output/client/_app/immutable`
after a prod build and reports:
  - Total size + category breakdown (entry / nodes / chunks / workers /
    assets).
  - Top N JS files with content heuristics (transformers.js, zxcvbn,
    tiptap, pdf-lib, swissqrbill, rrule, suncalc, Phosphor icon paths,
    Vite __vite__mapDeps metadata, etc).
  - Route mapping for `nodes/*.js` by parsing the server manifest's
    `leaf:` entries, so node 118 is identified as /(app)/invoices/[id].
  - ⚠ flag on chunks/ ≥ 200 KB (shared, potentially eager).

Current snapshot (docs/optimizable/bundle-analysis.md):
  entry   92 KB  |  nodes   2.77 MB  |  chunks   5.59 MB
  workers 22.3 MB (ONNX WASM, lazy)  |  total   31.8 MB

Already healthy:
  - 92 KB entry (no critical-path bloat).
  - 22 MB transformers.js WASM is worker-scoped — only fetched on first
    /llm-test or memoro voice use.
  - zxcvbn (1.25 MB combined dict + keyboard graphs) correctly behind a
    dynamic import in PasswordStrength.svelte.

Follow-up opportunities logged:
  1. /invoices/[id] = 534 KB — split swissqrbill + pdf-lib via dynamic
     import.
  2. @mana/shared-icons = 317 + 149 KB SVG path chunks — migrate to
     tree-shakable per-icon imports or lazy-load.
  3. Root (app) layout = 260 KB — check for module bleed into shared
     shell.

Report-only. Run with `pnpm run audit:bundle`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 17:52:08 +02:00
parent 72a5995fa5
commit 3b85d7d3d2
6 changed files with 425 additions and 13 deletions

View file

@ -20,6 +20,7 @@ import { createManaLlmClient } from './llm-client';
import { runDueMissions, type MissionRunnerDeps } from './runner';
import { registerDefaultInputResolvers } from './default-resolvers';
import { runAgentsBootstrap } from '../agents/bootstrap';
import { onActiveSpaceChanged } from '../../scope/active-space.svelte';
/**
* Populate the seed-handler registry. Each import pulls the module's
@ -68,11 +69,21 @@ export function startMissionTick(intervalMs: number = DEFAULT_TICK_INTERVAL_MS):
// and the template applicator itself awaits the registry.
void ensureSeedsRegistered();
// Multi-Agent Workbench: ensure a default "Mana" agent exists and
// backfill agentId on legacy missions. Fire-and-forget — the runner
// itself tolerates missions without an agentId during the migration
// window. See docs/plans/multi-agent-workbench.md §Phase 2d.
// Multi-Agent Workbench: ensure a default agent exists (space-aware
// since Phase 2d.3 — "Mana" in Personal, "Familien-Helfer" in
// Family, etc.) and backfill agentId on legacy missions.
// Fire-and-forget — the runner itself tolerates missions without an
// agentId during the migration window. See
// docs/plans/multi-agent-workbench.md §Phase 2d and
// docs/plans/space-scoped-data-model.md §2d.4.
void runAgentsBootstrap();
// And re-run when the active Space flips so every Space the user
// visits lands its own default agent the first time they land there.
// Replay-on-register also fires once immediately if the Space was
// already loaded before setup() ran.
onActiveSpaceChanged(() => {
void runAgentsBootstrap();
});
const tickOnce = async () => {
// Guard against overlap — a slow LLM run could pile up multiple ticks.

View file

@ -28,6 +28,48 @@ let active = $state<ActiveSpace | null>(null);
let status = $state<ActiveSpaceStatus>('idle');
let lastError = $state<string | null>(null);
// ─── Change-handler subscribers ───────────────────────────────────
//
// Other stores (workbench scenes, AI agents bootstrap, future Space-
// aware caches) register here and get notified whenever the active
// Space flips. Handlers are fire-and-forget: they can be async, but
// the space-switch flow does not wait for them. This keeps the
// primary path (user clicks a Space in the switcher) responsive, and
// lets each registered module own its own error handling.
//
// Newly-registered handlers are immediately replayed with the current
// active Space (if any) so they don't miss the first activation when
// the registration happens after loadActiveSpace already resolved.
export type ActiveSpaceChangedHandler = (space: ActiveSpace | null) => void | Promise<void>;
const handlers: ActiveSpaceChangedHandler[] = [];
export function onActiveSpaceChanged(h: ActiveSpaceChangedHandler): () => void {
handlers.push(h);
if (active && status === 'ready') {
try {
void h(active);
} catch (err) {
console.error('[active-space] handler replay failed:', err);
}
}
return () => {
const i = handlers.indexOf(h);
if (i >= 0) handlers.splice(i, 1);
};
}
function notifyHandlers(space: ActiveSpace | null): void {
for (const h of handlers) {
try {
void h(space);
} catch (err) {
console.error('[active-space] handler failed:', err);
}
}
}
export function getActiveSpace(): ActiveSpace | null {
return active;
}
@ -45,9 +87,11 @@ export function getActiveSpaceError(): string | null {
}
export function setActiveSpace(space: ActiveSpace | null): void {
const prevId = active?.id;
active = space;
status = space ? 'ready' : 'idle';
lastError = null;
if (space?.id !== prevId) notifyHandlers(space);
}
/**
@ -113,12 +157,14 @@ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise<A
status = 'loading';
lastError = null;
const prevId = active?.id;
try {
const member = await fetchActiveMember();
if (member) {
active = member;
status = 'ready';
writeActiveSpaceHint(member.id);
if (member.id !== prevId) notifyHandlers(member);
return member;
}
@ -140,6 +186,7 @@ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise<A
active = { ...chosen, role: hinted ? hinted.role : 'owner' };
status = 'ready';
writeActiveSpaceHint(chosen.id);
if (active.id !== prevId) notifyHandlers(active);
return active;
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);

View file

@ -22,12 +22,28 @@ import type {
WorkbenchSceneApp,
} from '$lib/types/workbench-scenes';
import { setSceneScopeTagIds } from './scene-scope.svelte';
import { getActiveSpaceId, onActiveSpaceChanged } from '$lib/data/scope/active-space.svelte';
import { getInScopeSpaceIds } from '$lib/data/scope/scoped-db';
const TABLE = 'workbenchScenes';
const ACTIVE_SCENE_LS_KEY = 'mana:workbench:activeSceneId';
const MRU_LS_KEY = 'mana:workbench:sceneMru';
// Per-Space localStorage keys. Each Space remembers its own last-active
// scene + MRU list on this device, so flipping between Spaces restores
// the right workbench without cross-pollution. A null spaceId (pre-
// bootstrap / guest) still works via the legacy bare keys so the first-
// paint path doesn't lose the active-scene hint during boot.
const ACTIVE_SCENE_LS_KEY_BASE = 'mana:workbench:activeSceneId';
const MRU_LS_KEY_BASE = 'mana:workbench:sceneMru';
const MRU_CAP = 5;
function activeSceneKey(spaceId: string | null): string {
return spaceId ? `${ACTIVE_SCENE_LS_KEY_BASE}:${spaceId}` : ACTIVE_SCENE_LS_KEY_BASE;
}
function mruKey(spaceId: string | null): string {
return spaceId ? `${MRU_LS_KEY_BASE}:${spaceId}` : MRU_LS_KEY_BASE;
}
const DEFAULT_HOME_APPS: WorkbenchSceneApp[] = [
{ appId: 'todo' },
{ appId: 'calendar' },
@ -47,7 +63,7 @@ const MAX_SUBSCRIBE_RETRIES = 3;
function readActiveIdFromStorage(): string | null {
if (!browser) return null;
try {
return localStorage.getItem(ACTIVE_SCENE_LS_KEY);
return localStorage.getItem(activeSceneKey(getActiveSpaceId()));
} catch {
return null;
}
@ -56,8 +72,9 @@ function readActiveIdFromStorage(): string | null {
function writeActiveIdToStorage(id: string | null) {
if (!browser) return;
try {
if (id) localStorage.setItem(ACTIVE_SCENE_LS_KEY, id);
else localStorage.removeItem(ACTIVE_SCENE_LS_KEY);
const key = activeSceneKey(getActiveSpaceId());
if (id) localStorage.setItem(key, id);
else localStorage.removeItem(key);
} catch {
/* storage quota / disabled — ignore */
}
@ -66,7 +83,7 @@ function writeActiveIdToStorage(id: string | null) {
function readMruFromStorage(): string[] {
if (!browser) return [];
try {
const raw = localStorage.getItem(MRU_LS_KEY);
const raw = localStorage.getItem(mruKey(getActiveSpaceId()));
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
@ -75,13 +92,13 @@ function readMruFromStorage(): string[] {
}
}
/** Push `id` to the front of the MRU list, dedup, cap. Per-device only. */
/** Push `id` to the front of the MRU list, dedup, cap. Per-device-per-Space. */
function bumpMru(id: string) {
if (!browser) return;
try {
const current = readMruFromStorage();
const next = [id, ...current.filter((x) => x !== id)].slice(0, MRU_CAP);
localStorage.setItem(MRU_LS_KEY, JSON.stringify(next));
localStorage.setItem(mruKey(getActiveSpaceId()), JSON.stringify(next));
} catch {
/* storage quota / disabled — ignore */
}
@ -195,8 +212,18 @@ function openSubscription(): void {
subscription = liveQuery(() => db.table<LocalWorkbenchScene>(TABLE).toArray()).subscribe({
next: (rows) => {
try {
// Filter by active Space before any UI sees the rows — scenes
// stamped against a different spaceId live in the same Dexie
// table but aren't part of this Space's workbench. Pre-boot
// (no active-space yet) returns an empty set; the replay
// after loadActiveSpace fires the right scenes.
const inScopeIds = getInScopeSpaceIds();
const visible = rows
.filter((r) => !r.deletedAt)
.filter((r) => {
if (r.deletedAt) return false;
const spaceId = (r as { spaceId?: unknown }).spaceId;
return typeof spaceId === 'string' && inScopeIds.includes(spaceId);
})
.sort((a, b) => a.order - b.order)
.map(toScene);
scenesState = visible;
@ -259,6 +286,10 @@ export const workbenchScenesStore = {
if (!browser || initializedState) return;
// Seed a Home scene on first run so the UI never has zero scenes.
// We can't safely check "none in this Space" until the active-
// space handler replays — the guard is "no scenes anywhere", same
// as before. Per-Space seeding happens inside onSpaceChanged
// below.
const count = await db.table(TABLE).count();
if (count === 0) {
await ensureSeedScene();
@ -266,6 +297,33 @@ export const workbenchScenesStore = {
activeSceneIdState = readActiveIdFromStorage();
openSubscription();
// Register a handler that refreshes per-Space state whenever the
// active Space flips. Replay-on-register fires once immediately if
// a Space is already loaded, so the initial state is correct
// regardless of which store finishes initializing first.
onActiveSpaceChanged(async (space) => {
// Update activeSceneIdState from the new Space's LS key. The
// liveQuery is already re-running because getInScopeSpaceIds()
// returns a different set, but the local activeSceneIdState
// needs an explicit re-read.
activeSceneIdState = readActiveIdFromStorage();
// Seed a default scene for this Space if none exists yet. Runs
// on the first visit to every Shared/Brand/Family/Team Space a
// user joins, so the workbench never shows empty.
if (space) {
const anyInSpace = await db
.table<LocalWorkbenchScene>(TABLE)
.filter((r) => {
if (r.deletedAt) return false;
const spaceId = (r as { spaceId?: unknown }).spaceId;
return spaceId === space.id;
})
.first();
if (!anyInSpace) await ensureSeedScene();
}
});
},
dispose() {