Files
notify-bridge/.claude/reviews/frontend-review.md
T
alexei.dolgolyov 6a8f374678 feat: observability, per-receiver Telegram options, oversized-video fallback
Operability:
- Correlation IDs end-to-end: shared dispatch_id between log lines and
  EventLog rows (event/watcher/scheduled/deferred/action/HA/command paths)
  and a new X-Request-Id middleware that normalizes inbound ids and binds
  request_id into log context.
- dispatch_summary block merged into EventLog.details: per-target
  success/failure counts plus Telegram media delivered/skipped/failed and
  truncated error lists, so partial outcomes surface in the UI.
- Diagnostic mode: admin can flip one module to DEBUG for a bounded
  window with auto-revert (in-memory only; setup_logging() resets on
  boot, lifespan reverts on shutdown). New /diagnostic-mode endpoints
  plus DiagnosticsCassette UI on the settings page.

Telegram:
- Per-receiver options: disable_notification (silent send) and
  message_thread_id (forum-topic routing), wired through the dispatcher
  via a ContextVar so all four send sites (sendMessage / sendPhoto-Video-
  Document / sendMediaGroup / cache-hit POST) pick them up.
- send_large_videos_as_documents target setting: bypass the 50 MB
  sendVideo cap by falling back to sendDocument for oversized videos.
- sendMediaGroup byte-budget enforcement (TELEGRAM_MAX_GROUP_TOTAL_BYTES,
  45 MB) with per-item fallback on chunk failure so a stale file_id no
  longer silently drops a cached asset.

Tests:
- New: diagnostic_mode, dispatch_summary, request_correlation,
  telegram_media_group_partial, telegram_per_send_options.

Docs:
- .claude/reviews/: six-axis production-readiness review of v0.8.1.
- .claude/docs/functional-review-2026-05-28.md: focused review of
  Telegram/Immich/logging subsystems.
2026-05-28 15:19:31 +03:00

29 KiB

Frontend Production-Readiness Review

Scope: frontend/src/** (~26k lines, Svelte 5 runes + SvelteKit). npm run check passes with exit code 0. The codebase is in good shape overall - i18n EN/RU keys are 1:1 in sync (1466 each), Modal/Snackbar overlays follow the position:fixed

  • z-index:9999 convention, no eval, no innerHTML, no string-interpolated setTimeout, and the sanitizer (lib/sanitize.ts) is a sound DOMParser-based allowlist. The issues below are real production risks layered on top of an otherwise clean architecture.

Executive Summary

  • Auth tokens live in localStorage (lib/api.ts). Any XSS that bypasses the (good) sanitizePreview allowlist - or sneaks past it via a future code path - exfiltrates both access and refresh tokens. There is no httpOnly-cookie alternative, no token rotation on refresh failure, and redirectToLogin only fires once per session (a leaked refresh token can outlive that flag).
  • One real provider-hardcoding violation (routes/actions/RuleEditor.svelte) breaks the "descriptors only" rule in CLAUDE.md item 8 and silently disables the people/album picker for any non-Immich provider - every other page is clean.
  • Caches duplicated into local $state on notification-trackers, command-trackers, and command-template-configs pages - the cache is populated but the page never re-reads it, so cross-page mutations (search palette pre-warming) won't update the list and cache invalidate() becomes useless. Convention #4 says "always use cache".
  • Three CRUD pages refetch all entities after every mutation (full await load() after upsert/delete) instead of using cache.upsert()/ remove() - defeats the optimistic-cache design and produces visible flicker on slow connections.
  • Floating async work + N+1 patterns: providers/+page.svelte fires N parallel health checks without an AbortController (state writes continue after navigation); bots/TelegramBotTab.svelte does a sequential for (const trk of trackers) { await api('/listeners') } loop.
  • backup/+page.svelte post-restart health poll keeps recursing for up to 120s with no unmount guard - if the user navigates away mid-restart, the recursive setTimeout chain keeps calling fetch('/api/health') until it reloads the page out from under whatever route they're on.
  • api() 30s timeout is per-request, hard-coded, with no observability - long-running provider operations (Immich bulk fetch, full backup export) hit it silently and surface as AbortError with no telemetry.

CRITICAL

C1. JWT tokens stored in localStorage - XSS-exfiltratable

lib/api.ts:78-91

function getToken(): string | null {
    return localStorage.getItem('access_token');
}
export function setTokens(access: string, refresh: string) {
    localStorage.setItem('access_token', access);
    localStorage.setItem('refresh_token', refresh);
}

Both the short-lived access token and the long-lived refresh token sit in localStorage. Any successful XSS - including a future template-preview path that escapes sanitizePreview, a vulnerable third-party CodeMirror extension, or a Telegram bot username that ends up unescaped somewhere - reads both with a single localStorage.getItem call.

Fix: Move to httpOnly + Secure + SameSite=Strict cookies set by the backend. If a cookie-based session is infeasible for the deployment model, at minimum move the refresh token to an httpOnly cookie and keep only the short-lived access token in memory (a module-level let accessToken is XSS-readable but not persistent across reloads, which limits the exfiltration window).

C2. Provider type hardcoded in RuleEditor.svelte (convention violation)

routes/actions/RuleEditor.svelte:55-67

async function loadProviderData() {
    if (actionType !== 'auto_organize') return;
    const provider = providersCache.items.find((p: any) => p.id === providerId);
    if (!provider || provider.type !== 'immich') return;
    ...

CLAUDE.md item 8 explicitly forbids if (type === 'immich') in components - this is the canonical example. As written, adding a second provider with auto-organize support (Google Photos, future SmugMug, etc.) is a silent no-op: the form renders with empty people/album lists and gives no error.

Fix: Add an actionTypes / peopleFilter capability flag to ProviderDescriptor, or add a supportsAutoOrganize: boolean discriminator, then check getDescriptor(provider.type)?.supportsAutoOrganize instead of the literal string.


HIGH

H1. Caches imported but copied into local $state - invalidation no-op

routes/notification-trackers/+page.svelte:33 routes/command-trackers/+page.svelte:27 routes/command-template-configs/+page.svelte:51

// notification-trackers - line 33
let allNotificationTrackers = $state<Tracker[]>([]);
// ...
[allNotificationTrackers] = await Promise.all([
    api<Tracker[]>('/notification-trackers'),
    ...
]);

The cache modules expose notificationTrackersCache, commandTrackersCache, and commandTemplateConfigsCache - populated by +layout.svelte on mount and by the search palette - but these three pages don't read from them. They each issue their own api(...) call and store the result locally. Side effects:

  1. The cache shows stale data on every other page that reads it (dashboard nav counts, search palette).
  2. commandTemplateConfigsCache.fetch(true) is called on command-template-configs load() but the result is then re-assigned from the function return value into allCmdTplConfigs - the cache itself is updated, but the page has no reactive link to it.
  3. cache.upsert() / cache.remove() after mutations would short-circuit a full refetch - but with the local-state copy, every save triggers a full await load() (see H2).

Fix: Replace let allX = $state([]) with let allX = $derived(cache.items) (see how targets/+page.svelte:147 does it correctly) and remove the parallel api() call.

H2. Full refetch after every mutation - cache.upsert/remove not used

routes/providers/+page.svelte:238-250 routes/actions/+page.svelte:139 routes/notification-trackers/+page.svelte:291 routes/targets/+page.svelte:476

Every save/delete/toggle on these pages calls cache.invalidate(); await load(), which re-fetches the entire list from the server. The cache exposes upsert(entity) and remove(id) for exactly this case - the server already returned the new entity (or 204), so the round-trip is wasted bandwidth and produces a visible "list redraws" flash on slow links.

Fix: On POST/PUT response, cache.upsert(savedEntity). On DELETE, cache.remove(id). Reserve invalidate() + fetch() for cases where the mutation may have changed other entities (e.g. broadcast target updates affect children).

H3. Provider health checks fire-and-forget - leak past navigation

routes/providers/+page.svelte:175-181

for (const p of allProviders) {
    health = { ...health, [p.id]: null };
    api(`/providers/${p.id}/test`, { method: 'POST' })
        .then((r: any) => { health = { ...health, [p.id]: r.ok }; })
        .catch(() => { health = { ...health, [p.id]: false }; });
}

No AbortController, no unmount guard. If the user navigates away while N slow Immich/Gitea probes are inflight, every probe still resolves and tries to write to the (now-detached) health $state. With Svelte 5 runes this won't crash, but it does waste backend connections (Immich health checks call the real API) and may trigger duplicate probes on quick back/forward navigation.

Fix: Pass { signal: controller.signal } to api() (already supported - see lib/api.ts:150), abort in onDestroy. Or use cache.probeAll() driven from a single store so revisiting the page reuses the previous result.

H4. Sequential awaits for independent fetches - N+1 in TelegramBotTab

routes/bots/TelegramBotTab.svelte:215-223

const trackers = await api<CommandTrackerSummary[]>('/command-trackers');
const matched: CommandTrackerSummary[] = [];
for (const trk of trackers) {
    try {
        const listeners = await api<ListenerEntry[]>(`/command-trackers/${trk.id}/listeners`);
        const hasBot = listeners.some(...);
        if (hasBot) matched.push(trk);
    } catch (e) { console.warn(...); }
}

For a deployment with 20 command trackers, opening the listener section on a bot triggers 20 serial GET /command-trackers/{id}/listeners requests - visibly slow over a high-latency link.

Fix: Either expose a single backend endpoint (GET /command-trackers/listeners?bot_id=X) or run the loop through Promise.all(trackers.map(trk => api(...).catch(() => null))) and filter afterwards.

H5. Post-restart health poll keeps running after unmount

routes/settings/backup/+page.svelte:117-139

async function applyAndRestart(): Promise<void> {
    await api('/backup/apply-restart', { method: 'POST' });
    restartingOverlay = true;
    const startedAt = Date.now();
    let attempts = 0;
    const poll = async (): Promise<void> => {
        attempts += 1;
        try {
            const res = await fetch('/api/health');
            if (res.ok && Date.now() - startedAt > 2000) {
                window.location.reload();
                return;
            }
        } catch { /* still down */ }
        if (attempts < 120) setTimeout(poll, 1000);
    };
    setTimeout(poll, 1500);
}

The recursive setTimeout(poll, 1000) chain has no cancellation. If the user navigates to another route between apply-restart and the next health probe, the chain keeps firing for up to 120s and eventually calls window.location.reload() from a route the user has since moved away from. Side effects:

  1. Unauthenticated fetch('/api/health') calls keep going while the user is on /login.
  2. A user who hit "restart later" on a different tab will still get reloaded from the original tab's poll.

Fix: Capture controller = new AbortController() and pass to fetch, onDestroy(() => controller.abort()). Also store the timeout handle and clearTimeout it on destroy.

H6. Token refresh races with logout in a sneaky edge

lib/api.ts:97-127

The dedupe via refreshPromise is correct for the refresh itself, but the outer api() reads getToken() before awaiting refreshAccessToken(). Three concurrent requests that all 401 will all queue on the same refresh promise, then all retry - fine. But if the refresh succeeds and an unrelated clearTokens() (from logout()) fires between the refresh resolving and the retry running, the retry uses an empty Authorization: Bearer header. The result is "ApiError: HTTP 401" surfaced via snackbar even though the redirect to /login already happened.

Fix: Either re-check isAuthenticated() immediately before the retry, or make clearTokens() cancel an inflight refreshPromise.

H7. AuthRedirectError is thrown but not consistently caught

lib/api.ts:165-170

Most pages use the pattern catch (err: unknown) { snackError(errMsg(err)); } - which catches AuthRedirectError too and shows "Unauthorized - redirecting to login" in a snackbar that the user sees as the route changes. The error class exists specifically to be distinguished, but only one or two call sites actually check instanceof AuthRedirectError before showing a snackbar.

Fix: Make errMsg() (or a new helper) return null for AuthRedirectError and have snackbar helpers ignore null messages. Or filter in the snackbar store.

H8. api() JSON-decode failure path swallowed silently

lib/api.ts:189

return res.json();

When the backend returns a 200 OK with a non-JSON body (proxy error page, HTML 502 from a misconfigured reverse proxy in front), res.json() rejects with a SyntaxError: Unexpected token < in JSON at position 0. The page shows the raw parser message in a snackbar, which is confusing UX.

Fix: Wrap res.json() in try/catch and throw a typed ApiError("Backend returned non-JSON response", 502) so the UI can show a clean message.

H9. Email/Matrix bot tabs strip secrets via as any

routes/bots/EmailBotTab.svelte:84 routes/bots/MatrixBotTab.svelte:79

if (!body.smtp_password) delete (body as any).smtp_password;
if (editingMatrix && !body.access_token) delete (body as any).access_token;

The as any bypass exists because the body type doesn't allow delete on a required field. The intent - "don't send a blank secret which would overwrite the stored one" - is correct, but the cast hides a real risk: if the field name ever changes (smtp_password -> smtpPassword), the delete is a no-op and the blank field is sent.

Fix: Build body as Partial<...> from the start and only conditionally include the secret field.

H10. template-configs hardcodes a slot name

routes/template-configs/+page.svelte:228

.map(s => ({ key: s.name, label: ..., rows: s.name === 'message_assets_added' ? 10 : 3, isDateFormat: false }))

Special-casing one Immich slot name inside a provider-agnostic component is the same pattern CLAUDE.md item 8 forbids for components, scoped to template configs. Other providers' "large" slots (Gitea PR descriptions, Planka card content) would render in 3-row editors that the author probably didn't intend.

Fix: Add a rows?: number field to the backend slot definition and read it via notification_slots[].rows.


MEDIUM

M1. Three placeholder strings hardcoded English in shared components

lib/components/EntitySelect.svelte:18 lib/components/IconGridSelect.svelte:16 lib/components/MultiEntitySelect.svelte:16

placeholder = 'Select...',

These defaults render Select... in RU locale when a caller doesn't pass an explicit placeholder. The convention (CLAUDE.md item 5) prescribes plain text selectors but says nothing about translation - these still need to flow through t().

Fix: Move the default into the template: placeholder = $props().placeholder ?? t('common.selectPlaceholder'), with common.selectPlaceholder added to both locales.

M2. EntitySelect.noneLabel defaults to a decorative em-dash literal

lib/components/EntitySelect.svelte:20

noneLabel = (em-dash literal),

CLAUDE.md item 5 calls out decorative dashes specifically. LinkedTargetsSection already overrides this with t('common.noneDefault') (good), but other consumers that do not override get the bare em-dash. It also fails the localizable smell test.

Fix: Default to t('common.none').

M3. lib/auth.svelte.ts logout does a full page reload, losing UX continuity

lib/auth.svelte.ts:54-61

export function logout() {
    clearTokens();
    clearAllCaches();
    user = null;
    if (typeof window !== 'undefined') {
        window.location.href = '/login';
    }
}

window.location.href triggers a hard reload - the SvelteKit router exists specifically to avoid this. Side effects: any inflight requests get cancelled without proper cleanup, the splash-loader flashes between the two pages, and the search-palette / overlays do not get a chance to close gracefully.

Fix: goto('/login', { invalidateAll: true, replaceState: true }).

M4. +layout.svelte auto-expand $effect writes during read

routes/+layout.svelte:336-342

The effect reads expandedGroups (via expandedGroups[entry.key]) and writes to expandedGroups. Svelte 5 dedupes the write back to the same set of keys, but the pattern is fragile - adding any side effect that re-derives from expandedGroups here would loop. It also persists to localStorage in toggleGroup but not from this effect - so auto-expansion stays in memory only.

Fix: Compute the next state in a single pass and write once; either include the localStorage save, or move the auto-expand into the initial hydration block.

M5. commandTemplateConfigsCache.fetch(true) result discarded; cache populated but unused

routes/command-template-configs/+page.svelte:208

The Promise.all destructures cfgs from commandTemplateConfigsCache.fetch(true) but then writes allCmdTplConfigs = cfgs instead of $derived-reading the cache. The cache is updated (good) but this page never reads it (bad - see H1).

Fix: Same fix as H1 - use $derived(commandTemplateConfigsCache.items).

M6. Dashboard search debounce timeout not cleared on filter change

routes/+page.svelte:268-272

If the user changes the type/provider filter (applyFilters runs synchronously from the $effect at line 249) while a search debounce is pending, the pending timeout still fires 300ms later and triggers an identical request. Not a leak, just a wasted call.

Fix: Clear searchTimeout from applyFilters() as well.

M7. Dashboard Promise.all destructure uses empty middle slot

routes/+page.svelte:283-287

const [statusRes, , chartRes] = await Promise.all([
    api<DashboardStatus>(`/status?limit=${eventsLimit}`),
    providersCache.fetch(),
    api<{ days: ... }>('/status/chart'),
]);

The empty middle slot is brittle - anyone reordering for readability silently swaps statusRes and chartRes. Trivially avoided.

Fix: Either await providersCache.fetch() separately (it caches anyway), or const [statusRes, _providers, chartRes] = ... with an explicit _providers local.

M8. actions/+page.svelte derives actionTypes from a function-in-derived

routes/actions/+page.svelte:78-81

let actionTypes = $derived((() => {
    const caps = capabilitiesCache.items[selectedProviderType];
    return caps?.action_types || [];
})());

The IIFE is unnecessary; $derived already runs the expression on every dependency change. Reads as a refactor leftover.

Fix: let actionTypes = $derived(capabilitiesCache.items[selectedProviderType]?.action_types ?? []);

M9. RuleEditor.svelte mutates rule object in toggleRule then sends to API

routes/actions/RuleEditor.svelte:105-108

async function toggleRule(rule: ActionRule) {
    rule.enabled = !rule.enabled;
    await updateRule(rule);
}

Direct mutation of the prop violates the immutability rule (coding-style.md). If the API call fails, the local state is already flipped - the UI shows the new value even though the server still has the old one.

Fix: await updateRule({ ...rule, enabled: !rule.enabled }). After successful response, await loadRules() (already happens) re-syncs.

M10. +layout.svelte filter functions use as any[] four times

routes/+layout.svelte:145-151

notification_trackers: filterById(notificationTrackersCache.items as any[]).length,

The cast exists because filterById<T extends { provider_id?: number }> is narrower than the cache item types. The proper fix is a single base interface { provider_id?: number } on the relevant types so the cast goes away.

M11. setLocale does not update <html lang> attr

lib/i18n/index.svelte.ts:31-36

Screen readers and browser translation extensions rely on <html lang="en">. The app never sets it, so switching to RU leaves accessibility tooling thinking the page is still English.

Fix: document.documentElement.lang = locale in setLocale.

M12. Modal.svelte focus restore does not verify element still in DOM

lib/components/Modal.svelte:43-45

If the previously focused element has been removed from the DOM between modal open and close (common with optimistic UI updates that rerender the source button), .focus() is a silent no-op on a detached node. Focus ends up on <body> and the next Tab restarts from the top of the page.

Fix: if (... && document.contains(previouslyFocused)) previouslyFocused.focus(), else focus a sensible fallback (the trigger that opened the page).

M13. TimezoneSelector ticks at 1s - wakes the event loop forever

lib/components/TimezoneSelector.svelte:33-37

let tickHandle: ReturnType<typeof setInterval> | null = null;
onMount(() => {
    tickHandle = setInterval(() => { now = new Date(); }, 1000);
});

A 1Hz tick is fine for visible UI; the issue is it keeps running even when the selector dropdown is closed (the time display is only visible when the dropdown is open). Battery impact is non-trivial on mobile for what is essentially a hidden component.

Fix: Start/stop the interval based on open state, or use requestAnimationFrame driven by IntersectionObserver.

M14. Backup file download builds blob from JSON without size guard

routes/settings/backup/+page.svelte:269-281

const data = await api(`/backup/files/${filename}`);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });

For a deployment with hundreds of providers/trackers, the JSON serialization of the entire backup happens in-memory in a single string before the Blob constructor - wasted memory peak and a frozen tab on slow machines. Worse, api() parses the JSON and then JSON.stringify re-serializes it.

Fix: Use fetchAuth() for the download path and pipe the response stream straight into a Blob (new Blob([await res.arrayBuffer()])).

M15. Modal focus-trap query selector includes disabled inputs

lib/components/Modal.svelte:62-67

Re-querying the DOM on every Tab keystroke is OK but means disabled inputs (common in long forms with submit-in-progress) are included in the trap and focus can land on them. The selector should add :not([disabled]).

M16. i18n resolve uses any for the recursion accumulator

lib/i18n/index.svelte.ts:55-62

function resolve(obj: any, path: string): string | undefined {

obj: unknown plus a runtime check would let TS narrow current properly and catch the case where someone accidentally passes a string (returns undefined silently today).

M17. Tracker name auto-set string concat - English-only

routes/notification-trackers/+page.svelte:82-84 routes/command-trackers/+page.svelte:69-71

form.name = provider ? `${provider.name} Tracker` : 'Tracker';
form.name = provider ? `${provider.name} Commands` : 'Commands';

Defaults the tracker name to "Provider Name Tracker" / "Provider Name Commands"

  • only English. Russian users get an English suffix on the auto-generated name. Inconsistent with the rest of the i18n discipline.

Fix: Use t('notificationTracker.defaultName').replace('{name}', provider.name).

M18. topbar-action store not cleared on auth state change

routes/providers/+page.svelte:160-167

Each page sets a topbar CTA in onMount and clears it in onDestroy. If logout() is called from inside the page (via the search palette, etc.), the page never destroys cleanly and the topbar action sticks into the login screen. Defensive topbarAction.clear() in logout() would plug this.

M19. Many : any and as any types in critical paths

routes/users/+page.svelte:62 routes/command-trackers/+page.svelte:27 routes/providers/+page.svelte:179 lib/providers/types.ts:120

64 occurrences of : any / as any across 20 files. None are in security-sensitive paths, but they remove type safety in exactly the call sites that shape API requests (body: any = { ... }). Recommended cleanup task, not a blocker.


LOW

L1. +page.svelte event types hardcoded in three parallel maps

routes/+page.svelte:475-512

eventLabels, eventIcons, and eventGradients are three parallel dicts keyed by the same set of strings. Adding a new event type requires editing three places (plus i18n). A single EVENT_META object would be more maintainable.

L2. TestMenu.svelte uses z-index 9998 instead of 9999

routes/notification-trackers/TestMenu.svelte:25

<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"

The convention says 9999 for overlays. Using 9998 was probably intentional (so the menu sits above the backdrop), but the cleaner pattern is to give the backdrop a slightly lower stacking context inside the same parent.

L3. console.warn left in production-bound code

14 console.warn/console.error occurrences. Most are guarded by a "failed to load" + UI fallback - legitimate debug noise. Recommend wiring to a structured logger before public release; current state is acceptable for an internal tool but spam-prone in DevTools.

L4. Dashboard setTimeout(animateCount, 200) is uncancelled

routes/+page.svelte:290-299

The 200ms delay before triggering count animations is uncancelled. Navigating away during the first 200ms means the count animation requestAnimationFrame chain still runs against a stale status reference. Cosmetic only.

L5. app.html inline theme bootstrap reads localStorage without try/catch

src/app.html:12

Theme is hydrated synchronously in <head> to avoid FOUC - fine - but if localStorage is blocked (Safari private mode, some enterprise policies) the inline script throws and the rest of the head bootstrap may be skipped.

L6. EventChart computes activeTypes and hasData from same loop twice

lib/components/EventChart.svelte:46-49

hasData and activeTypes traverse the same data twice. Single-pass derivation would be cheaper for the rare "many days of events" case.

L7. Single-letter t shadowing in +layout.svelte

+layout.svelte:140 uses for (const t of targets) inside navCounts, which shadows the imported i18n function t. Svelte 5 does not flag it (inner scope wins), but it confuses search/grep and breaks IDE go-to-definition. Several other pages use single-letter t as iteration var (actions/+page.svelte, command-trackers/+page.svelte, targets/+page.svelte). Recommend target / tracker for legibility.


Notes & non-findings

  • Modal overlay convention (CLAUDE.md #2): Modal.svelte, Snackbar, IconPicker, IconGridSelect, MultiEntitySelect, EntitySelect, TimezoneSelector, EventChart, Hint, SearchPalette, and TestMenu all use position:fixed with z-index: 9999 (or 9998 for the TestMenu backdrop - see L2). Convention upheld.
  • @html usage - only three call sites, all pipe through sanitizePreview, which is a DOMParser-based allowlist limited to B, I, CODE, PRE, A, BR with https?:// href validation. Safe.
  • i18n parity: EN and RU JSON have the exact same 1466 keys - no orphans.
  • Selector placeholders: LinkedTargetsSection correctly uses t('common.noneDefault'), no em-dash leaks in user-facing flows (only defaults inside shared components - see M1/M2).
  • svelte-check passes (exit 0) - no type errors at the strict level the project compiles with.
  • No eval, new Function, or string-setTimeout: dynamic code execution surface is clean.
  • No var declarations, no == (loose equality) outside generated CSS.
  • AbortController usage: present in lib/api.ts for the canonical fetch wrapper - the rest of the codebase could lean on it more (see H3, H5).