Skip to content

fix(accounts): 🐛 polish the multi-account UI across items, switcher and sign-in - #59

Merged
8liam merged 115 commits into
mainfrom
fix/multi-account-ui-issues
Nov 8, 2026
Merged

8liam merged 115 commits into
mainfrom
fix/multi-account-ui-issues

Conversation

@8liam

@8liam 8liam commented Nov 8, 2026 •

Copy link
Copy Markdown
Owner

Fixes the multi-account UI issues from the two independent reviews: stale data and filters across account switches, overlapping item rows, the re-auth dialog that never closed, an ambiguous account switcher, and assorted a11y and convention gaps.

What changed

HIGH

  1. Items filters and scope. Pages remount when the sidebar account changes, so filters, selection and drafts start fresh. A stale filter value falls back to "all". Filtered-empty states now offer Clear filters. The in-page filter is renamed Filter by account / "Any account", so its accessible name no longer matches the switcher's ("Account").
  2. Selection. The selection is derived from visible rows. A row hidden by a filter, search or scope change can no longer be moved, and the header checkbox and count agree with what's on screen.
  3. Row overlap. Highlight chips and stickers share one line and collapse into +N (the hidden reasons are announced to screen readers). Stickers with no image are skipped instead of showing a broken glyph. Chip text is 11px with stronger contrast, and narrow table rows are taller.
  4. Mobile filters. Below sm, the controls use a two-column grid with full-width search, and the toggles get their own row.
  5. Cross-account data. useRemoteData keeps stale data only within the same account= scope. On a scope change it returns loading unless the new path is cached.
  6. Organiser. Rules are stored per SteamID (openinventory:organiser-rules:<steamId>). Legacy global rules are adopted only by the account whose units they all target. The run confirmation names the account.
  7. Add / re-auth dialog. The server now publishes lastLogin (sequence, steamId, added), so re-signing an existing account closes the dialog. A banner then reports "Added …" or "Signed in to … again", with View … to switch to it. The dialog title reads "Sign in to again". Cancel is posted only while a sign-in is still pending, and the card-in-card layout is flattened.
  8. Account switcher. Every option shows a status dot and status text, its label, and the SteamID tail (which never truncates, including in the trigger). The list is wider. Destructive confirmations use name (…0001). The trigger's status is read through aria-describedby.

MEDIUM

  1. Errored settings rows get Reconnect and Sign in again. Remove is an outline destructive button. Label saves show a pending state. Focus moves to Add account once a removed row disappears.
  2. Storage, Organiser, Trade-up, Cases and Terminals show an Account: chip when more than one account is tracked. Remove, organiser and Terminal-activation confirmations name the account. accountText uses the label copy instead of the raw key.
  3. The partial-connection notice is now a slim banner that names the disconnected accounts. It offers Reconnect / Sign in again when exactly one errored account is waiting, plus Open settings, and announces itself through a polite live region. The "Choose an account" buttons carry status dots.
  4. Dashboard and Settings refresh use Promise.allSettled over connected accounts only. They show a busy state, report each failure by account, and label their scope ("Covers: …").
  5. A first load that fails now shows Can't reach the server or This page doesn't match the server with Retry or Reload (the EventSource is recreated), instead of an endless skeleton. Failed page loads show a retryable LoadError. Storage checks error before its empty states.
  6. Highlights: tierless highlights get a marker in the Highlights column. Naming is unified ("Highlights: best first" in grid). The highlights-only empty state says "No highlights match these filters" and offers a reset. The help now explains the Float, Pattern and Sticker craft signals.
  7. A new light-theme-safe --accent-strong token (#3f8f17 on light) fixes the invisible gem. The Highlights toggle and the help button are bordered Buttons with a clear pressed state.
  8. Trade-up finder: controls and results are extracted into component folders. It uses the shared Select, keeps its copy in config, colours profit with text-success, shows — when the float is unknown, and shows a skeleton while searching. One TRADE_UP_STATTRAK_FILTER list serves both client and server.
  9. A11y: the dialogs have aria-labelledby / aria-describedby. A shared AccountStatusDot always pairs the dot with text. AccountStatus uses role=alert on the error text only. TiersHelp is a disclosure clamped to the viewport. The add button is 36px.

Conventions: AccountRow moved into its own folder with .types.ts and .config.ts. SyncErrorBanner and TransferBanner prop types moved into .types.ts. account-gate and account-status gained .config.ts. The items-page constants moved to lib/items/items-page.config.ts. authDotClass moved into account-status-dot.config.ts.

Screenshots

All shots come from the mock API (3 accounts: 2 connected, 1 with a sign-in error; 6 items) at 1400×860 or 390×844.

Before After
Account switcher open: names truncated, no status
Status dot + text, SteamID tail, wider list
Items, all accounts: vague amber notice, broken sticker glyph, row 3 overlaps
Named banner with fixes, one-line chips, tierless marker
Stale filter after switching (filter = tradebot, then switch to bossman): empty page
Filters reset for the new account
Single account rows: chips overlap the next row
Chips on one line
Hidden selection (select an AK, then filter Type = Container): move bar still offers to move it
Hidden rows aren't selected
Mobile items: Location/Type shrink to stubs, rows overlap
Two-column grid, toggles on their own row
Settings with an errored account: no fix, solid red Remove per row
Reconnect / Sign in again, outline Remove, SteamID tail
Re-auth dialog: titled "Add a Steam account", card in a card
"Sign in to … again", flat layout
Add dialog
Remove confirmation: same-named accounts ambiguous
Names the SteamID tail
Trade-up: native select, bare text
Shared Select, account chip
Light theme, Highlights only: gem invisible on white
Dark accent, tinted pressed state
Grid view (before shot is from the earlier review run)
One chip plus +N per card

After only

The re-auth flow (14, 15) and finder results (16) come from an extended copy of the mock that simulates a QR sign-in and returns sample contracts. The shared mock can't drive a login and has no finder results. Shot 17 is a dev server pointed at a dead backend.

Re-auth: QR pending
Re-auth finished: dialog closed, outcome banner
Trade-up finder results: text-success profit, — float
Server unreachable on first load
Filtered empty with Clear filters
Choose an account, with status dots
Organiser, with account chip
Mobile grid
Mobile help, clamped to the viewport
Mobile storage / trade-up (header regression check)

Verification

  • pnpm build (tsc -b + vite build): passes.
  • pnpm lint: exits 0. The 4 warnings were all there before this PR (reveal refs, react-virtual incompatible-library ×2, triple-slash in steam.real).
  • pnpm test: 191/191 pass.
  • Every commit up to the screenshots commit type-checks on its own (rebase --exec "tsc -b").
  • site/ (marketing demo): pnpm build and pnpm typecheck pass with only site/ dependencies installed. The first push broke the Vercel build, which the last two commits fix: the demo needed a useAppSnapshot stub, and the naming helpers moved out of the zustand-backed hooks.

Caveats

  • The account chip and the account name in confirmation titles only appear when more than one account is tracked.
  • Selection is derived from visible rows, not cleared. A pick hidden by a filter comes back if that filter is cleared.
  • Legacy global organiser rules are adopted only by an account whose units match every rule target. Otherwise they are left untouched.
  • lastLogin has no server unit test. The real Steam QR flow, and focus return after a real account removal, were checked in code and against the mock only.
  • On a 390px phone, a row with three reasons shows a truncated first chip plus +2; the full list is in the title and screen-reader text.

Round 2

Fixes the 16 remaining issues from the re-review. Commits run from 4098b37 to 9d9d8a4, one per issue where possible.

HIGH

  1. Items table at 768–1150px. The wide table now starts at the 960px Electron minimum. From 768 to 959 the compact rows take over, and they include the owner line and the highlight button. Wide columns get minmax() floors (price 96px, float 64px, highlights 76px, item 208px, account 112px). DataTable measures its own width and hides optional columns by priority: Type first, then Location, then Account. Columns get a 12px gap, so right-aligned headers no longer run together. Highlight chips go by the name column's own width (a container query: 1 chip, then 2 from 220px, then 3 from 320px). The skeleton uses the same fit, including the Account column in the all-accounts view. At 960px with every account in view, about 24px is left spare for a classic Windows scrollbar.

MEDIUM

  1. Phone highlights. Narrow rows drop the Highlights column. The tier badge and a ✦N count sit under the name, as a button that opens a panel with every reason, the pattern seed, the exact float, stickers and charms. The desktop Highlights cell opens the same panel. It is portalled out of the virtual rows and takes focus on open. Escape or Tab returns focus to the button, and it closes on outside click, scroll or resize. Clicking it doesn't toggle row selection. On phones, an invisible overhang stretches its tap area to about 44px tall without growing the row.
  2. Ownership. accountDisplayName() returns the name and SteamID tail as separate parts. An AccountName component truncates the name but never the tail. It is used in the Account column, grid cards, settings rows and the new phone subtitle line. Location options name their owner (bossman (…0001)) and keep the tail beside the location in the trigger. Sync, transfer and not-connected messages use name (…0001) too.
  3. Stale data. Failed revalidations still keep the last good data, but now report it to a stale-data store. A closed event stream (for example a proxy 502) is re-opened with backoff. A banner says "Couldn't refresh this page" or "Lost the live connection… Reconnecting…", with the time of the last good data and Retry now. Retry refetches without blanking the page.
  4. Select. Tab closes the list and keeps its default move. Focus leaving the component closes it too.
  5. Organiser. The Run hint now reads "No rules to run yet." with no rules, "Already organised, so there's nothing to run." when nothing would move, and keeps "Fix the problems above…" only for error-level problems. MovePlan.blocked is new and covered by a test.

LOW

  1. The table skeleton is aria-hidden. LoadingRegion still announces "Loading…".
  2. Open settings uses foreground text. Light muted text darkens from #71717a to #6b6b74, so it clears 4.5:1 on hovered and selected rows too (axe flagged 4.39:1).
  3. Move uses the primary style, like its confirm and the organiser buttons.
  4. "Choose an account" keeps the page's own h1 (e.g. "Trade-up calculator") and explains per page why it needs one account. Trade-up and Cases say they are read-only.
  5. An unnamed account is Account …0003, with no second tail. It fits the sidebar trigger whole.
  6. utils/plural.ts (pluralize, formatCount, with tests) gives "1 row", "1 item" and "1 item · Value" on storage units.
  7. On phones, row checkboxes have a 44×44 hit area, the list/grid toggle is 40×36, Export buttons are 36px tall and grid-card checkboxes are 36px. Desktop density is unchanged.
  8. The trade-up finder and calculator empty states use a new EmptyState (icon, headline, reason).
  9. LoginCard takes headingLevel: h2 in the dialog, h1 on the first-run screen.
  10. account-actions, auth-gate, login-card, sign-in-screen, item-collection and terminal-activate have .config.ts files. item-collection maps each view mode to its renderer (satisfies Record<ItemViewMode, …>). auth-gate lists its first-load problem states. Transfer-bar's last inline labels also moved to config.

Checks: pnpm build ✅ · pnpm lint ✅ (no new warnings) · pnpm test 194/194 ✅ · site/ pnpm typecheck + pnpm build ✅ at 9d9d8a4, also in a clean checkout with only site/ dependencies installed. axe (WCAG 2.1 A/AA + best practice) reports 0 violations on: items loading skeleton, items for all accounts (dark and light), one account (light), phone light with the highlight panel open, phone dark, and the choose-account screen.

Round 2 screenshots

All from the mock API (3 accounts: 2 connected, 1 errored; 7 items). Desktop shots are dark, phone shots are light.

768px: below the wide breakpoint, compact rows with owner line, tier and ✦ count
960px, all accounts (Electron minimum): Type and Location hidden, prices whole
1100px: Location back, Type still hidden
1440px: every column, three chips where they fit
960px, one account: Location shown, Type hidden
390px phone, highlight panel open: tier + ✦2 under the name, owner line, full reasons, seed, float, stickers
Desktop highlight panel, opened from the T3 badge
Two accounts named "bossman": switcher and Account column stay distinct by tail
Same-name location list: each location names its owner
Same names on a phone: owner subtitle on every row
Unnamed account: Account …0003 with one tail, fits the trigger
Server down: reconnecting banner with last-good time and Retry, data kept
Loading: skeleton with the Account column reserved
Select open (keyboard)
After Tab: list closed, focus on the next control (Type)
Organiser, no rules
Organiser, nothing to move
Trade-up empty states with headline + reason
Choose an account (phone): page heading, read-only copy

Round 2 caveats

  • Phone and sub-960px rows can't sort by Highlights any more, because the column moved under the name. Highlights only and desktop sorting still work.
  • The stale banner was tested by stopping the mock's API behind the Vite proxy. It was not tested against a real server restart.
  • The /items route is the only one checked with axe at phone width. Other pages were checked at desktop width or not at all (Settings and Cases were not run).

Round 3

Root cause behind the 768–1200px regressions: the item table chose its layout from the window width, but on Storage it sits in a pane beside the 288px units list. The table, its skeleton and the Storage split now size from their own width.

P1

  1. Storage at 768–1199px. DataTable measures its scroll area (useScrollArea, which replaces useElementWidth). It hides optional columns, then switches to the compact item + price rows when the required columns no longer fit. Storage stacks the units strip above the contents until its own width reaches 56rem (a container query, about a 1170px window), so the contents table always gets room for its required columns. Item names also fit the row layout instead of the viewport: wide rows truncate to one line, compact rows wrap to two. Before, a wide table at 640px wrapped names and overflowed its 64px rows.
  2. Rows overlapping after a resize. The virtualizer re-measures when the row height changes. Checked by resizing 1440 → 390 → 1440 on every page with no overlap or row overflow.
  3. Settings clipping. Settings scrolls under its header like the other pages. main is now overflow-y-auto, so content that can't shrink (the table's 288px floor on a short window) scrolls instead of being clipped.

P2

  1. A sort on a column the current layout hides falls back to the default sort (Price, marked in the header). Sort keys come from both layouts, so the sort comes back when the column does.
  2. The compact table has the grid's Sort items menu (price, float, highlights, name, each with a direction). Grid and table share one list (lib/items/item-sort.config.ts).
  3. Reload units catches failures and shows the server's message with Try again. The button is marked busy instead of disabled, so it keeps focus. After a successful retry, focus goes back to it.
  4. The SteamID tail no longer fades, so it has the same contrast as the name.
  5. Highlight buttons are named after their visible tier first, e.g. "T1 highlights for AK-47 | Case Hardened: 2 reasons".
  6. Focus: (a) the label select stays enabled while saving and the row is aria-busy; (b) the QR code (role="img", labelled) takes focus when it replaces Show QR code; (c) when a re-auth succeeds and its opener is gone, focus goes to the sign-in outcome banner; (d) when the stale banner resolves after Retry, focus goes to the page h1; (e) the highlight panel follows its row while scrolling and returns focus to its button when it closes; (f) after a removal, focus moves to Add account once the POST has finished and the row is gone, in either order.
  7. Highlight panel: Tab goes from the panel to Close. Tab past Close returns to the button and carries on from there. Shift+Tab from the panel returns to the button. The panel stays inside main, so it never covers the bottom nav. The tier line no longer repeats the item name ("Tier 2 of 3 (Blue gem)").

P3

  1. Label selects use the name with its tail ("Label for bossman (…0002)").
  2. Organiser: when a rule matches nothing, it says "no items match your rules" (in the dry run, the per-rule line and the Run hint). Section headings are h2 under the page h1.
  3. Inline classes moved to config in highlight-disclosure, account-row, login-outcome, tiers-help, stale-banner and add-account-dialog. event.target as Node casts are replaced with instanceof Node checks.
  4. Settings rows all switch layout at the same panel width: side by side with long names truncated, or stacked. On touch and phones, the tiers help button is 36×36, highlight buttons are at least 36px with a 44px tap area, and the panel's Close is 36px. Desktop density is unchanged.

Also fixed (found by axe while testing): the dark theme's solid destructive button had white text on #f87171 (2.8:1). It now uses a --destructive-solid fill. Hovered primary buttons faded to 4.4:1 with opacity-90; solid buttons now darken on hover instead.

Checks at 0381ba5: pnpm build ✅ · pnpm lint ✅ (the same 4 warnings as before this round) · pnpm test 198/198 ✅ (new: data-table.utils.test.ts for layout fitting and nulls-last sorting) · site/ pnpm typecheck + pnpm build ✅ in a clean archive with only site/ dependencies installed.
axe (WCAG A/AA + best practice): 0 violations on Dashboard, Items, Storage, Organiser, Cases, Terminals, Trade-up and Settings at 768, 960, 1100 and 1440 (dark) and 390 (light). Also 0 on Items (all accounts) with the highlight panel open and the compact sort menu open at every width, and on the add-account, QR and remove-account dialogs in both themes.
Keyboard-only checks for items 9a–9f and 10 passed against the mock API, including forced failures, same-name accounts, a dropped event stream and add/remove account.

Round 3 screenshots

Mock API: 3 accounts (2 connected, 1 errored). Desktop shots are dark, phone shots are light.

Storage 768, before: the table squeezed beside the units list, with the Item column about 14px wide
Storage 768, after: units stacked above, compact rows with the sort menu
Storage 960: stacked, full table with Type
Storage 1100: stacked, every column
Storage 1440: side by side once the page fits both
Storage 390
Resized 1440 → 768, before: rows kept their 64px size and overlapped
Resized 1440 → 768, after: rows re-measured
Settings 390, scrolled to the end
Settings 768: every row uses the same layout, long names truncate
Compact table sort menu
Highlight panel on a phone: kept above the bottom nav, tier line without the name
Reload units failed: message and Try again
Organiser, rule matches nothing

Round 3 caveats

  • Touch sizing uses pointer: coarse or a width under 640px. It was checked in desktop Chromium at 390px, not on a real touch device.
  • Storage side by side starts at 56rem of content, not the reviewers' approximate 1200px window. At a 1200px window, the units list and a table with Float, Highlights and Price fit side by side; below that it stacks.
  • Nothing is skipped from the round 3 list.

Round 4 UI QA

Checked at 390, 768, 960, 1100 and 1440px with a three-account mock. The Items table keeps its row heights when resized in both directions. Dashboard, Cases, Items, Storage, Settings and populated Trade-up pass Axe WCAG 2 A/AA and 2.1 A/AA checks in light and dark themes. The Trade-up candidate list now keeps names, prices, float and location visible on compact screens. App build, site build, lint and 198 tests pass.

Phone: highlight details and count
Phone: all accounts and partial connection
Dashboard 960: readable value changes
Cases 390: readable kind counts
Trade-up 390: item names visible
Trade-up 768: compact candidate rows

These are mock API screenshots. They do not verify Steam sign-in, Electron, real transfers, or live marketplace prices.

Round 5 UI QA

An independent pass with an extended mock: 3 accounts (two named "bossman", one with a sign-in error), the site demo's 1,600+ items, terminals, cases, dashboard history, and trade-up candidates, odds and finder results. It covered 7 routes in all three scopes (All, …0001, …0002), at 390, 768, 960 and 1440 in both themes, plus live resize, a delayed account switch, keyboard and axe.

Fixed

  • Trade-up: the page had three nested scroll areas. At 390px the calculator was about 106px tall, with a 320px list inside it. Everything below the header now scrolls as one focusable region.
  • Trade-up finder: the finder list (still capped at 40vh) is now focusable. Axe flagged it scrollable-region-focusable (serious) at every width once finder results existed. Round 4's run had no results, so it missed this.
  • Dashboard hero: at 768px the Items metric was clipped off the card. The hero now lays out from the card's width with a container query, not the viewport's, and the metrics wrap.
  • Dashboard chart: the x-axis date labels collided at 768px. The tick count now comes from the measured plot width (120px per date label, 150px per date-time label, at most 4 intervals) and updates on resize.

Verified: 168 route × width × theme × scope combos with 0 axe violations, 0 page overflow and 0 console errors. With a 2.5s delay on the A→B switch, only skeletons show (no rows from A) and filters reset. Choosing the errored account offers Reconnect and Sign in again. App build, lint (0 errors, 4 existing warnings), 198/198 tests, and site build + typecheck all pass.

Still open (not changed): on phones, the Items and Storage tables scroll inside a short inner area. Short dropdowns (under 8 options) have no type-ahead. Finder results can't be loaded into the calculator. Terminals shows plain "Loading…" text instead of a skeleton.

Dashboard 768: metrics wrap, axis spaced
Dashboard 390
Trade-up 390: one page scroll
Trade-up 768
Trade-up 1440 light, inputs picked

Round 6 UI QA

The selected-account removal flow now returns keyboard focus to Add account after the dialog and account switch settle. Settings stays mounted while the selected account changes. The Items grid puts its selection and sort controls outside the ARIA grid; the populated grid passed Axe with zero violations at 390px and 1440px. Highlight chips now fit within the virtual row height, leaving a 12px gap between cards. The focused card's border and focus ring remain visible. Browser checks found no page overflow at those widths. App build, lint (zero errors, four existing warnings), and 198 tests pass.

1440px grid with keyboard focus 390px grid

Screenshots use a mock API. Independent provider scores for this head are pending.

Round 7 follow-up

The removal confirmation keeps its focused button available as an ARIA-disabled control while the request runs. A live status announces the working label; repeat submission is guarded. In a mock browser run covering eight keyboard/mouse, selected/nonselected, phone/desktop and response-order cases, focus stayed on “Removing…” during the request and ended on “Add account.” Terminals now says “Expires in less than 1 hour” for an active offer with under an hour remaining. App build, lint (zero errors), and 198 tests pass. The Round 6 screenshots above remain representative of the grid layout; independent scores for this head are pending.

Round 8 phone QA

The Items card grid has a 368px minimum frame, so a 272px card and its focus outline fit inside the scroll viewport even at 320×700 and 375×667. ArrowUp, PageUp and Home keep a 6px inset around the focused card. Trade-up finder outcome rows now shrink within the list at 320px; prices remain visible and the list no longer scrolls sideways. Browser checks at 320, 375, 390 and 1440px found no page overflow. The populated grid passed Axe at all four widths. App build, lint (zero errors), and 198 tests pass.

375×667 Items grid: full card and focus ring 320px Trade-up: outcome prices visible

Screenshots use a populated mock. Independent scores for this head are pending.

Round 9 UI QA

Trade-up finder outcome names now truncate independently, so the probability and price stay visible at 320, 768, 1024 and 1100px. All 18 mock outcomes showed their odds at each width, with no list or page horizontal overflow. Disconnected-account pages now retain their route title above the account recovery card. Dashboard, Items, Storage, Cases, Terminals and Trade-up each had one h1 and zero Axe violations in the errored-account state at 390px. App build, lint (zero errors), and 198 tests pass.

320px Trade-up: odds and prices 1024px Trade-up: odds and prices 390px disconnected Items

Screenshots use a populated mock. Independent scores for this head are pending.

Round 10 UI QA

Trade-up finder outcomes now choose their column count from the result list's own width. Full item names wrap; probabilities and prices remain visible. In the populated mock, none of 18 outcome names clipped at 320, 768, 1024, 1100 or 1440px; the list and page had no horizontal overflow, and Axe found no violations. Choosing an account from the single-account prompt moves keyboard focus to the destination page heading. Reconnect keeps its button focused while waiting and restores focus to the heading if the status card disappears; a delayed-reply mock confirmed focus through that transition. App build, lint (zero errors), and 198 tests pass.

320px Trade-up: full distinguishing names 1024px Trade-up: full names and odds

Screenshots use a populated mock. Independent scores for this head are pending.

Round 11 UI QA

When a not-yet-connected account becomes ready, the account gate restores lost focus to the newly rendered page heading. A mock that delivered the live account update before the Reconnect response, and a separate choose-account flow, kept focus on the Trade-up heading after the page swap. At 320px, the mobile header gives the switcher room to show the account name and SteamID suffix, while compact Items rows use the thumbnail's width for readable names. The compact Price heading is a label because the sort menu already controls it. The Dashboard chart has enough left padding for a $968.7K y-axis label at 960px. Populated Items and Dashboard passed Axe with zero violations; app build, lint (zero errors) and 198 tests pass.

320px Items list and account switcher 960px Dashboard chart

Screenshots use a populated mock. Independent scores for this head are pending.

Round 12 UI QA

Trade-up calculator outcome names wrap fully on phones, with chance and price on a separate row. A browser check with realistic long StatTrak/wear names found no clipped outcome names or page overflow at 320, 360, 375, 390 and 414px. The compact Items sort trigger shows direction as “Price ↓” or “Price ↑” at 320 and 360px; the open menu keeps the full labels. Compact list thumbnails yield space to names through 375px. Dashboard chart labels fit all 47 supported currencies at 320 and 960px in a browser measurement. The repository vp check passes with zero errors and four existing warnings; app build and 198 tests pass.

320px Trade-up outcomes 320px compact sort 960px CHF Dashboard 360px Items names

Screenshots use a mock API. Independent scores for this head are pending.

Round 13 phone QA

Dashboard Top holdings uses a phone layout with the full item name, Qty, Each and Total on separate lines, while retaining its table from 640px upward. Browser checks at 320, 360, 390 and 414px in USD and IDR found no panel or page horizontal overflow, including a stress case with a long StatTrak name and a nine-digit IDR price. Compact Items rows give the name the thumbnail's width on phones, including 375px. Trade-up candidate rows wrap their float and storage location instead of truncating them at 320px. The loading skeleton follows the same phone holdings layout. Full vp check passes with zero errors and four existing warnings; app build and 198 tests pass.

360px holdings, USD 390px holdings, IDR 375px Items list 320px Trade-up input

Screenshots use a mock API. Independent scores for this head are pending.

Round 14: Cases rarity streaks and drop odds

The Cases view now shows cases since the latest exact-tier Mil-Spec, Restricted, Classified, Covert and Gold pull. A tier never pulled shows the full case count and “Never pulled”. The new chart compares observed case drop rates with the supplied reference odds (79.92%, 15.98%, 3.20%, 0.64%, 0.26%); unknown rarities are excluded from the rate denominator and called out. This comparison is shown for weapon cases only.

Cases rarity streaks and odds on desktop

Cases rarity streaks on phone

Cases odds on phone

QA: app build, 199 tests, and vp check pass (0 errors, 4 existing warnings). Browser checks at 320, 390, 768, 960 and 1440px found no page overflow or Axe WCAG 2.1 AA violations in the populated mock Cases view.

Round 15: Cases review fixes

  • Keep the SteamID suffix visible in the “Choose an account” prompt at 320px, even with a long persona name.
  • Add a keyboard-accessible “View monthly counts” list under the monthly bars; month labels and empty text now follow the selected event kind.
  • Link the fixed case-odds reference to Perfect World's published weapon-case odds and clarify that the displayed percentages are approximate.
  • Update the marketing-site Cases demo and mock stats so its typecheck/build pass.

Account choice at 320px

Monthly counts opened by keyboard on a phone

Case odds with source on a phone

Case odds with source on desktop

QA: app build; site typecheck and build; vp check (0 errors, 4 existing warnings); 320px account-choice and 390/1440px Cases browser checks with no page overflow or Axe violations. The populated monthly list and source link were checked by keyboard and DOM inspection.

Round 16: Honest streaks and small-sample context

  • Openings with unknown rarity make an exact tier streak uncertain. The Cases view now shows “Up to N” and an unknown-rarity note when such an opening follows the latest confirmed pull. It avoids claiming a tier was never pulled when the recorded rarity is unknown.
  • A note beside the odds chart explains that small histories can diverge sharply from the published rates and that past pulls do not change the next case's odds.
  • The Gold streak appears once, in the five-tier streak panel. The desktop streak panel sizes to its content, and the chart tracks are more visible in dark mode.
  • Tests now cover uncertainty from unknown-rarity openings. App build, 200 tests, site typecheck/build, and vp check pass (0 errors, 4 existing warnings). Browser checks of populated and unknown-rarity cases at 390/1440px found no page overflow or Axe WCAG 2.1 AA violations.

Case odds and small-sample context on desktop

Case odds and small-sample context on phone

Unknown-rarity streak bounds on phone

Round 4 review: focus, odds chart and accessibility

  • Sync history keeps focus. The button now stays focusable while a sync is pending or running (aria-disabled, presses ignored) instead of being disabled, and a polite live region announces “Syncing history…” when the request starts.
  • Focus after Retry. A shared useRestoreFocusOnRecovery hook moves focus to the page heading once a load recovers from an error, and is applied to Items, Storage, Dashboard, Cases, Terminals and all three Trade-up loads. Focus the user has already moved elsewhere is left alone.
  • Highlight panel. Closing now unmounts the panel before refocusing the trigger, so Shift+Tab, Escape, Close and Tab past the end all end on the trigger. I could not reproduce the reported row landing in Chromium on this head (every path already ended on the button); the change removes the ordering dependency, and the flows are asserted below.
  • Odds chart. Each tier is one track: your rate is the filled bar and the published odds a marker on the same scale, so a 0% tier no longer sits next to a full-width “odds” bar. Both numbers use 3 decimals, each tier is a role="group" named e.g. “Mil-Spec: your rate 80.000%, published odds 79.923% (8 of 10 pulls)”, and the bars are aria-hidden. A note says each tier has its own scale, and the small-sample caveat now explains the empty bar.
  • “Up to N” / “Unknown rarity”. A keyboard- and touch-friendly <details> explainer in plain language appears when a streak is uncertain.
  • Conventions. case-rarity-insights gets .config.ts and .types.ts (order, 500 threshold, decimals, classes, copy); sync-banner gets a .config.ts.

QA: app build, 200 tests, vp lint (0 errors, 4 existing warnings), site typecheck and build all pass. Axe (WCAG 2.1 AA) found no violations at 768, 960 and 1100px, in light and dark, on Dashboard, Items, Storage, Organiser, Settings, Cases, Trade-up and Terminals (48 checks), plus Cases at 390 and 1440px. With Playwright I tabbed and asserted document.activeElement: Sync stays on the button through the request; Retry lands on the h1; highlight Shift+Tab (panel and Close), Escape land on the trigger. Screenshots use a mock API.

Cases odds, 390px Cases odds, 1440px Sync pending, 390px Focus after Retry

Round 5 review: consistency and wide layouts

  • Cases chart matches the summary. The "What you pulled" donut and list now add an "Unknown rarity" slice for any opening without a recorded tier, so the centre equals "Cases opened". The current server already folds these into an "Other" slice, so this guards payloads where the tiers sum to less than the total.
  • Retry keeps focus. The error stays mounted while a retry runs: the button reads "Retrying…", is aria-disabled (still focusable, presses ignored) and keeps focus after a failed retry. A successful retry still moves focus to the page heading.
  • Account chip everywhere. Items and Dashboard now show "Account: name (…0001)" like Storage, Cases, Terminals and Trade-up, when more than one account is tracked.
  • Small-sample copy. "1 / 1 pull" uses the shared plural helper (also in the tier group labels and the unclassified note). Below the 500-pull small-sample threshold your rate drops decimals ("75%"); published odds keep 3 decimals.
  • Settings at wide widths. Accounts and Data sit beside Appearance, Currency and Pricing from about 768px of content width (container query), up to a 72rem cap; narrower stays one column.
  • Terminal "Activate…" is a neutral underlined link instead of red; the confirmation dialog still carries the destructive framing.
  • Conventions. load-error, sync-banner and page-header classes moved into their .config.ts files.

QA: app build, 204 tests, vp check (0 errors, 4 existing warnings), site typecheck and build pass. Axe (all rules incl. best-practice, default tag set) found 0 violations on Dashboard, Items, Storage, Cases, Terminals, Trade-up and Settings at 768/960/1100/1440 dark and 390 light (35 checks), plus Items/Storage/Settings after a 1440→390→1440 resize round trip, and Cases in its error state. No page or main overflow at any width. Screenshots use a mock API.

Settings, 1440px Settings, 1100px
Cases donut with unknown slice Account chip on Items Failed Retry keeps focus

Round 6 review: compact rows, banner placement, strip cue and conventions

  • Compact Items rows carry location and float. Below roughly 1000px of content, each row shows a muted Location · float line (plus Pattern #n when there is one, folded into the same line) under the name, above the account line in All accounts. It truncates with the full text in a tooltip, so nothing overflows at 390 or 768. The compact "Price" header is now screen-reader-only because the sort menu already says it. Row heights went from 88/104 to 104/120. I checked the Redline (pattern plus highlight), Terminal (sealed and active, with Activate) and long-name rows in All accounts at 390 and 768 and none clip.
  • Transfer and organiser banners no longer move the page. They render under the page (always-mounted live region #transfer-status), not above the heading, so the h1 stays at the same y before and after a run. After "Move items" focus stays on "Run plan…" (it is now aria-disabled while busy rather than disabled, so it can take focus back). In the Items/Storage Move bar, Move hands focus to "Confirm move", Back returns it to Move, and a successful confirm moves focus to the status region before the bar unmounts.
  • Storage unit strip cue. While the strip scrolls sideways (768–1169px), the cut-off edge fades and a "+N more" button names how many units are beyond it. Pressing it scrolls the next unit in and moves focus to it, so the button disappearing never drops focus to the page.
  • Copy. Partial history reads "History is incomplete — sync to load older pages." (shown only when not syncing, so it never claims a sync is in progress next to an error). Navigation uses one set of labels in the sidebar and the bottom bar: "Dashboard" and "Storage" (the page heading stays "Storage units"); the 7-item bar still fits at 320 and 360px. The Dashboard range pill "Total" is now "All time", so it no longer clashes with the "Total" series pill.
  • Bar lists read "Label, count" and "value, share" (a visually hidden separator), instead of running the numbers together.
  • Cases. Monthly bars are capped at 56px wide and show their count above and month below (with up to 12 months; month numbers plus the first and last full month beyond 6). The three lower panels align to their own content height instead of stretching to "Latest openings".
  • Consistency. On Terminals, "Activate…" is a regular button beside "Next offer…" (table rows keep the compact link, where a button would not fit). The Trade-up input rarity picker also writes the chosen rarity next to the circles, and filled input slots are named with their rarity.
  • Settings. One full-width column when narrow (no 576px cap with dead space at 960) and no 72rem cap when wide, so its right edge matches the other pages at 1440.
  • Robustness. A "Skip to content" link is the first tab stop (it focuses main). A root errorComponent shows a landmarked page with a heading and "Reload the app" instead of a blank screen. The organiser condition-value input is capped at max-w-sm.
  • Conventions. Inline classes moved into .config.ts files for tradeup-finder-results, plan-preview, organiser-panel, item-grid-card, bar-list, monthly-bars, rarity-picker, transfer-status, plus the Settings, Storage, Cases, Trade-up and Dashboard routes (settings-page, storage-page, cases, tradeup-page, dashboard-page and a shared page-layout config). No behaviour change there. The landing-site patch for the Trade-up slot label was dropped because the app now ships it.

QA: app build, 206 tests (new coverage for the compact detail join), vp check (0 errors, 4 existing warnings), site typecheck and build pass. Axe (WCAG 2.0/2.1/2.2 A and AA plus best-practice) found 0 violations on Dashboard, Items, Storage, Organiser, Cases, Trade-up, Terminals and Settings at 768/960/1100/1440 dark and 390 light (40 checks), plus Items, Storage and Settings through a 1440→390→1440→768→1100→960 resize sequence (15 checks); no horizontal page scroll in any of them. The keyboard checks for the banner and focus behaviour are Playwright scripts run against a mock API (they assert document.activeElement, they are not repo tests): Run plan… → Move items lands on "Run plan…" with the h1 y unchanged; Move → Confirm move → Back → Move; Confirm lands on the status region; "+N more" pressed with Enter ends on a unit card; Tab then Enter on the skip link ends on main.

Compact Items row, 390px Compact Items row, 768px Banner below the page, 390px
Storage strip cue, 768px Storage strip cue, 390px
Organiser run banner under the page, 1440px
Settings, 960px Settings, 1440px

Root error page, 768px (an injected test throw, not a real failure):

8liam added 30 commits November 8, 2026 12:45
…oads

useRemoteData keeps stale data only within the same account; a failed load now carries retry, and the event stream reports disconnected/incompatible first loads.
…atus

- Switcher options carry a status dot and text, the SteamID tail (also in the
  trigger), and a wider list; fallback names keep their ending.
- The server publishes which account the last QR sign-in was for, so signing
  an existing account in again closes the dialog and reports "Added…" or
  "Signed in to … again" with a shortcut to view it. Cancel is posted only
  while a sign-in is pending; the dialog reads "Sign in to <name> again".
- Settings rows for broken accounts get Reconnect / Sign in again, Remove is
  an outline button, label saves show a pending state, and focus moves to
  Add account once a removed row disappears.
Pages remount per sidebar scope; stale filter values fall back to 'all'; selection is derived from visible rows; filtered-empty states offer Clear filters; the in-page account filter is now 'Filter by account' / 'Any account'; controls use a two-column grid on phones. The partial-connection notice becomes a banner naming the accounts with a fix action.
…apping

Chips collapse into +N (named for screen readers), stickers without images are skipped, text is 11px with stronger contrast, narrow rows are taller. Highlights help is a clamped disclosure that explains each signal, and a light-theme-safe accent-strong token fixes the invisible gem.
Promise.allSettled across the accounts in view, skipping ones that aren't connected, so one failure no longer hides the rest; each failure is reported by account name.
… parts

Shared Select instead of a native one, copy in config, text-success for profit, '—' when float is unknown, a skeleton while searching, and StatTrak filter values from one config list used by client and server.
Components that only need an account's name no longer import the zustand-backed scope store, so the marketing site's demo (which installs only site/ deps) bundles again.
…when narrow

DataTable measures its own width and drops optional columns by priority, so prices and floats never clip from the 960px Electron minimum up. Highlight chips follow the name column's width (container query) instead of the viewport. The footer row count uses the plural helper.
…e rows

Phone rows drop the Highlights column: the tier and a reason count sit under the name and open a panel listing every reason, the pattern seed, exact float and attachments. The desktop Highlights cell opens the same panel. It is portalled out of the virtual rows, takes focus on open and returns it on Escape or Tab.
Failed revalidations keep the last good data but now report it, and a closed event stream is re-opened with backoff instead of going quiet. The banner says how old the screen is and Retry refetches without blanking it.
8liam added 28 commits November 9, 2026 01:20
@8liam
8liam merged commit 2080760 into main Nov 8, 2026
4 checks passed
@8liam
8liam deleted the fix/multi-account-ui-issues branch November 8, 2026 22:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant