# 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<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](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<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](frontend/src/routes/settings/backup/+page.svelte#L117-L139)

```ts
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](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<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](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<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](frontend/src/lib/i18n/index.svelte.ts#L31-L36)

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](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
`<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](frontend/src/lib/components/TimezoneSelector.svelte#L33-L37)

```ts
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](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
<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](frontend/src/routes/+page.svelte#L290-L299)

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](frontend/src/app.html#L12)

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](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).
