# 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](frontend/src/lib/api.ts#L78-L91) ```ts 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](frontend/src/routes/actions/RuleEditor.svelte#L55-L67) ```ts 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](frontend/src/routes/notification-trackers/+page.svelte#L33) [routes/command-trackers/+page.svelte:27](frontend/src/routes/command-trackers/+page.svelte#L27) [routes/command-template-configs/+page.svelte:51](frontend/src/routes/command-template-configs/+page.svelte#L51) ```ts // notification-trackers - line 33 let allNotificationTrackers = $state([]); // ... [allNotificationTrackers] = await Promise.all([ api('/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](frontend/src/routes/providers/+page.svelte#L238-L250) [routes/actions/+page.svelte:139](frontend/src/routes/actions/+page.svelte#L139) [routes/notification-trackers/+page.svelte:291](frontend/src/routes/notification-trackers/+page.svelte#L291) [routes/targets/+page.svelte:476](frontend/src/routes/targets/+page.svelte#L476) 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](frontend/src/routes/providers/+page.svelte#L175-L181) ```ts 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](frontend/src/routes/bots/TelegramBotTab.svelte#L215-L223) ```ts const trackers = await api('/command-trackers'); const matched: CommandTrackerSummary[] = []; for (const trk of trackers) { try { const listeners = await api(`/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](frontend/src/routes/settings/backup/+page.svelte#L117-L139) ```ts async function applyAndRestart(): Promise { await api('/backup/apply-restart', { method: 'POST' }); restartingOverlay = true; const startedAt = Date.now(); let attempts = 0; const poll = async (): Promise => { 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](frontend/src/lib/api.ts#L97-L127) 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](frontend/src/lib/api.ts#L165-L170) 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](frontend/src/lib/api.ts#L189) ```ts 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](frontend/src/routes/bots/EmailBotTab.svelte#L84) [routes/bots/MatrixBotTab.svelte:79](frontend/src/routes/bots/MatrixBotTab.svelte#L79) ```ts 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](frontend/src/routes/template-configs/+page.svelte#L228) ```ts .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](frontend/src/lib/components/EntitySelect.svelte#L18) [lib/components/IconGridSelect.svelte:16](frontend/src/lib/components/IconGridSelect.svelte#L16) [lib/components/MultiEntitySelect.svelte:16](frontend/src/lib/components/MultiEntitySelect.svelte#L16) ```ts 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](frontend/src/lib/components/EntitySelect.svelte#L20) ``` 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](frontend/src/lib/auth.svelte.ts#L54-L61) ```ts 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](frontend/src/routes/+layout.svelte#L336-L342) 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](frontend/src/routes/command-template-configs/+page.svelte#L208) 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](frontend/src/routes/+page.svelte#L268-L272) 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](frontend/src/routes/+page.svelte#L283-L287) ```ts const [statusRes, , chartRes] = await Promise.all([ api(`/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](frontend/src/routes/actions/+page.svelte#L78-L81) ```ts 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](frontend/src/routes/actions/RuleEditor.svelte#L105-L108) ```ts 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](frontend/src/routes/+layout.svelte#L145-L151) ```ts notification_trackers: filterById(notificationTrackersCache.items as any[]).length, ``` The cast exists because `filterById` 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 `` attr [lib/i18n/index.svelte.ts:31-36](frontend/src/lib/i18n/index.svelte.ts#L31-L36) Screen readers and browser translation extensions rely on ``. 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](frontend/src/lib/components/Modal.svelte#L43-L45) 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 `` 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](frontend/src/lib/components/TimezoneSelector.svelte#L33-L37) ```ts let tickHandle: ReturnType | 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](frontend/src/routes/settings/backup/+page.svelte#L269-L281) ```ts 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](frontend/src/lib/components/Modal.svelte#L62-L67) 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](frontend/src/lib/i18n/index.svelte.ts#L55-L62) ```ts 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](frontend/src/routes/notification-trackers/+page.svelte#L82-L84) [routes/command-trackers/+page.svelte:69-71](frontend/src/routes/command-trackers/+page.svelte#L69-L71) ```ts 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](frontend/src/routes/providers/+page.svelte#L160) 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](frontend/src/routes/users/+page.svelte#L62) [routes/command-trackers/+page.svelte:27](frontend/src/routes/command-trackers/+page.svelte#L27) [routes/providers/+page.svelte:179](frontend/src/routes/providers/+page.svelte#L179) [lib/providers/types.ts:120](frontend/src/lib/providers/types.ts#L120) 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](frontend/src/routes/+page.svelte#L475-L512) `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](frontend/src/routes/notification-trackers/TestMenu.svelte#L25) ```svelte
` 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](frontend/src/lib/components/EventChart.svelte#L46-L49) `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).