๐งญ Toolbar & Responsive Layout
PageToolbar is the shared shell used by all 6 main pages (Dashboard, Broker Detail, Assets list/detail, FX list/detail) to lay out filters + summary + actions + tabs in a single, container-width-driven responsive bar โ instead of each page re-implementing its own breakpoint logic.
graph TD
RL["responsiveLayout.svelte.ts<br/><small>ResizeObserver ยท thresholds โ layoutMode</small>"] --> PT["PageToolbar.svelte<br/><small>Shell: Filters ยท Summary ยท Actions ยท Tabs</small>"]
PT --> DRP["DateRangePicker<br/><small>align=start, badge jolly fill</small>"]
PT --> TB["TabBar<br/><small>fillWidth, shrinkLabelsToFit</small>"]
PT --> LS["labelShrink.ts<br/><small>Linear font auto-shrink</small>"]
PT -.->|used by| DASH["Dashboard"]
PT -.->|used by| BD["Broker Detail"]
PT -.->|used by| AL["Assets List/Detail"]
PT -.->|used by| FX["FX List/Detail"]
style RL fill:#e3f2fd,stroke:#1565c0
style PT fill:#e8f5e9,stroke:#2e7d32
style DRP fill:#e3f2fd,stroke:#1565c0
style TB fill:#e3f2fd,stroke:#1565c0
style LS fill:#fff3e0,stroke:#e65100
Key files: lib/utils/layout/responsiveLayout.svelte.ts (thresholds + layoutMode), lib/components/ui/toolbar/PageToolbar.svelte (shell), lib/components/ui/date/DateRangePicker.svelte (date picker + preset "jolly" badges), lib/utils/layout/labelShrink.ts (shared linear label auto-shrink), lib/utils/layout/dropdownPosition.ts (shared fixed-position dropdown helper).
๐ฏ The 4 layout tiers
PageToolbar doesn't use CSS media queries โ it uses a ResizeObserver on its own container and a set of pixel thresholds configured per page. The resulting layoutMode always matches the name of the threshold that triggered it (no separate vocabulary to keep in sync):
layoutMode |
Triggered when | Picker + Summary ("Centro") | Actions |
|---|---|---|---|
oneRow |
width โฅ oneRow |
side-by-side, picker renders as 1 row (full preset badges) | side-by-side, 2ร2 grid |
denseRow |
between denseRow and oneRow |
side-by-side, picker renders as 2 internal rows | side-by-side, 2ร2 grid |
stackFilters |
between stackFilters and denseRow |
stacked below the picker (start-aligned, width capped to the picker's own width) | still beside the stacked column, but as a 4ร1 vertical list |
oneColumn |
width < stackFilters |
same as stackFilters (no further change) |
move below everything, back to a 2ร2 grid |
A page reads 3 semantic flags instead of comparing layoutMode strings directly (so a future redefinition only touches PageToolbar itself):
filtersStackedโ true instackFilters+oneColumn. The flag pages should read for "my Center content must become full-width/justified".isStackedโ true ONLY inoneColumn(the whole bar, Actions included, is one column).actionsStackedโ true ONLY instackFilters(Actions render as a 4ร1 column instead of 2ร2).
The oneColumn threshold itself has no effect
Once width drops below stackFilters, the tier is always 'oneColumn' โ there's no narrower tier below it, so the numeric value of the oneColumn threshold is never actually compared. It's kept as a reserved placeholder for a possible future sub-tier, conventionally set to stackFilters โ 40 on every page. The only threshold that actually decides the stackFilters โ oneColumn boundary is stackFilters itself.
๐ง Thresholds โ current values per page
Each page passes its own thresholds object to <PageToolbar> โ there's no shared default, every page is tuned independently against its own content:
| Page | oneRow |
denseRow |
stackFilters |
oneColumn |
labelHideActions |
labelHideTabs |
|---|---|---|---|---|---|---|
| Dashboard | 1000 | 810 | 430 | 390 | 210 | 370 |
| Broker Detail | 1000 | 800 | 470 | 430 | 270 | 370 |
| Assets (list) | 1340 | 850 | 440 | 400 | 250 | 370 |
| Assets (detail) | 1215 | 780 | 400 | 360 | 230 | 370 |
| FX (list) | 1120 | 930 | 440 | 400 | 260 | 370 |
| FX (detail) | 870 | 650 | 400 | 360 | 230 | 370 |
labelHideActions/labelHideTabsโ independent thresholds below which Action-button labels / Tab labels disappear entirely (see Auto-shrink labels below โ hiding is always the last resort, not the first reaction to limited space).noExtraLabel(optional, Dashboard only) โ hides a page-specific decorative label (Dashboard's "Currency:" prefix next to the currency selector) below a given width, independently of the tier system.
Tuning thresholds live, no rebuild needed
Every page registers its layout instance on window.__lfLayouts.<name> (open DevTools console):
// Available page names: dashboard, brokerDetail, assetsList, assetDetail, fxList, fxDetail
window.__lfLayouts.dashboard.thresholds
// { oneRow: 1000, denseRow: 810, stackFilters: 430, oneColumn: 390, ... }
// Change a threshold โ recalculates immediately, no resize needed
window.__lfLayouts.dashboard.thresholds.stackFilters = 480
// Read the resulting mode
window.__lfLayouts.dashboard.layoutMode
Widths are compared in content-box (the bar's own interior, excluding its p-4 padding) โ getBoundingClientRect() in DevTools returns border-box, so subtract ~32px if reading it directly.
๐ก Auto-shrink labels before they hide
Some languages render the same UI string much longer than others (e.g. French "Vue d'ensemble" vs. shorter equivalents). Instead of hiding a label the moment it doesn't fit, labelShrink.ts computes a continuous linear scale factor (not a handful of fixed discrete sizes) from real measured geometry and shrinks every label in a group uniformly (down to a 75% floor for readability) โ labels only disappear entirely once the independent labelHideActions/labelHideTabs threshold is crossed.
Used by both TabBar.svelte (own shrinkLabelsToFit prop) and PageToolbar.svelte's Actions zone (generic โ reads button > span labels directly, no per-page wiring required).
๐ "Justified" Center alignment
When filtersStacked, a page's Center content (currency selector, search/filters, price summary, etc.) stacks below the DateRangePicker โ start-aligned and capped to the picker's own current width (via the picker's bindable effectiveMaxWidth), never centered and never wider than the picker itself:
<DateRangePicker align="start" {layoutMode} bind:effectiveMaxWidth={pickerMaxWidth} />
<div class="{filtersStacked ? 'w-full' : ''}" style={filtersStacked && pickerMaxWidth ? `max-width: ${pickerMaxWidth}px` : ''}>
<!-- Center content -->
</div>
Any row with more than one element in this capped zone uses justify-around as the universal standard (never justify-between/justify-center) โ for 2 elements this distributes free space exactly 1/4 : 2/4 : 1/4 (start : middle : end), avoiding the oversized gap justify-between leaves between small, unrelated controls.
A label and its own control are ONE unit, not two
If a row has a decorative label next to its own input/select (e.g. "Currency: [selector]"), wrap both together in a shared container before applying justify-around to the row โ otherwise the label and the control get spaced apart as if they were two independent items.
๐ Extending the system to a new page
- Use
<PageToolbar thresholds={{oneRow, denseRow, stackFilters, oneColumn, labelHideActions, labelHideTabs}} layoutDebugName="uniqueName">. Thefilterszone receives{layoutMode, isStacked, filtersStacked, showExtraLabels},summaryreceives{layoutMode, isStacked, filtersStacked},actionsreceives{layoutMode, showActionLabels, actionsStacked}. - Pass
align="start"+layoutMode={layoutMode}+ the samedebugNametoDateRangePickerโ withoutlayoutModethe picker always stays 1-row, ignoring the page's thresholds. Never wrap it in an extra<div class="flex flex-1 ...">with no width cap of its own (DateRangePicker's root already self-appliesgrow+max-widthwhenalign="start") โ an uncapped wrapper grows past the picker's real content and misaligns any sibling zone. If adata-testidis needed on the wrapper, useclass="contents"(exits the box model, keeps the attribute queryable). - Give any other element sharing the
filtersrow with the picker (currency selector, search box, etc.)shrink-0โ only theDateRangePickeritself should shrink/shed badges under pressure. - If a hand-rolled dropdown panel is needed, reuse
getFixedDropdownPosition()/clamp()fromlib/utils/layout/dropdownPosition.ts(fixed positioning, escapesPageToolbar's ownoverflow-hiddencard wrapper) rather than plainposition: absolute.
See also: Date Pickers for DateRangePicker's own preset/badge-fill mechanics.