📱 App & UI State
Status: Implemented (Feb 2026)
The App & UI State category contains stores responsible for the global user experience. These stores manage authentication, global preferences, localization, and shell navigation.
🗂️ Stores
| Store | File | Purpose |
|---|---|---|
| Auth | app/auth.ts |
Manages login status, JWT tokens, and the current user object. |
| Settings | app/settings.ts |
User-specific preferences (language, theme, base currency, avatar). Syncs with the backend. |
| Global Settings | app/globalSettings.ts |
Application-wide settings (admin-only writes), like max upload sizes. |
| Language | app/language.ts |
The interface language. currentLanguage.set() also sets the svelte-i18n locale and saves the choice in this browser (librefolio-locale), which the next load starts from. Nothing listens to the Settings store: the language is set explicitly — by the auth store at login, from the user's settings in the login response, and by the UI: the Preferences tab and the Welcome page after their write, or the header selector (this browser only). |
| Interface locale | app/uiLocale.ts |
The locale every figure is written in: the interface language, plus the browser's region when the browser's primary tag is in that language. Derived from Language, so it follows every switch. Two faces: uiLocale() for runes code and as the default of every figure formatter; uiLocaleStore, read as $uiLocaleStore in a legacy template. See Numbers, amounts and dates. |
| Theme | app/themeStore.ts |
Manages dark/light mode toggling and system-preference detection. |
| Privacy | app/privacyStore.svelte.ts |
Device-local "hide amounts" flag, read inside the currency formatters. See Privacy masking. |
| Navigation | app/navigationStore.ts |
Controls the sidebar state (open/closed) and active page tracking. |
| Toasts | app/toastStore.svelte.ts |
Svelte 5 Rune-based store for displaying global notification messages. |
| Chart Settings | chartSettingsStore.svelte.ts |
Chart preferences of the FX and Assets pages: line colouring, area fill, grid lines, stale-data gradient, axis scales, the asset Calendar Return window and overlay signals. Kept per user in localStorage (lf_{userId}_chartSettingsStore), never on the server, in two separate scopes, fx and assets: each has its own global settings plus per-item overrides (pair slug, asset-{id}), and applying a scope's global settings clears that scope's per-item overrides. The chart period is not stored here: it lives in sessionStorage (Date Range, below). |
| Date Range | dateRangeStore.svelte.ts |
The date range (start, end) shared by the Dashboard (through dateRangeController.svelte.ts), the broker detail page, and the Assets and FX pages, list and detail. It lives in sessionStorage (librefolio_dateRange), so it belongs to the browser tab and survives a reload; on a full page load, start and end in the URL win, and it falls back to the last three months when the account changes. |
📐 Architecture & Flow
The following diagram shows how the stores are filled and who sets the language and the theme. Once the auth check succeeds, the (app) layout's appBootstrap.load() loads the stores in parallel (see Onboarding Guides); nothing listens to the settings store, so language and theme are set explicitly (items 2–3 below). Writes are dotted and go through the components (write first, item 4 below): a component sends the change to the API and, once it succeeds, copies the saved values into the store with setDirect(). No store writes to the API itself — their updateSetting() methods have no callers.
---
config:
layout: elk
---
graph TD
API["🐍 Backend API"]
Auth["🔐 authStore (Manages Login)"]
Boot["🚪 (app)/+layout.svelte<br>appBootstrap.load(), in parallel"]
Settings["⚙️ settingsStore (User Preferences)"]
Onboarding["🧭 onboarding (Guide Progress)"]
Global["🌍 globalSettingsStore (App Config)"]
Lang["🗣️ languageStore"]
Theme["🎨 themeStore"]
UI["💻 Frontend UI Components"]
API -->|Session / User Data| Auth
Auth -->|"checkAuth() succeeded"| Boot
Boot -->|"userSettings.load()"| Settings
Boot -->|"onboarding.load(onboardingApi)"| Onboarding
Boot -->|"globalSettings.load()"| Global
Auth -->|"Login: setDirect(user_settings)"| Settings
Auth -->|"Login: currentLanguage.set()"| Lang
Auth -->|"Login: applyTheme()"| Theme
UI -.->|"PUT /settings/user<br>first Welcome: POST /settings/onboarding/welcome/complete"| API
UI -.->|"PATCH /settings/global/bulk (admin)"| API
UI -.->|"setDirect() once the write succeeds"| Settings
UI -.->|"setDirect() once the write succeeds"| Global
UI -->|"currentLanguage.set(): after the write<br>(Preferences, Welcome) or header button"| Lang
UI -->|"applyTheme(): after the write<br>(Preferences) or header button"| Theme
Lang -->|Updates text| UI
Theme -->|Updates CSS vars| UI
Global -->|Enforces limits| UI
🔐 Authentication Flow
- Mount: On app load,
auth.checkAuth()verifies the existing cookie with the backend. - Bootstrap: If authenticated, the
(app)layout runsappBootstrap.load(), which loads the user settings (userSettings.load()), the onboarding progress (onboarding.load(onboardingApi)) and the global settings (globalSettings.load()) in parallel. The auth store loads nothing: at login it copies the login response'suser_settingsinto the store withsetDirect(), and applies their language and theme (item 3). - Language and theme: nothing listens to the settings store; they are set explicitly — by the auth store at login, from the same response (
currentLanguage.set(),applyTheme()); by the Preferences tab after its write; by the Welcome page after Continue (the language only: Welcome leaves the theme alone); and, for this browser only, by the header buttons (item 4). - Write first: no settings screen updates the store before the server answers. The Preferences tab sends
PUT /api/v1/settings/user(one field per request) and, only once it succeeds, applies the language or theme and callsuserSettings.setDirect(); a failed write leaves the store as it was and shows an error toast. The Profile tab (avatar) and the Welcome page also write first; Welcome only previews a new language (the svelte-i18nlocale) until Continue saves it.setDirect()itself never calls the API: it sets the store and its per-userlocalStoragecache (lf_{userId}_user_settings). The header's theme and language buttons change only this browser (librefolio-theme,librefolio-locale) and save nothing to the account. - After a reload: language and theme come from this browser's
librefolio-localeandlibrefolio-theme, so a change made on another device shows here only at the next login.
🙈 Privacy masking
The header's eye button (lib/components/ui/PrivacyToggle.svelte) flips a single flag. Everything
else is decided where a number becomes a string, so this section is the contract for any code that
renders money. What the user sees is described in the Privacy mode section of
User Preferences. Paths are relative to
frontend/src/ unless they start with backend/.
🗄️ The store
lib/stores/app/privacyStore.svelte.ts holds one rune, hydrated synchronously at module init
from localStorage['librefolio-privacy']: the currency formatters import the store, so it is
initialized before the first amount is formatted. Only the exact value '1' means on; an absent key
means off, so nothing is masked by default.
- Device-local, bare key. The preference describes the screen being watched, not the account,
so the key is not
lf_{userId}_…. With a per-user key, switching account in front of a projector would adopt the other account's "off" and bring every value back. - Two guards.
browseranswers whetherlocalStorageexists;try/catchanswers whether the call can fail while existing (private modes, exceeded quota, denied storage). After a refused writeisPrivacyPersisted()returnsfalse: this session keeps masking, the next load will not. No component reads it today. - No cross-tab sync. There is no
storagelistener: another open tab follows on reload.
| Export | Use |
|---|---|
isPrivacyEnabled() |
Read the flag. Called inside a runes template or a $derived, the read registers the dependency — no subscription needed. |
setPrivacyEnabled(value), togglePrivacy() |
Write the flag and try to persist it. |
isPrivacyPersisted() |
false once a write has failed. |
🧰 The masking channel
Masking is decided at formatter level: before the string exists, the formatter hands the caller
either the figure or PRIVACY_PLACEHOLDER (•••). Where the resulting node lands is then
irrelevant — a masked string is as safe in a table cell as in an ECharts tooltip or a title
attribute. The placeholder is a constant and carries nothing derived from the value: no magnitude,
no digit count, no sign.
| Primitive | File | Use it for |
|---|---|---|
formatCurrencyAmountPlain, formatCurrencyAmountHtml |
lib/utils/currency/currencyFormat.ts |
Every money amount — the D8 primitives. Option sensitivity: 'personal' or 'public'; omitted means personal, i.e. masked. |
maskable(formatted, sensitivity?) |
lib/utils/privacy/maskable.ts |
A formatted amount without its sign, replaced whole. |
maskCurrencyParts(parts, sensitivity?) |
lib/utils/privacy/maskable.ts |
An Intl.NumberFormat with style: 'currency': pass its formatToParts(…). |
maskFormattedNumber(formatted, sensitivity?) |
lib/utils/privacy/maskable.ts |
A number with no currency, such as an axis tick. |
maskableQuantity(formatted) |
lib/utils/privacy/maskable.ts |
A quantity the user holds, where it sits next to a price. |
shouldMaskAmount(sensitivity?) |
lib/utils/privacy/maskable.ts |
The decision itself; public returns false before reading the store. |
CurrencyAmount.svelte |
lib/components/ui/display/ |
One D8 amount rendered from a legacy parent (below). |
The rules the primitives carry:
personalorpublic.personalis wealth: values, cash, P&L, proceeds, fees, taxes.publicis a number that does not let anyone infer what the user owns — market and unit prices, WAC, FX rates, the amounts of asset-level events (product owner, 2026-09-22). The default ispersonalon purpose: a public value left unclassified disappears, which the user reports; a personal value left in the clear by the opposite default would leak, which nobody sees.- The currency stays. The D8 primitives mask the absolute value and append symbol, flag and code
afterwards:
-••• € 🇪🇺 EUR. A formatter that letsIntlplace the currency cannot append it, somaskCurrencyPartsworks on the parts: the run from the first to the last magnitude part (integer,group,decimal,fraction,compact, the exponent parts,nan,infinity), inner literals included, becomes one•••, whilecurrency, the sign and the outer literals stay —-$1,234.50gives-$•••, a compact1,2 Mio. €gives••• €.compactis inside the run on purpose:€•••Kwould still disclose the order of magnitude. Known limit: inpt-CVICU types the escudo sign$as adecimalpart (and thecurrencypart is a zero-width space), so the masked string loses the only visible symbol. - The sign stays (D8). The D8 primitives write
+or-outsidemaskable.maskFormattedNumberandmaskableQuantitykeep the leading sign run exactly as the locale wrote it (/^[\p{Cf}+\-\u2212]*/u: U+2212 insv-SE, a bidi mark before the minus in Arabic) rather than rebuilding an ASCII hyphen, which would change the unmasked output too. The cost is stated in themaskabledocstring: where a site passesshowSign: value !== 0, a prefix appears exactly when the amount is non-zero, so a masked column still shows which rows are zero. The product owner ruled with that objection on the table; do not move the sign back inside the mask. - Absence is the caller's job. Check for a missing, non-finite or unavailable value first and
render
—: masking an absence would claim a figure that does not exist, and an em-dash handed tomaskFormattedNumbercomes back masked. - A quantity's class is decided at the call site (D5′). The same number is masked in positions
and lots, where quantity × public price rebuilds what the user owns, and stays visible in the
Transactions list. So the rule cannot live in a shared quantity formatter:
maskableQuantityis a separate, searchable name with nosensitivity, and a site that must stay visible —formatTxQuantityinlib/components/transactions/shared/txDisplayHelpers.ts— simply does not call it. Its callers today areExposureTable.svelte,unifiedLotsTableHelpers.ts,LotCustodyModal.svelte,LotGanttChart.svelteandLotWacPriceChart.svelte. Units and tickers stay outside the argument, as the currency does for money; a partially closed lot keeps its open share (formatLotQuantityCellrenders••• (60%)).
🚧 The anti-regression gate
lib/utils/privacy/moneyRenderSites.test.ts tests a premise, not behaviour: that the places
rendering money are the places we know. It scans every .ts and .svelte file under
frontend/src/ — test files and the node_modules, __mocks__ and __tests__ folders excluded —
line by line for two forms:
- Form A —
style: 'currency', exact; - Form B — a template literal interpolating a currency token (
currencyorsymbol) beside a numeric-looking one (toFixed,toLocaleString,amount,price,total, …), a heuristic.
Every hit must be registered in REGISTRY, keyed by file and whitespace-collapsed content rather
than by line number, so an unrelated edit above a site does not turn the gate red. Each entry
carries a status and a written reason:
| Status | Meaning |
|---|---|
masked |
Goes through the channel, at the site or at its function boundary. |
not-money |
Matches a form but renders no amount. |
public |
Real money in the clear by rule: a market price, an asset-level event, a rate. |
residual |
Real money outside the channel, deliberately not fixed yet. |
unmasked |
Real money outside the channel, found by the gate and not yet triaged. |
The test fails on an unregistered hit and on an entry whose code no longer exists. It asserts the
residual, unmasked and public lists by exact content, so fixing or reclassifying a site is a
visible edit, and it carries a positive control — hits ≥ entries, both forms found, the risk helper
found — because "no unregistered site" is also true of a scanner that reads nothing. It runs in
./dev.py test front-utility core-unit.
A line that matches SAFE_CALL — it names a D8 primitive, a currency-code formatter or the risk
helpers' formatCurrencyAmount / formatScopedCurrencyAmount, or calls maskable( or
maskFormattedNumber( — is not scanned for Form B. The skip is line-granular: a masked call on
a line also hides an unmasked fallback written on the same line. Entry to SAFE_CALL has a price:
the name must be an export pinned by unit tests that would break if its masking were removed.
What the gate cannot see:
- Money without a currency token — axis ticks and labels, such as the value axis of the lot
comparison chart (
formatAxisAmountinlib/components/brokers/lots/lotComparisonChartHelpers.ts). Only their own tests protect them. - A
publicmarking.{sensitivity: 'public'}sits on aSAFE_CALLline and is never a hit, and apublicentry is not re-examined. Only the tests of that site protect the decision — for the Positions price and average cost, the D5′ block ofExposureTable.test.ts. - Reactivity. A site can be registered, masked and green and still not follow the toggle: see the legacy freeze below.
🧊 Legacy components freeze masked output (R20)
In a legacy (non-runes) component, a function call inside a template expression is compiled
into $.untrack(…), and only the values the expression names are tracked. The privacy flag, read
inside the formatter, never becomes a dependency, so the output keeps the state it had at mount:
turned on in place, privacy leaves the digits visible; turned off, ••• stays. The same
{@html formatCurrencyAmountHtml(amount, code)}, compiled with the Svelte in
frontend/node_modules (5.48.0) — excerpt:
// legacy parent: export let amount; export let code;
$.deep_read_state(amount()),
$.deep_read_state(code()),
$.untrack(() => formatCurrencyAmountHtml(amount(), code()))
// runes parent: let {amount, code} = $props();
$.html(node, () => formatCurrencyAmountHtml($$props.amount, $$props.code));
Neither the gate, which reads source text, nor a formatter unit test, which calls the formatter fresh, can see this. The remedy:
- render the amount through a runes child —
lib/components/ui/display/CurrencyAmount.svelterenders one D8 amount — or migrate the parent to runes when it is small; - cover the site with a component test that toggles privacy in place, in both directions,
including a mount with privacy already on:
lib/components/brokers/BrokerCard.test.ts, andlib/components/ui/display/CurrencyAmount.test.ts, which mounts the child under__tests__/harness/CurrencyAmountLegacyHost.svelte. That host is legacy by construction (export let) and also renders the same call inline, as a control that must stay frozen.
Today the legacy parents that render money are lib/components/brokers/BrokerCard.svelte and
routes/(app)/brokers/[id]/+page.svelte; both render it through CurrencyAmount.
📈 ECharts formatters
ECharts calls formatter and renderItem callbacks itself, outside Svelte's effect tracking. They
read the current flag whenever they run, but the read registers nothing, so a toggle does not redraw
what is already painted: an axis keeps its ticks, a bar keeps its label, and an open tooltip keeps
its text until it is shown again. The render $effect must read the flag itself —
void isPrivacyEnabled();, beside its other void dependencies — so that a toggle re-runs it and
the option is rebuilt with every formatter called again:
lib/components/brokers/lots/LotComparisonChart.svelte— axis formatters; each render replacesxAxis,yAxisandseries(replaceMerge);lib/components/brokers/lots/LotGanttChart.svelte— the lot quantity on each bar is built inrenderItem.
The interface locale follows the same pattern: both effects also read void uiLocale();, so a language
switch redraws the chart as a toggle does (see Numbers, amounts and dates).
Keep the formatter a pure exported helper, as formatAxisAmount and formatAxisCurrency in
lotComparisonChartHelpers.ts are, so a unit test can pin it; the effect wiring itself is only
exercised by a test that mounts the chart.
🧩 Contract for Tool renderers
A Tool's compiled renderer (see Tool plugins) presents results the backend computed from the user's own scenario, so it renders through the same channel:
- money only through the D8 primitives, with an explicit
sensitivityon every call, so the classification is read at the site rather than implied by the default; - quantities through
maskableQuantitywhere they are holdings; - rates and percentages marked
publicexplicitly wherever they pass through a masking primitive; —for an empty, non-finite or unavailable value — checked before formatting, never masked;- runes mode for the component, and the ECharts rule above for any chart it draws.
The PAC allocator renders through lib/features/tools/pac-allocator/planner/format.ts, its only privacy
adapter: every amount, quantity, price, rate and percentage the planner shows goes through it.
An exact_ratio number — the ExactRatio branch of backend/app/schemas/pac_allocator.py, whose
authoritative value is its reduced numerator and denominator — is shown through the backend's
display_decimal, a projection the schema marks non_authoritative, with ≈ in front unless that
projection equals the ratio; an amount is rounded exactly from the ratio to its currency's minor unit
instead, ≈ marking a rounding that changed it.