`) -> edit fields -> `Save changes` is disabled until the
computed diff is non-empty -> Save -> `PUT /api/settings` with only the changed
group -> the full tree is re-read plus a "Changes saved" toast.
**Explore / invalidate cache.** The only cache-facing surfaces are Settings >
Caching - the semantic-cache configuration form plus the Operations block below
its save footer (purge everything behind a confirm, or purge one entry by
pasting the exact request JSON) - and the Dashboard's two permanently empty
hit-rate cards. Status > Shared state additionally shows the response-cache mode
and this process's local entry count (`StatusView.tsx:597-608`). There is still
**no cache key browser, entry listing, or hit/miss statistics view**.
---
## 6. Interaction states
### 6.1 Loading
Skeletons over a bare spinner page, enforced structurally: `Spinner` has exactly
one importer, `button.tsx`, so it is unreachable except inside a button.
| Mechanism | Site |
| --- | --- |
| `Skeleton` / `TableSkeleton` / `TileSkeleton` / `PanelSkeleton` (composites `aria-hidden`) | `skeleton.tsx` |
| `DataTable loading` (one row of `TableSkeleton`) | `data-table.tsx:204-211` |
| `StatTile loading` / `ChartCard loading` | `stat-tile.tsx:15`, `ChartCard.tsx:105` |
| Settings `LoadingPanel` (four `Skeleton` bars) | `SettingsView.tsx:199-210` |
| `Button isLoading` (disables + prepends `Spinner`) | `button.tsx:36,57,70` |
| `ConfirmDialog pending` (disables Cancel, `isLoading` on Confirm) | `dialog.tsx:125,131` |
### 6.2 Empty
`EmptyState` (dashed block with icon / title / body / optional action);
`DataTable` empty row (default "No results."); `ChartCard` auto-empty ("No data
available" + optional note - an all-zero window never renders a misleading flat
line, and the toggle is hidden); `Combobox`/`FacetRail` "No matches.";
sidebar/palette "No matching views." The shared analytics empty string **"No
data available"** is used at five render sites.
### 6.3 Error
`Banner tone="error"` (`role="alert"`); `ErrorBanner({message})`; field error
text `text-destructive` with `id={id}-error`; `aria-invalid:border-destructive`
on every input; `PemTextarea` non-blocking `text-warning` hint; inline
`` (`ConfigPanel`, `CacheOpsPanel`);
Toast
`tone="error"`; the app-level 401 banner with a "Set token" action. Error
banners quote the gateway's `error.message` verbatim via `ApiError.message`.
### 6.4 Success / confirmation
`ToastProvider` + `useToast().success/error/info`; auto-dismiss 5000ms (6000ms
with an action); container `aria-live="polite"`, fixed bottom-right, tone-colored
icon only on `bg-popover` / `shadow-lg`. `CopyButton` shows a 2s "Copied" state;
`MaskedSecretCell` copy flips its `aria-label` to "Copied" with a `text-success`
check. `useToast()` returns a no-op API outside a provider so views render
standalone in tests.
### 6.5 Destructive confirmation
Every destructive action is `ConfirmDialog`-gated, and the body always names the
concrete blast radius (exact stored-entry counts on Logs; the last-key
governance warning on Virtual Keys; reference counts on Teams/Customers; exact
provider counts on Config import; a plaintext-secret warning on Config export;
priced-model counts on Pricing). `ConfirmDialog` defaults to `destructive={true}`
and its overlay click does not dismiss.
### 6.6 Form validation
There is no shared validation library; every form hand-rolls its checks. Two
patterns coexist:
- **Banner-at-the-bottom, submit-time, first-failure-wins** for the dialogs and
the two big forms: `AddProviderForm`, `AddCustomProviderForm`, `VKForm`,
`TeamDialog`, `CustomerDialog`.
- **Inline, live, per-field** for `PricingView` (every keystroke), `ConfigPanel`
import (every keystroke), `CacheOpsPanel` purge-one (live), and `BudgetField`
(Teams/Customers).
`ProviderConfigPanel` has **no field validation** - only live "at-risk" warn
banners; nothing blocks Save. Settings panels have no validation - Save is
disabled until the diff is non-empty. A notable gap: clearing a numeric settings
field to blank produces no diff, so a numeric setting can be changed but **not
unset** from the UI.
### 6.7 Honest-empty / advisory UI
Large parts of the analytics and logs surface are deliberately "honest empty":
they render explicit "not recorded yet" copy rather than hiding. Examples: the
Dashboard cache-hit-rate cards, Provider Latency, MCP usage, and User Rankings;
The Logs Type/Provider/Model/Tokens columns are **no longer** in this category -
decision-log item 56 wired the telemetry enrichment that populates them, and the
Models/Provider/Type facet groups became live filters. Those cells still render
`N/A` on a row that carries no value (a health probe has no model), which is
row-level honesty rather than a missing data source; the Cost facet group says
"No filter yet" instead of "Not recorded yet" because cost IS recorded per entry
and only the range control is unbuilt. Remaining honest-empty examples:
the 10 honest-empty Logs facet groups; the Providers "Weight" column (hardcoded
`1`); the
Add Custom Provider request-type grid (advisory, never sent); the provider-config
"Proxy Type" (advisory); the Settings "Code Mode Binding Level" (preview-only,
never persisted). No UI is gated on a build-time flag or a browser-read env var;
every "off" state is either a server response (404, `tracked:false`, empty list)
or hardcoded.
---
## 7. Theming
**Light and dark are both fully supported, and a user-facing theme switcher DOES
exist** - it is the second button in the sidebar footer (an icon button labelled
`Switch to {light|dark} theme`, rendering lucide `Moon` when dark and `Sun` when
light, `Sidebar.tsx:308-322`).
| Step | Evidence |
| --- | --- |
| Default class on the document | `index.html:2` - `` |
| Pre-paint resolution (blocking inline script) | `index.html:7-22`: reads `localStorage["frosty.theme"]`; if unset, `dark = !matchMedia("(prefers-color-scheme: light)").matches` (dark unless the OS explicitly asks for light; `no-preference` stays dark); toggles `.dark` and sets `documentElement.dataset.theme`, all in try/catch |
| React state seeded from the DOM | `App.tsx:168-170` |
| Toggle handler | `App.tsx:234-247` - flips state, toggles `.dark`, sets `dataset.theme`, persists to `localStorage` in try/catch |
| CSS mechanism | Light values are the `:root` base; dark is a `.dark` override; `color-scheme` per theme so native widgets follow |
| `data-theme` attribute | set on `` but **no CSS keys off `[data-theme]`** - it is informational/hook-only; the `.dark` class is the sole styling switch |
Token mapping across themes: 38 color tokens + 3 shadow tokens are re-declared
under `.dark`; every other token (typography, spacing, radius, motion, z-index,
control metrics) is theme-independent. Light->dark inverts luminance while
holding hue (neutrals stay hue 265; semantic hues stay red 25 / green 155 /
amber 70->82 / blue 250-255 / violet 300). `--primary` is the clearest
inversion (near-black in light, near-white in dark). `--sidebar` is always the
recessed surface. `--sidebar-primary-foreground` is the only color token with an
identical value in both themes. Alpha-composited surfaces (`bg-/16`, etc.)
re-resolve automatically because they composite the theme-current token.
**There is no "system / auto" tri-state.** Once the user clicks the toggle the
stored value wins forever; there is no "follow system" option and no `matchMedia`
change listener after boot. The theme key `frosty.theme` lives in localStorage
(persists across tabs), whereas the admin token `frosty.admin-token` lives in
sessionStorage (dies with the tab) and the sidebar collapse state
`frosty.sidebar` in localStorage.
---
## 8. Accessibility
### 8.1 Implemented global measures
| Measure | Evidence |
| --- | --- |
| Visible focus ring on every focusable element: `2px solid var(--ring)` at 2px offset | `index.css:29-32` |
| 44px minimum hit-area helper `.hit-target` (a centred `max(100%, var(--tap-target))` pseudo-element); 34 applications across 24 files | `index.css:77-90` |
| Skip link `Skip to content` -> `` | `App.tsx:258,308-311` |
| Heading focus on nav change: `#main h2` is focused on every view change except first mount | `App.tsx:186-195`, `page-header.tsx:26-32` |
| `lang="en"` + viewport meta | `index.html:2,5` |
| Reduced motion honoured globally (tokens to 0, keyframes off, spinner `motion-reduce:animate-none`, `reduceMotion()` guard) | `tokens.css:250-258`, `index.css:124-129`, `spinner.tsx:11`, `LogsTable.tsx:189` |
| Semantic landmarks: ``, `