diff --git a/docs/pr-screenshots/after/01-switcher-open.png b/docs/pr-screenshots/after/01-switcher-open.png new file mode 100644 index 0000000..91283a2 Binary files /dev/null and b/docs/pr-screenshots/after/01-switcher-open.png differ diff --git a/docs/pr-screenshots/after/02-items-all.png b/docs/pr-screenshots/after/02-items-all.png new file mode 100644 index 0000000..34a1e5e Binary files /dev/null and b/docs/pr-screenshots/after/02-items-all.png differ diff --git a/docs/pr-screenshots/after/03-stale-filter-after-switch.png b/docs/pr-screenshots/after/03-stale-filter-after-switch.png new file mode 100644 index 0000000..15f2125 Binary files /dev/null and b/docs/pr-screenshots/after/03-stale-filter-after-switch.png differ diff --git a/docs/pr-screenshots/after/04-reauth-dialog.png b/docs/pr-screenshots/after/04-reauth-dialog.png new file mode 100644 index 0000000..c9936f8 Binary files /dev/null and b/docs/pr-screenshots/after/04-reauth-dialog.png differ diff --git a/docs/pr-screenshots/after/05-settings-errored.png b/docs/pr-screenshots/after/05-settings-errored.png new file mode 100644 index 0000000..5f378d9 Binary files /dev/null and b/docs/pr-screenshots/after/05-settings-errored.png differ diff --git a/docs/pr-screenshots/after/06-add-dialog.png b/docs/pr-screenshots/after/06-add-dialog.png new file mode 100644 index 0000000..485b721 Binary files /dev/null and b/docs/pr-screenshots/after/06-add-dialog.png differ diff --git a/docs/pr-screenshots/after/07-remove-dialog.png b/docs/pr-screenshots/after/07-remove-dialog.png new file mode 100644 index 0000000..82c6224 Binary files /dev/null and b/docs/pr-screenshots/after/07-remove-dialog.png differ diff --git a/docs/pr-screenshots/after/08-tradeup.png b/docs/pr-screenshots/after/08-tradeup.png new file mode 100644 index 0000000..365fe99 Binary files /dev/null and b/docs/pr-screenshots/after/08-tradeup.png differ diff --git a/docs/pr-screenshots/after/10-items-single.png b/docs/pr-screenshots/after/10-items-single.png new file mode 100644 index 0000000..15f2125 Binary files /dev/null and b/docs/pr-screenshots/after/10-items-single.png differ diff --git a/docs/pr-screenshots/after/11-hidden-selection.png b/docs/pr-screenshots/after/11-hidden-selection.png new file mode 100644 index 0000000..09933e1 Binary files /dev/null and b/docs/pr-screenshots/after/11-hidden-selection.png differ diff --git a/docs/pr-screenshots/after/12-mobile-items.png b/docs/pr-screenshots/after/12-mobile-items.png new file mode 100644 index 0000000..4d210ce Binary files /dev/null and b/docs/pr-screenshots/after/12-mobile-items.png differ diff --git a/docs/pr-screenshots/after/13-light-highlights.png b/docs/pr-screenshots/after/13-light-highlights.png new file mode 100644 index 0000000..cdac14c Binary files /dev/null and b/docs/pr-screenshots/after/13-light-highlights.png differ diff --git a/docs/pr-screenshots/after/14-reauth-qr.png b/docs/pr-screenshots/after/14-reauth-qr.png new file mode 100644 index 0000000..bdb3dc5 Binary files /dev/null and b/docs/pr-screenshots/after/14-reauth-qr.png differ diff --git a/docs/pr-screenshots/after/15-reauth-success.png b/docs/pr-screenshots/after/15-reauth-success.png new file mode 100644 index 0000000..68fa6ef Binary files /dev/null and b/docs/pr-screenshots/after/15-reauth-success.png differ diff --git a/docs/pr-screenshots/after/16-tradeup-finder.png b/docs/pr-screenshots/after/16-tradeup-finder.png new file mode 100644 index 0000000..0cd801d Binary files /dev/null and b/docs/pr-screenshots/after/16-tradeup-finder.png differ diff --git a/docs/pr-screenshots/after/17-disconnected.png b/docs/pr-screenshots/after/17-disconnected.png new file mode 100644 index 0000000..011f6bc Binary files /dev/null and b/docs/pr-screenshots/after/17-disconnected.png differ diff --git a/docs/pr-screenshots/after/18-mobile-storage.png b/docs/pr-screenshots/after/18-mobile-storage.png new file mode 100644 index 0000000..d25423e Binary files /dev/null and b/docs/pr-screenshots/after/18-mobile-storage.png differ diff --git a/docs/pr-screenshots/after/18b-mobile-tradeup.png b/docs/pr-screenshots/after/18b-mobile-tradeup.png new file mode 100644 index 0000000..cefe934 Binary files /dev/null and b/docs/pr-screenshots/after/18b-mobile-tradeup.png differ diff --git a/docs/pr-screenshots/after/19-help-mobile.png b/docs/pr-screenshots/after/19-help-mobile.png new file mode 100644 index 0000000..e98e99f Binary files /dev/null and b/docs/pr-screenshots/after/19-help-mobile.png differ diff --git a/docs/pr-screenshots/after/20-filtered-empty.png b/docs/pr-screenshots/after/20-filtered-empty.png new file mode 100644 index 0000000..a8ebf9e Binary files /dev/null and b/docs/pr-screenshots/after/20-filtered-empty.png differ diff --git a/docs/pr-screenshots/after/21-choose-account.png b/docs/pr-screenshots/after/21-choose-account.png new file mode 100644 index 0000000..5be1f71 Binary files /dev/null and b/docs/pr-screenshots/after/21-choose-account.png differ diff --git a/docs/pr-screenshots/after/22-organiser.png b/docs/pr-screenshots/after/22-organiser.png new file mode 100644 index 0000000..aa9cfbb Binary files /dev/null and b/docs/pr-screenshots/after/22-organiser.png differ diff --git a/docs/pr-screenshots/after/23-grid.png b/docs/pr-screenshots/after/23-grid.png new file mode 100644 index 0000000..14b3675 Binary files /dev/null and b/docs/pr-screenshots/after/23-grid.png differ diff --git a/docs/pr-screenshots/after/24-grid-mobile.png b/docs/pr-screenshots/after/24-grid-mobile.png new file mode 100644 index 0000000..4518910 Binary files /dev/null and b/docs/pr-screenshots/after/24-grid-mobile.png differ diff --git a/docs/pr-screenshots/before/01-switcher-open.png b/docs/pr-screenshots/before/01-switcher-open.png new file mode 100644 index 0000000..9a83bbe Binary files /dev/null and b/docs/pr-screenshots/before/01-switcher-open.png differ diff --git a/docs/pr-screenshots/before/02-items-all.png b/docs/pr-screenshots/before/02-items-all.png new file mode 100644 index 0000000..d39fc79 Binary files /dev/null and b/docs/pr-screenshots/before/02-items-all.png differ diff --git a/docs/pr-screenshots/before/03-stale-filter-after-switch.png b/docs/pr-screenshots/before/03-stale-filter-after-switch.png new file mode 100644 index 0000000..ee2e231 Binary files /dev/null and b/docs/pr-screenshots/before/03-stale-filter-after-switch.png differ diff --git a/docs/pr-screenshots/before/04-reauth-dialog.png b/docs/pr-screenshots/before/04-reauth-dialog.png new file mode 100644 index 0000000..e286948 Binary files /dev/null and b/docs/pr-screenshots/before/04-reauth-dialog.png differ diff --git a/docs/pr-screenshots/before/05-settings-errored.png b/docs/pr-screenshots/before/05-settings-errored.png new file mode 100644 index 0000000..ce156b2 Binary files /dev/null and b/docs/pr-screenshots/before/05-settings-errored.png differ diff --git a/docs/pr-screenshots/before/06-add-dialog.png b/docs/pr-screenshots/before/06-add-dialog.png new file mode 100644 index 0000000..a46fe21 Binary files /dev/null and b/docs/pr-screenshots/before/06-add-dialog.png differ diff --git a/docs/pr-screenshots/before/07-remove-dialog.png b/docs/pr-screenshots/before/07-remove-dialog.png new file mode 100644 index 0000000..4d57c92 Binary files /dev/null and b/docs/pr-screenshots/before/07-remove-dialog.png differ diff --git a/docs/pr-screenshots/before/08-tradeup.png b/docs/pr-screenshots/before/08-tradeup.png new file mode 100644 index 0000000..c909fde Binary files /dev/null and b/docs/pr-screenshots/before/08-tradeup.png differ diff --git a/docs/pr-screenshots/before/10-items-single.png b/docs/pr-screenshots/before/10-items-single.png new file mode 100644 index 0000000..63918b3 Binary files /dev/null and b/docs/pr-screenshots/before/10-items-single.png differ diff --git a/docs/pr-screenshots/before/11-hidden-selection.png b/docs/pr-screenshots/before/11-hidden-selection.png new file mode 100644 index 0000000..289edd0 Binary files /dev/null and b/docs/pr-screenshots/before/11-hidden-selection.png differ diff --git a/docs/pr-screenshots/before/12-mobile-items.png b/docs/pr-screenshots/before/12-mobile-items.png new file mode 100644 index 0000000..6984776 Binary files /dev/null and b/docs/pr-screenshots/before/12-mobile-items.png differ diff --git a/docs/pr-screenshots/before/13-light-highlights.png b/docs/pr-screenshots/before/13-light-highlights.png new file mode 100644 index 0000000..e0b9eb3 Binary files /dev/null and b/docs/pr-screenshots/before/13-light-highlights.png differ diff --git a/docs/pr-screenshots/before/23-grid.png b/docs/pr-screenshots/before/23-grid.png new file mode 100644 index 0000000..a30cee5 Binary files /dev/null and b/docs/pr-screenshots/before/23-grid.png differ diff --git a/docs/pr-screenshots/round10/tradeup-full-names-1024.png b/docs/pr-screenshots/round10/tradeup-full-names-1024.png new file mode 100644 index 0000000..1dc55a2 Binary files /dev/null and b/docs/pr-screenshots/round10/tradeup-full-names-1024.png differ diff --git a/docs/pr-screenshots/round10/tradeup-full-names-320.png b/docs/pr-screenshots/round10/tradeup-full-names-320.png new file mode 100644 index 0000000..d2f45de Binary files /dev/null and b/docs/pr-screenshots/round10/tradeup-full-names-320.png differ diff --git a/docs/pr-screenshots/round11/dashboard-960.png b/docs/pr-screenshots/round11/dashboard-960.png new file mode 100644 index 0000000..eefc592 Binary files /dev/null and b/docs/pr-screenshots/round11/dashboard-960.png differ diff --git a/docs/pr-screenshots/round11/items-list-320.png b/docs/pr-screenshots/round11/items-list-320.png new file mode 100644 index 0000000..58eef4d Binary files /dev/null and b/docs/pr-screenshots/round11/items-list-320.png differ diff --git a/docs/pr-screenshots/round12/dashboard-chf-960.png b/docs/pr-screenshots/round12/dashboard-chf-960.png new file mode 100644 index 0000000..38088ef Binary files /dev/null and b/docs/pr-screenshots/round12/dashboard-chf-960.png differ diff --git a/docs/pr-screenshots/round12/items-list-360.png b/docs/pr-screenshots/round12/items-list-360.png new file mode 100644 index 0000000..65140be Binary files /dev/null and b/docs/pr-screenshots/round12/items-list-360.png differ diff --git a/docs/pr-screenshots/round12/items-sort-320.png b/docs/pr-screenshots/round12/items-sort-320.png new file mode 100644 index 0000000..67345bd Binary files /dev/null and b/docs/pr-screenshots/round12/items-sort-320.png differ diff --git a/docs/pr-screenshots/round12/tradeup-outcomes-320.png b/docs/pr-screenshots/round12/tradeup-outcomes-320.png new file mode 100644 index 0000000..d3a6f86 Binary files /dev/null and b/docs/pr-screenshots/round12/tradeup-outcomes-320.png differ diff --git a/docs/pr-screenshots/round13/holdings-360-usd.png b/docs/pr-screenshots/round13/holdings-360-usd.png new file mode 100644 index 0000000..f5f90ac Binary files /dev/null and b/docs/pr-screenshots/round13/holdings-360-usd.png differ diff --git a/docs/pr-screenshots/round13/holdings-390-idr.png b/docs/pr-screenshots/round13/holdings-390-idr.png new file mode 100644 index 0000000..bd7476a Binary files /dev/null and b/docs/pr-screenshots/round13/holdings-390-idr.png differ diff --git a/docs/pr-screenshots/round13/items-list-375.png b/docs/pr-screenshots/round13/items-list-375.png new file mode 100644 index 0000000..1257960 Binary files /dev/null and b/docs/pr-screenshots/round13/items-list-375.png differ diff --git a/docs/pr-screenshots/round13/tradeup-input-320.png b/docs/pr-screenshots/round13/tradeup-input-320.png new file mode 100644 index 0000000..48a2fc6 Binary files /dev/null and b/docs/pr-screenshots/round13/tradeup-input-320.png differ diff --git a/docs/pr-screenshots/round14/cases-desktop.png b/docs/pr-screenshots/round14/cases-desktop.png new file mode 100644 index 0000000..2fddfb2 Binary files /dev/null and b/docs/pr-screenshots/round14/cases-desktop.png differ diff --git a/docs/pr-screenshots/round14/cases-phone-odds.png b/docs/pr-screenshots/round14/cases-phone-odds.png new file mode 100644 index 0000000..4c69838 Binary files /dev/null and b/docs/pr-screenshots/round14/cases-phone-odds.png differ diff --git a/docs/pr-screenshots/round14/cases-phone-streaks.png b/docs/pr-screenshots/round14/cases-phone-streaks.png new file mode 100644 index 0000000..fb99928 Binary files /dev/null and b/docs/pr-screenshots/round14/cases-phone-streaks.png differ diff --git a/docs/pr-screenshots/round15/account-choice-320.png b/docs/pr-screenshots/round15/account-choice-320.png new file mode 100644 index 0000000..26849df Binary files /dev/null and b/docs/pr-screenshots/round15/account-choice-320.png differ diff --git a/docs/pr-screenshots/round15/cases-counts-390.png b/docs/pr-screenshots/round15/cases-counts-390.png new file mode 100644 index 0000000..8719e63 Binary files /dev/null and b/docs/pr-screenshots/round15/cases-counts-390.png differ diff --git a/docs/pr-screenshots/round15/cases-odds-source-1440.png b/docs/pr-screenshots/round15/cases-odds-source-1440.png new file mode 100644 index 0000000..4f723bf Binary files /dev/null and b/docs/pr-screenshots/round15/cases-odds-source-1440.png differ diff --git a/docs/pr-screenshots/round15/cases-odds-source-390.png b/docs/pr-screenshots/round15/cases-odds-source-390.png new file mode 100644 index 0000000..369c2da Binary files /dev/null and b/docs/pr-screenshots/round15/cases-odds-source-390.png differ diff --git a/docs/pr-screenshots/round16/cases-sample-1440.png b/docs/pr-screenshots/round16/cases-sample-1440.png new file mode 100644 index 0000000..0a787f1 Binary files /dev/null and b/docs/pr-screenshots/round16/cases-sample-1440.png differ diff --git a/docs/pr-screenshots/round16/cases-sample-390.png b/docs/pr-screenshots/round16/cases-sample-390.png new file mode 100644 index 0000000..bce43c2 Binary files /dev/null and b/docs/pr-screenshots/round16/cases-sample-390.png differ diff --git a/docs/pr-screenshots/round16/cases-unknown-390.png b/docs/pr-screenshots/round16/cases-unknown-390.png new file mode 100644 index 0000000..8a7dce5 Binary files /dev/null and b/docs/pr-screenshots/round16/cases-unknown-390.png differ diff --git a/docs/pr-screenshots/round17/site-cases-expanded-1440.png b/docs/pr-screenshots/round17/site-cases-expanded-1440.png new file mode 100644 index 0000000..8041af8 Binary files /dev/null and b/docs/pr-screenshots/round17/site-cases-expanded-1440.png differ diff --git a/docs/pr-screenshots/round17/site-cases-expanded-390.png b/docs/pr-screenshots/round17/site-cases-expanded-390.png new file mode 100644 index 0000000..d18a403 Binary files /dev/null and b/docs/pr-screenshots/round17/site-cases-expanded-390.png differ diff --git a/docs/pr-screenshots/round17/site-cases-scrolled-1440.png b/docs/pr-screenshots/round17/site-cases-scrolled-1440.png new file mode 100644 index 0000000..b58cc76 Binary files /dev/null and b/docs/pr-screenshots/round17/site-cases-scrolled-1440.png differ diff --git a/docs/pr-screenshots/round17/site-cases-scrolled-390.png b/docs/pr-screenshots/round17/site-cases-scrolled-390.png new file mode 100644 index 0000000..bee840d Binary files /dev/null and b/docs/pr-screenshots/round17/site-cases-scrolled-390.png differ diff --git a/docs/pr-screenshots/round17/sync-error-390.png b/docs/pr-screenshots/round17/sync-error-390.png new file mode 100644 index 0000000..eec214a Binary files /dev/null and b/docs/pr-screenshots/round17/sync-error-390.png differ diff --git a/docs/pr-screenshots/round2/00-items-768.png b/docs/pr-screenshots/round2/00-items-768.png new file mode 100644 index 0000000..40e4068 Binary files /dev/null and b/docs/pr-screenshots/round2/00-items-768.png differ diff --git a/docs/pr-screenshots/round2/01-items-960.png b/docs/pr-screenshots/round2/01-items-960.png new file mode 100644 index 0000000..347ef92 Binary files /dev/null and b/docs/pr-screenshots/round2/01-items-960.png differ diff --git a/docs/pr-screenshots/round2/02-items-1100.png b/docs/pr-screenshots/round2/02-items-1100.png new file mode 100644 index 0000000..18f8b4e Binary files /dev/null and b/docs/pr-screenshots/round2/02-items-1100.png differ diff --git a/docs/pr-screenshots/round2/03-items-1440.png b/docs/pr-screenshots/round2/03-items-1440.png new file mode 100644 index 0000000..d0ff796 Binary files /dev/null and b/docs/pr-screenshots/round2/03-items-1440.png differ diff --git a/docs/pr-screenshots/round2/04-items-960-one-account.png b/docs/pr-screenshots/round2/04-items-960-one-account.png new file mode 100644 index 0000000..1e46697 Binary files /dev/null and b/docs/pr-screenshots/round2/04-items-960-one-account.png differ diff --git a/docs/pr-screenshots/round2/05-mobile-highlights-open.png b/docs/pr-screenshots/round2/05-mobile-highlights-open.png new file mode 100644 index 0000000..2c02b2f Binary files /dev/null and b/docs/pr-screenshots/round2/05-mobile-highlights-open.png differ diff --git a/docs/pr-screenshots/round2/06-desktop-highlights-open.png b/docs/pr-screenshots/round2/06-desktop-highlights-open.png new file mode 100644 index 0000000..7db6b9a Binary files /dev/null and b/docs/pr-screenshots/round2/06-desktop-highlights-open.png differ diff --git a/docs/pr-screenshots/round2/07-same-name-switcher.png b/docs/pr-screenshots/round2/07-same-name-switcher.png new file mode 100644 index 0000000..64db1bf Binary files /dev/null and b/docs/pr-screenshots/round2/07-same-name-switcher.png differ diff --git a/docs/pr-screenshots/round2/08-same-name-locations.png b/docs/pr-screenshots/round2/08-same-name-locations.png new file mode 100644 index 0000000..1fca58c Binary files /dev/null and b/docs/pr-screenshots/round2/08-same-name-locations.png differ diff --git a/docs/pr-screenshots/round2/09-same-name-mobile.png b/docs/pr-screenshots/round2/09-same-name-mobile.png new file mode 100644 index 0000000..fb8eb22 Binary files /dev/null and b/docs/pr-screenshots/round2/09-same-name-mobile.png differ diff --git a/docs/pr-screenshots/round2/10-unnamed-account-trigger.png b/docs/pr-screenshots/round2/10-unnamed-account-trigger.png new file mode 100644 index 0000000..5ddde40 Binary files /dev/null and b/docs/pr-screenshots/round2/10-unnamed-account-trigger.png differ diff --git a/docs/pr-screenshots/round2/11-stale-banner.png b/docs/pr-screenshots/round2/11-stale-banner.png new file mode 100644 index 0000000..ff4a1f7 Binary files /dev/null and b/docs/pr-screenshots/round2/11-stale-banner.png differ diff --git a/docs/pr-screenshots/round2/12-select-open.png b/docs/pr-screenshots/round2/12-select-open.png new file mode 100644 index 0000000..df4152b Binary files /dev/null and b/docs/pr-screenshots/round2/12-select-open.png differ diff --git a/docs/pr-screenshots/round2/13-select-after-tab.png b/docs/pr-screenshots/round2/13-select-after-tab.png new file mode 100644 index 0000000..8c0255f Binary files /dev/null and b/docs/pr-screenshots/round2/13-select-after-tab.png differ diff --git a/docs/pr-screenshots/round2/14-organiser-no-rules.png b/docs/pr-screenshots/round2/14-organiser-no-rules.png new file mode 100644 index 0000000..f4c254f Binary files /dev/null and b/docs/pr-screenshots/round2/14-organiser-no-rules.png differ diff --git a/docs/pr-screenshots/round2/15-organiser-nothing-to-move.png b/docs/pr-screenshots/round2/15-organiser-nothing-to-move.png new file mode 100644 index 0000000..201a77d Binary files /dev/null and b/docs/pr-screenshots/round2/15-organiser-nothing-to-move.png differ diff --git a/docs/pr-screenshots/round2/16-tradeup-empty.png b/docs/pr-screenshots/round2/16-tradeup-empty.png new file mode 100644 index 0000000..c34f7e9 Binary files /dev/null and b/docs/pr-screenshots/round2/16-tradeup-empty.png differ diff --git a/docs/pr-screenshots/round2/17-choose-account.png b/docs/pr-screenshots/round2/17-choose-account.png new file mode 100644 index 0000000..e86ff0f Binary files /dev/null and b/docs/pr-screenshots/round2/17-choose-account.png differ diff --git a/docs/pr-screenshots/round2/18-loading-skeleton.png b/docs/pr-screenshots/round2/18-loading-skeleton.png new file mode 100644 index 0000000..f0fe1ae Binary files /dev/null and b/docs/pr-screenshots/round2/18-loading-skeleton.png differ diff --git a/docs/pr-screenshots/round3/highlight-panel-390.png b/docs/pr-screenshots/round3/highlight-panel-390.png new file mode 100644 index 0000000..23bfb35 Binary files /dev/null and b/docs/pr-screenshots/round3/highlight-panel-390.png differ diff --git a/docs/pr-screenshots/round3/items-768-sort-menu.png b/docs/pr-screenshots/round3/items-768-sort-menu.png new file mode 100644 index 0000000..a59cae4 Binary files /dev/null and b/docs/pr-screenshots/round3/items-768-sort-menu.png differ diff --git a/docs/pr-screenshots/round3/organiser-no-match.png b/docs/pr-screenshots/round3/organiser-no-match.png new file mode 100644 index 0000000..d9eff0e Binary files /dev/null and b/docs/pr-screenshots/round3/organiser-no-match.png differ diff --git a/docs/pr-screenshots/round3/resize-1440-to-768-after.png b/docs/pr-screenshots/round3/resize-1440-to-768-after.png new file mode 100644 index 0000000..5ccada1 Binary files /dev/null and b/docs/pr-screenshots/round3/resize-1440-to-768-after.png differ diff --git a/docs/pr-screenshots/round3/resize-1440-to-768-before.png b/docs/pr-screenshots/round3/resize-1440-to-768-before.png new file mode 100644 index 0000000..a4f93dc Binary files /dev/null and b/docs/pr-screenshots/round3/resize-1440-to-768-before.png differ diff --git a/docs/pr-screenshots/round3/settings-390-scrolled.png b/docs/pr-screenshots/round3/settings-390-scrolled.png new file mode 100644 index 0000000..2d6af90 Binary files /dev/null and b/docs/pr-screenshots/round3/settings-390-scrolled.png differ diff --git a/docs/pr-screenshots/round3/settings-768.png b/docs/pr-screenshots/round3/settings-768.png new file mode 100644 index 0000000..be8746c Binary files /dev/null and b/docs/pr-screenshots/round3/settings-768.png differ diff --git a/docs/pr-screenshots/round3/storage-1100.png b/docs/pr-screenshots/round3/storage-1100.png new file mode 100644 index 0000000..df5ac43 Binary files /dev/null and b/docs/pr-screenshots/round3/storage-1100.png differ diff --git a/docs/pr-screenshots/round3/storage-1440.png b/docs/pr-screenshots/round3/storage-1440.png new file mode 100644 index 0000000..4bfe7d1 Binary files /dev/null and b/docs/pr-screenshots/round3/storage-1440.png differ diff --git a/docs/pr-screenshots/round3/storage-390.png b/docs/pr-screenshots/round3/storage-390.png new file mode 100644 index 0000000..04a6342 Binary files /dev/null and b/docs/pr-screenshots/round3/storage-390.png differ diff --git a/docs/pr-screenshots/round3/storage-768-before.png b/docs/pr-screenshots/round3/storage-768-before.png new file mode 100644 index 0000000..d6728a3 Binary files /dev/null and b/docs/pr-screenshots/round3/storage-768-before.png differ diff --git a/docs/pr-screenshots/round3/storage-768.png b/docs/pr-screenshots/round3/storage-768.png new file mode 100644 index 0000000..65f78cf Binary files /dev/null and b/docs/pr-screenshots/round3/storage-768.png differ diff --git a/docs/pr-screenshots/round3/storage-960.png b/docs/pr-screenshots/round3/storage-960.png new file mode 100644 index 0000000..36f5a2f Binary files /dev/null and b/docs/pr-screenshots/round3/storage-960.png differ diff --git a/docs/pr-screenshots/round3/storage-reload-failed.png b/docs/pr-screenshots/round3/storage-reload-failed.png new file mode 100644 index 0000000..00a3313 Binary files /dev/null and b/docs/pr-screenshots/round3/storage-reload-failed.png differ diff --git a/docs/pr-screenshots/round4/cases-390.png b/docs/pr-screenshots/round4/cases-390.png new file mode 100644 index 0000000..31811d2 Binary files /dev/null and b/docs/pr-screenshots/round4/cases-390.png differ diff --git a/docs/pr-screenshots/round4/cases-odds-1440.png b/docs/pr-screenshots/round4/cases-odds-1440.png new file mode 100644 index 0000000..eadf7fd Binary files /dev/null and b/docs/pr-screenshots/round4/cases-odds-1440.png differ diff --git a/docs/pr-screenshots/round4/cases-odds-390.png b/docs/pr-screenshots/round4/cases-odds-390.png new file mode 100644 index 0000000..1e2f7f1 Binary files /dev/null and b/docs/pr-screenshots/round4/cases-odds-390.png differ diff --git a/docs/pr-screenshots/round4/dashboard-960.png b/docs/pr-screenshots/round4/dashboard-960.png new file mode 100644 index 0000000..17695ee Binary files /dev/null and b/docs/pr-screenshots/round4/dashboard-960.png differ diff --git a/docs/pr-screenshots/round4/phone-all-accounts.png b/docs/pr-screenshots/round4/phone-all-accounts.png new file mode 100644 index 0000000..7d38a88 Binary files /dev/null and b/docs/pr-screenshots/round4/phone-all-accounts.png differ diff --git a/docs/pr-screenshots/round4/phone-highlight-panel.png b/docs/pr-screenshots/round4/phone-highlight-panel.png new file mode 100644 index 0000000..2d82e3b Binary files /dev/null and b/docs/pr-screenshots/round4/phone-highlight-panel.png differ diff --git a/docs/pr-screenshots/round4/retry-focus-visible-1440.png b/docs/pr-screenshots/round4/retry-focus-visible-1440.png new file mode 100644 index 0000000..b959237 Binary files /dev/null and b/docs/pr-screenshots/round4/retry-focus-visible-1440.png differ diff --git a/docs/pr-screenshots/round4/sync-pending-390.png b/docs/pr-screenshots/round4/sync-pending-390.png new file mode 100644 index 0000000..12e9c92 Binary files /dev/null and b/docs/pr-screenshots/round4/sync-pending-390.png differ diff --git a/docs/pr-screenshots/round4/tradeup-390.png b/docs/pr-screenshots/round4/tradeup-390.png new file mode 100644 index 0000000..f46fbc2 Binary files /dev/null and b/docs/pr-screenshots/round4/tradeup-390.png differ diff --git a/docs/pr-screenshots/round4/tradeup-768.png b/docs/pr-screenshots/round4/tradeup-768.png new file mode 100644 index 0000000..f2f1932 Binary files /dev/null and b/docs/pr-screenshots/round4/tradeup-768.png differ diff --git a/docs/pr-screenshots/round5/cases-donut-unknown-1440.png b/docs/pr-screenshots/round5/cases-donut-unknown-1440.png new file mode 100644 index 0000000..613cd3f Binary files /dev/null and b/docs/pr-screenshots/round5/cases-donut-unknown-1440.png differ diff --git a/docs/pr-screenshots/round5/dashboard-hero-390.png b/docs/pr-screenshots/round5/dashboard-hero-390.png new file mode 100644 index 0000000..31e540c Binary files /dev/null and b/docs/pr-screenshots/round5/dashboard-hero-390.png differ diff --git a/docs/pr-screenshots/round5/dashboard-hero-768.png b/docs/pr-screenshots/round5/dashboard-hero-768.png new file mode 100644 index 0000000..b07061a Binary files /dev/null and b/docs/pr-screenshots/round5/dashboard-hero-768.png differ diff --git a/docs/pr-screenshots/round5/items-account-chip-1440.png b/docs/pr-screenshots/round5/items-account-chip-1440.png new file mode 100644 index 0000000..95531ba Binary files /dev/null and b/docs/pr-screenshots/round5/items-account-chip-1440.png differ diff --git a/docs/pr-screenshots/round5/retry-failed-focus-1440.png b/docs/pr-screenshots/round5/retry-failed-focus-1440.png new file mode 100644 index 0000000..3344621 Binary files /dev/null and b/docs/pr-screenshots/round5/retry-failed-focus-1440.png differ diff --git a/docs/pr-screenshots/round5/settings-390.png b/docs/pr-screenshots/round5/settings-390.png new file mode 100644 index 0000000..233109a Binary files /dev/null and b/docs/pr-screenshots/round5/settings-390.png differ diff --git a/docs/pr-screenshots/round5/settings-768.png b/docs/pr-screenshots/round5/settings-768.png new file mode 100644 index 0000000..2cc61ce Binary files /dev/null and b/docs/pr-screenshots/round5/settings-768.png differ diff --git a/docs/pr-screenshots/round5/settings-960.png b/docs/pr-screenshots/round5/settings-960.png new file mode 100644 index 0000000..d2c6bef Binary files /dev/null and b/docs/pr-screenshots/round5/settings-960.png differ diff --git a/docs/pr-screenshots/round5/settings-wide-1100.png b/docs/pr-screenshots/round5/settings-wide-1100.png new file mode 100644 index 0000000..700f54b Binary files /dev/null and b/docs/pr-screenshots/round5/settings-wide-1100.png differ diff --git a/docs/pr-screenshots/round5/settings-wide-1440.png b/docs/pr-screenshots/round5/settings-wide-1440.png new file mode 100644 index 0000000..b0eb146 Binary files /dev/null and b/docs/pr-screenshots/round5/settings-wide-1440.png differ diff --git a/docs/pr-screenshots/round5/tradeup-1440-light.png b/docs/pr-screenshots/round5/tradeup-1440-light.png new file mode 100644 index 0000000..08d8c48 Binary files /dev/null and b/docs/pr-screenshots/round5/tradeup-1440-light.png differ diff --git a/docs/pr-screenshots/round5/tradeup-390.png b/docs/pr-screenshots/round5/tradeup-390.png new file mode 100644 index 0000000..ce09d8d Binary files /dev/null and b/docs/pr-screenshots/round5/tradeup-390.png differ diff --git a/docs/pr-screenshots/round5/tradeup-768.png b/docs/pr-screenshots/round5/tradeup-768.png new file mode 100644 index 0000000..c7df636 Binary files /dev/null and b/docs/pr-screenshots/round5/tradeup-768.png differ diff --git a/docs/pr-screenshots/round6/items-grid-focus-1440.png b/docs/pr-screenshots/round6/items-grid-focus-1440.png new file mode 100644 index 0000000..271fbef Binary files /dev/null and b/docs/pr-screenshots/round6/items-grid-focus-1440.png differ diff --git a/docs/pr-screenshots/round6/items-grid-focus-390.png b/docs/pr-screenshots/round6/items-grid-focus-390.png new file mode 100644 index 0000000..6369211 Binary files /dev/null and b/docs/pr-screenshots/round6/items-grid-focus-390.png differ diff --git a/docs/pr-screenshots/round6/r6-banner-below-1440.png b/docs/pr-screenshots/round6/r6-banner-below-1440.png new file mode 100644 index 0000000..bd93ce5 Binary files /dev/null and b/docs/pr-screenshots/round6/r6-banner-below-1440.png differ diff --git a/docs/pr-screenshots/round6/r6-banner-below-390.png b/docs/pr-screenshots/round6/r6-banner-below-390.png new file mode 100644 index 0000000..32c32dd Binary files /dev/null and b/docs/pr-screenshots/round6/r6-banner-below-390.png differ diff --git a/docs/pr-screenshots/round6/r6-items-compact-390.png b/docs/pr-screenshots/round6/r6-items-compact-390.png new file mode 100644 index 0000000..25eab05 Binary files /dev/null and b/docs/pr-screenshots/round6/r6-items-compact-390.png differ diff --git a/docs/pr-screenshots/round6/r6-items-compact-768.png b/docs/pr-screenshots/round6/r6-items-compact-768.png new file mode 100644 index 0000000..e3daa27 Binary files /dev/null and b/docs/pr-screenshots/round6/r6-items-compact-768.png differ diff --git a/docs/pr-screenshots/round6/r6-root-error-768.png b/docs/pr-screenshots/round6/r6-root-error-768.png new file mode 100644 index 0000000..72782bc Binary files /dev/null and b/docs/pr-screenshots/round6/r6-root-error-768.png differ diff --git a/docs/pr-screenshots/round6/r6-settings-1440.png b/docs/pr-screenshots/round6/r6-settings-1440.png new file mode 100644 index 0000000..ffe9391 Binary files /dev/null and b/docs/pr-screenshots/round6/r6-settings-1440.png differ diff --git a/docs/pr-screenshots/round6/r6-settings-960.png b/docs/pr-screenshots/round6/r6-settings-960.png new file mode 100644 index 0000000..156bdf7 Binary files /dev/null and b/docs/pr-screenshots/round6/r6-settings-960.png differ diff --git a/docs/pr-screenshots/round6/r6-storage-strip-390.png b/docs/pr-screenshots/round6/r6-storage-strip-390.png new file mode 100644 index 0000000..cece6fe Binary files /dev/null and b/docs/pr-screenshots/round6/r6-storage-strip-390.png differ diff --git a/docs/pr-screenshots/round6/r6-storage-strip-768.png b/docs/pr-screenshots/round6/r6-storage-strip-768.png new file mode 100644 index 0000000..0b4b989 Binary files /dev/null and b/docs/pr-screenshots/round6/r6-storage-strip-768.png differ diff --git a/docs/pr-screenshots/round8/items-grid-focus-375.png b/docs/pr-screenshots/round8/items-grid-focus-375.png new file mode 100644 index 0000000..0aa6dd3 Binary files /dev/null and b/docs/pr-screenshots/round8/items-grid-focus-375.png differ diff --git a/docs/pr-screenshots/round8/tradeup-320.png b/docs/pr-screenshots/round8/tradeup-320.png new file mode 100644 index 0000000..0402497 Binary files /dev/null and b/docs/pr-screenshots/round8/tradeup-320.png differ diff --git a/docs/pr-screenshots/round9/disconnected-items-390.png b/docs/pr-screenshots/round9/disconnected-items-390.png new file mode 100644 index 0000000..333b4d1 Binary files /dev/null and b/docs/pr-screenshots/round9/disconnected-items-390.png differ diff --git a/docs/pr-screenshots/round9/tradeup-odds-1024.png b/docs/pr-screenshots/round9/tradeup-odds-1024.png new file mode 100644 index 0000000..72b2fc2 Binary files /dev/null and b/docs/pr-screenshots/round9/tradeup-odds-1024.png differ diff --git a/docs/pr-screenshots/round9/tradeup-odds-320.png b/docs/pr-screenshots/round9/tradeup-odds-320.png new file mode 100644 index 0000000..fb00468 Binary files /dev/null and b/docs/pr-screenshots/round9/tradeup-odds-320.png differ diff --git a/server/accounts/account-manager.ts b/server/accounts/account-manager.ts index 90c5257..0d8ee19 100644 --- a/server/accounts/account-manager.ts +++ b/server/accounts/account-manager.ts @@ -1,6 +1,10 @@ import type { AccountLabel } from "../../src/lib/accounts/accounts.types.ts"; import { AUTH_STATUS } from "../../src/lib/storage/storage.config.ts"; -import type { AccountState, AppSnapshot } from "../../src/lib/storage/storage.types.ts"; +import type { + AccountState, + AppSnapshot, + LoginResult, +} from "../../src/lib/storage/storage.types.ts"; import type { Database } from "../db/db.types.ts"; import type { SavedSession } from "../steam/session-file.ts"; import type { SessionStore } from "../steam/session-store.ts"; @@ -34,6 +38,7 @@ export function createAccountManager({ const listeners = new Set<(snapshot: AppSnapshot) => void>(); let revision = 0; let emitScheduled = false; + let lastLogin: LoginResult | null = null; const login = createQrLogin((session) => void adopt(session)); login.subscribe(() => emit()); @@ -66,6 +71,11 @@ export function createAccountManager({ /** A QR login finished: store the token, register the account and connect it (or reconnect with the new token). */ async function adopt(session: SavedSession): Promise { const existing = gateways.get(session.steamId); + lastLogin = { + sequence: (lastLogin?.sequence ?? 0) + 1, + steamId: session.steamId, + added: existing === undefined, + }; existing?.disconnect(); unsubscribers.get(session.steamId)?.(); sessions.forAccount(session.steamId).save(session); @@ -101,7 +111,7 @@ export function createAccountManager({ snapshot, }); } - return { accounts, login: login.getState(), revision }; + return { accounts, login: login.getState(), lastLogin, revision }; } return { diff --git a/server/cases/case-stats.test.ts b/server/cases/case-stats.test.ts index e8ff2be..05f3c18 100644 --- a/server/cases/case-stats.test.ts +++ b/server/cases/case-stats.test.ts @@ -56,10 +56,40 @@ test("measures the gold drought in both directions", () => { assert.equal(stats.casesToFirstGold, 3); // knife was the 3rd opening ever }); +test("tracks the exact tier of each case pull, including never-pulled tiers", () => { + const stats = buildCaseStats(events, "case"); + assert.deepEqual( + stats.caseRarities.map((rarity) => [ + rarity.key, + rarity.count, + rarity.sinceLast, + rarity.hasDrop, + rarity.uncertain, + ]), + [ + ["gold", 1, 1, true, false], + ["covert", 1, 0, true, false], + ["classified", 1, 2, true, false], + ["restricted", 0, 4, false, false], + ["milSpec", 1, 3, true, false], + ], + ); +}); + +test("marks streaks with unidentified drops as upper bounds", () => { + const unknown = opening(5, "Unknown skin", "b0c3d9", Date.UTC(2026, 3, 5)); + const stats = buildCaseStats([unknown, ...events], "case"); + assert.equal(stats.caseRarities.find((rarity) => rarity.key === "covert")?.sinceLast, 1); + assert.equal(stats.caseRarities.find((rarity) => rarity.key === "covert")?.uncertain, true); + assert.equal(stats.caseRarities.find((rarity) => rarity.key === "restricted")?.sinceLast, 5); + assert.equal(stats.caseRarities.find((rarity) => rarity.key === "restricted")?.uncertain, true); +}); + test("handles an account with no openings", () => { const stats = buildCaseStats([], "case"); assert.equal(stats.casesSinceGold, null); assert.equal(stats.casesToFirstGold, null); + assert.ok(stats.caseRarities.every((rarity) => rarity.sinceLast === 0 && !rarity.hasDrop)); assert.deepEqual(stats.perMonth, []); }); @@ -102,6 +132,7 @@ test("breaks the same history down by kind", () => { assert.equal(capsules.kindCounts.capsule, 1); assert.equal(capsules.topCases[0]?.name, "Test Capsule"); assert.equal(capsules.casesSinceGold, null); + assert.deepEqual(capsules.caseRarities, []); assert.equal(buildCaseStats(all, "case").total, 4); }); diff --git a/server/cases/case-stats.ts b/server/cases/case-stats.ts index f5ef00c..92b1b6d 100644 --- a/server/cases/case-stats.ts +++ b/server/cases/case-stats.ts @@ -2,6 +2,7 @@ import { IMAGE_SIZE_SUFFIX } from "../../src/lib/storage/storage.config.ts"; import { OPENING_KINDS } from "../../src/lib/cases/cases.config.ts"; import type { CaseOpening, + CaseRarityStat, CaseStats, OpeningKind, PulledItem, @@ -124,6 +125,29 @@ export function buildCaseStats(events: readonly HistoryEvent[], kind: OpeningKin color: bucket.color, count: rarityCounts.get(bucket.key) ?? 0, })).filter((slice) => slice.count > 0); + const caseRarities: CaseRarityStat[] = + kind === "case" + ? RARITY_BUCKETS.filter((bucket) => bucket.key !== "other").map((bucket) => { + const latest = cases.findIndex((event) => { + const reward = event.gained[0]; + return reward && bucketKey(reward) === bucket.key; + }); + const sinceLast = latest < 0 ? openings.length : latest; + const uncertain = cases.slice(0, sinceLast).some((event) => { + const reward = event.gained[0]; + return reward && bucketKey(reward) === "other"; + }); + return { + key: bucket.key, + label: bucket.label, + color: bucket.color, + count: rarityCounts.get(bucket.key) ?? 0, + sinceLast, + hasDrop: latest >= 0, + uncertain, + }; + }) + : []; const perMonth = new Map(); for (const opening of [...openings].reverse()) { @@ -150,6 +174,7 @@ export function buildCaseStats(events: readonly HistoryEvent[], kind: OpeningKin : goldIndexNewestFirst, casesToFirstGold: goldIndexOldestFirst < 0 ? null : openings.length - goldIndexOldestFirst, rarity, + caseRarities, rarest: openings .map((opening, index) => ({ opening, index, rank: rarityRank(opening) })) .sort((a, b) => a.rank - b.rank || a.index - b.index) // ties keep newest first diff --git a/server/index.ts b/server/index.ts index a295755..e996640 100644 --- a/server/index.ts +++ b/server/index.ts @@ -7,6 +7,7 @@ import { SSE_KEEP_ALIVE_MS, STORAGE_API, STORAGE_LIMITS, + TRADE_UP_FINDER, } from "../src/lib/storage/storage.config.ts"; import type { ApiError, @@ -541,8 +542,9 @@ const server = createServer(async (req, res) => { c.skin ? [{ view: c.view, skin: c.skin }] : [], ); if (path === STORAGE_API.tradeUpFinder) { - const statTrak = url.searchParams.get("statTrak") ?? "all"; - if (statTrak !== "all" && statTrak !== "normal" && statTrak !== "stattrak") { + const requested = url.searchParams.get("statTrak") ?? TRADE_UP_FINDER.defaultStatTrakFilter; + const statTrak = TRADE_UP_FINDER.statTrakFilters.find((filter) => filter === requested); + if (statTrak === undefined) { throw new UserFacingError("Invalid StatTrak filter", HTTP.badRequest); } return sendJson( diff --git a/server/tradeup-finder.ts b/server/tradeup-finder.ts index f39f4c8..7c44e1c 100644 --- a/server/tradeup-finder.ts +++ b/server/tradeup-finder.ts @@ -3,7 +3,11 @@ import { type TradeUpFinderOptions, type TradeUpFinderResponse, } from "../src/lib/items/item.types.ts"; -import { TRADE_UP, TRADE_UP_FINDER } from "../src/lib/storage/storage.config.ts"; +import { + TRADE_UP, + TRADE_UP_FINDER, + TRADE_UP_STATTRAK_FILTER, +} from "../src/lib/storage/storage.config.ts"; import type { Catalog } from "./items/catalog.types.ts"; import type { PriceBook } from "./prices/prices.ts"; import { computeTradeUp, inputCountFor, isPriced, type TradeUpCandidate } from "./tradeup.ts"; @@ -23,8 +27,10 @@ export function findTradeUps( if (!isPriced(candidate)) return false; if (!options.includeStorage && candidate.view.locationId !== INVENTORY_LOCATION_ID) return false; - if (options.statTrak === "normal" && candidate.skin.isStatTrak) return false; - if (options.statTrak === "stattrak" && !candidate.skin.isStatTrak) return false; + if (options.statTrak === TRADE_UP_STATTRAK_FILTER.normal && candidate.skin.isStatTrak) + return false; + if (options.statTrak === TRADE_UP_STATTRAK_FILTER.statTrak && !candidate.skin.isStatTrak) + return false; if (options.rarityId === TRADE_UP.covertRarity) { return ( candidate.skin.skinId !== null && catalog.specialsFor(candidate.skin.skinId).length > 0 diff --git a/site/plugins/app-source.config.ts b/site/plugins/app-source.config.ts index 310a656..fd249de 100644 --- a/site/plugins/app-source.config.ts +++ b/site/plugins/app-source.config.ts @@ -71,20 +71,7 @@ export const APP_PATCHES: readonly AppPatch[] = [ }, ], }, - { - file: "components/tradeup-inputs/tradeup-inputs.component.tsx", - replacements: [ - { - from: "title={`${item.name} · ${copy.remove}`}", - to: "title={`${item.name} · ${copy.remove}`}\n aria-label={`${item.name} · ${copy.remove}`}", - }, - ], - }, // Contrast and hover feedback on already-active controls. - { - file: "components/kind-pills/kind-pills.component.tsx", - replacements: [{ from: "tabular opacity-60", to: "tabular opacity-100" }], - }, { file: "components/pill/pill.component.tsx", replacements: [ diff --git a/site/src/components/features/feature-demo.tsx b/site/src/components/features/feature-demo.tsx index 5042030..36f98ec 100644 --- a/site/src/components/features/feature-demo.tsx +++ b/site/src/components/features/feature-demo.tsx @@ -21,7 +21,10 @@ const DEMOS: Record ReactNode> = { export default function FeatureDemo({ id }: { id: string }) { const Demo = DEMOS[id]; const boxRef = useRef(null); + const dialogRef = useRef(null); + const openRef = useRef(null); const [scale, setScale] = useState(DEMO_MIN_SCALE); + const [expanded, setExpanded] = useState(false); useEffect(() => { const el = boxRef.current; @@ -33,6 +36,10 @@ export default function FeatureDemo({ id }: { id: string }) { return () => ro.disconnect(); }, []); + useEffect(() => { + if (expanded) dialogRef.current?.showModal(); + }, [expanded]); + if (!Demo) return null; return (
@@ -42,6 +49,30 @@ export default function FeatureDemo({ id }: { id: string }) { >
+ + {expanded && ( + { + setExpanded(false); + openRef.current?.focus(); + }} + > +
+ OpenInventory · {id} demo + +
+
+ +
+
+ )} ); } diff --git a/site/src/mock/data/cases.mock.ts b/site/src/mock/data/cases.mock.ts index 79b1ecc..a9f9b09 100644 --- a/site/src/mock/data/cases.mock.ts +++ b/site/src/mock/data/cases.mock.ts @@ -57,6 +57,31 @@ const RARITY_BASE: readonly RaritySlice[] = [ { label: "Other", color: "b0c3d9", count: 2 }, ]; +const OTHER_RARITIES: Record, readonly RaritySlice[]> = { + souvenir: [ + { label: "Consumer", color: "b0c3d9", count: 25 }, + { label: "Industrial", color: "5e98d9", count: 5 }, + { label: "Mil-Spec", color: "4b69ff", count: 1 }, + ], + capsule: [ + { label: "High Grade", color: "4b69ff", count: 800 }, + { label: "Remarkable", color: "8847ff", count: 160 }, + { label: "Exotic", color: "d32ce6", count: 32 }, + { label: "Extraordinary", color: "eb4b4b", count: 5 }, + ], + tradeUp: [ + { label: "Restricted", color: "8847ff", count: 10 }, + { label: "Classified", color: "d32ce6", count: 5 }, + { label: "Covert", color: "eb4b4b", count: 1 }, + ], + drop: [ + { label: "Consumer", color: "b0c3d9", count: 320 }, + { label: "Industrial", color: "5e98d9", count: 115 }, + { label: "Mil-Spec", color: "4b69ff", count: 29 }, + { label: "Restricted", color: "8847ff", count: 2 }, + ], +}; + type OpeningSeed = { readonly image: string; readonly name: string; @@ -128,6 +153,59 @@ const RAREST: readonly CaseOpening[] = [ }), ]; +const OTHER_RAREST: Record, readonly CaseOpening[]> = { + souvenir: [ + opening({ + image: "", + name: "Souvenir AK-47 | Safari Mesh", + source: "Major Souvenir Package", + daysAgo: 120, + }), + opening({ + image: "", + name: "Souvenir P250 | Sand Dune", + source: "Major Souvenir Package", + daysAgo: 240, + }), + ], + capsule: [ + opening({ + image: "Sticker | Miami Stabbyfish", + name: "Sticker | Miami Stabbyfish", + source: "Sticker Capsule", + daysAgo: 12, + }), + opening({ + image: "Sticker | Blue Lethal", + name: "Sticker | Blue Lethal", + source: "Sticker Capsule", + daysAgo: 17, + }), + ], + tradeUp: [ + { + ...opening({ + image: "M4A1-S | Decimator (Field-Tested)", + name: "M4A1-S | Decimator", + source: "", + daysAgo: 23, + }), + caseName: null, + }, + ], + drop: [ + { + ...opening({ + image: "P90 | Trigon (Field-Tested)", + name: "P90 | Trigon", + source: "", + daysAgo: 4, + }), + caseName: null, + }, + ], +}; + const pulled = (image: string, name: string, count: number): PulledItem => ({ name, imageUrl: itemImage(image), @@ -142,30 +220,83 @@ function stats(kind: OpeningKind): CaseStats { month: monthLabel(i), count: Math.round(c * scale), })); - const rarity = RARITY_BASE.map((r) => ({ - ...r, - count: kind === "case" ? r.count : Math.max(1, Math.round(r.count * scale)), - })); + const rarity = kind === "case" ? RARITY_BASE : OTHER_RARITIES[kind]; + const rarest = kind === "case" ? RAREST : OTHER_RAREST[kind]; return { kind, kindCounts: KIND_COUNTS, total, gold: kind === "case" ? 1 : 0, - statTrak: kind === "case" ? 39 : kind === "souvenir" ? 3 : 0, + statTrak: kind === "case" ? 39 : 0, firstOpenedAt: MOCK_NOW - 3446 * DAY_MS, lastOpenedAt: MOCK_NOW - 3 * DAY_MS, casesSinceGold: kind === "case" ? 297 : null, casesToFirstGold: kind === "case" ? 115 : null, rarity, - rarest: + caseRarities: kind === "case" - ? RAREST - : RAREST.slice(0, 5).map((o, i) => ({ ...o, openedAt: o.openedAt + i * 31 * DAY_MS })), - topItems: [ - pulled("AK-47 | Redline (Field-Tested)", "AK-47 | Redline", 7), - pulled("M4A4 | Poly Mag (Minimal Wear)", "M4A4 | Poly Mag", 6), - pulled("Negev | Drop Me (Field-Tested)", "Negev | Drop Me", 5), - ], + ? [ + { + key: "gold", + label: "Gold", + color: "d4af37", + count: 1, + sinceLast: 297, + hasDrop: true, + uncertain: false, + }, + { + key: "covert", + label: "Covert", + color: "eb4b4b", + count: 2, + sinceLast: 54, + hasDrop: true, + uncertain: false, + }, + { + key: "classified", + label: "Classified", + color: "d32ce6", + count: 8, + sinceLast: 16, + hasDrop: true, + uncertain: false, + }, + { + key: "restricted", + label: "Restricted", + color: "8847ff", + count: 63, + sinceLast: 6, + hasDrop: true, + uncertain: false, + }, + { + key: "milSpec", + label: "Mil-Spec", + color: "4b69ff", + count: 336, + sinceLast: 0, + hasDrop: true, + uncertain: false, + }, + ] + : [], + rarest, + topItems: + kind === "case" + ? [ + pulled("AK-47 | Redline (Field-Tested)", "AK-47 | Redline", 7), + pulled("M4A4 | Poly Mag (Minimal Wear)", "M4A4 | Poly Mag", 6), + pulled("Negev | Drop Me (Field-Tested)", "Negev | Drop Me", 5), + ] + : rarest.map((item) => ({ + name: item.itemName, + imageUrl: item.imageUrl, + color: item.color, + count: 1, + })), topCases: kind === "case" ? [ diff --git a/site/src/mock/data/storage-client.mock.ts b/site/src/mock/data/storage-client.mock.ts index 07de6c7..bd68043 100644 --- a/site/src/mock/data/storage-client.mock.ts +++ b/site/src/mock/data/storage-client.mock.ts @@ -1,4 +1,5 @@ -import type { TransferRequest } from "~/lib/storage/storage.types"; +import { AUTH_STATUS } from "~/lib/storage/storage.config"; +import type { AppSnapshot, TransferRequest } from "~/lib/storage/storage.types"; type Handler = (request: TransferRequest) => Promise; @@ -12,3 +13,15 @@ export function setTransferHandler(next: Handler | undefined): void { export async function postJson(_path: string, body: unknown): Promise { await handler?.(body as TransferRequest); } + +/** The demo has no live server: no tracked accounts, so components fall back to single-account copy. */ +const DEMO_SNAPSHOT: AppSnapshot = { + accounts: [], + login: { status: AUTH_STATUS.signedOut }, + lastLogin: null, + revision: 0, +}; + +export function useAppSnapshot(): AppSnapshot { + return DEMO_SNAPSHOT; +} diff --git a/site/src/mock/demo/cases-demo.tsx b/site/src/mock/demo/cases-demo.tsx index 7bed077..6a8cfbc 100644 --- a/site/src/mock/demo/cases-demo.tsx +++ b/site/src/mock/demo/cases-demo.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react"; import { BarList } from "~/components/bar-list/bar-list.component"; import { CaseSummary } from "~/components/case-summary/case-summary.component"; +import { CaseRarityInsights } from "~/components/case-rarity-insights/case-rarity-insights.component"; import { DonutChart } from "~/components/donut-chart/donut-chart.component"; import { KindPills } from "~/components/kind-pills/kind-pills.component"; import { MonthlyBars } from "~/components/monthly-bars/monthly-bars.component"; @@ -28,6 +29,7 @@ export default function CasesDemo() { + {kind === "case" && }
- + - +
diff --git a/site/src/mock/demo/dashboard-demo.tsx b/site/src/mock/demo/dashboard-demo.tsx index 07a3244..b6bf7e9 100644 --- a/site/src/mock/demo/dashboard-demo.tsx +++ b/site/src/mock/demo/dashboard-demo.tsx @@ -8,7 +8,7 @@ export default function DashboardDemo() { return (
- +
); } diff --git a/site/src/mock/demo/storage-demo.tsx b/site/src/mock/demo/storage-demo.tsx index 13129e4..65ddb50 100644 --- a/site/src/mock/demo/storage-demo.tsx +++ b/site/src/mock/demo/storage-demo.tsx @@ -7,7 +7,8 @@ import { COPY } from "~/lib/copy/copy.config"; import { itemRowKey } from "~/lib/items/item-key"; import type { ItemView } from "~/lib/items/item.types"; import { MOCK_CURRENCY } from "@demo/data/mock.config"; -import { formatInteger, formatMoney } from "~/utils/format"; +import { formatMoney } from "~/utils/format"; +import { formatCount } from "~/utils/plural"; import { DemoTransferStatus } from "./demo-transfer-status"; import { useDemoInventory } from "./use-demo-inventory"; @@ -57,7 +58,7 @@ export default function StorageDemo() { {selected && (

{selected.name} ·{" "} - {formatInteger(selected.itemCount)} {copy.items} · {copy.value}{" "} + {formatCount(selected.itemCount, copy.items)} · {copy.value}{" "} {formatMoney(selectedValue, MOCK_CURRENCY)} diff --git a/site/src/styles/index.css b/site/src/styles/index.css index 38d6fb1..8d73997 100644 --- a/site/src/styles/index.css +++ b/site/src/styles/index.css @@ -157,7 +157,7 @@ --accent: rgb(20 22 26 / 0.05); --primary: oklch(52% 0.21 264); --success: #047857; - --destructive: #dc2626; + --destructive: #b91c1c; --warning: #b45309; --skeleton: rgb(20 22 26 / 0.06); --skeleton-highlight: rgb(20 22 26 / 0.12); diff --git a/site/src/styles/sections.css b/site/src/styles/sections.css index 5fce8a9..c265628 100644 --- a/site/src/styles/sections.css +++ b/site/src/styles/sections.css @@ -728,6 +728,72 @@ transform-origin: 0 0; overflow: hidden; } +.fdemo__open { + position: absolute; + z-index: 2; + right: 1rem; + bottom: 1rem; + padding: 0.6rem 0.85rem; + border: 1px solid var(--c-line); + border-radius: 0.5rem; + background: var(--c-surface); + color: var(--c-fg); + font: inherit; + font-size: var(--t-s); + font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 18px rgb(0 0 0 / 0.22); +} +.fdemo__open:hover, +.fdemo__dialog-head button:hover { + background: var(--c-bg); +} +.fdemo__open:focus-visible, +.fdemo__dialog-head button:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: 2px; +} +.fdemo__dialog { + width: min(96vw, 1200px); + height: min(92vh, 900px); + max-width: none; + max-height: none; + padding: 0; + margin: auto; + border: 1px solid var(--c-line); + border-radius: 1rem; + background: var(--c-app-bg); + color: var(--c-fg); + overflow: hidden; +} +.fdemo__dialog::backdrop { + background: rgb(0 0 0 / 0.7); +} +.fdemo__dialog-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.7rem 1rem; + border-bottom: 1px solid var(--c-line); + background: var(--c-surface); + font-size: var(--t-s); + font-weight: 600; +} +.fdemo__dialog-head button { + padding: 0.4rem 0.6rem; + border: 1px solid var(--c-line); + border-radius: 0.4rem; + background: var(--c-surface); + color: var(--c-fg); + font: inherit; + cursor: pointer; +} +.fdemo__dialog-body { + height: calc(100% - 3.2rem); + overflow: auto; + overscroll-behavior: contain; +} .step { display: grid; @@ -872,6 +938,11 @@ .step__n { color: var(--c-accent); } +@media (prefers-color-scheme: light) { + .step__n { + color: #754000; + } +} .step__body { display: grid; gap: 0.5rem; diff --git a/src/components/account-actions/account-actions.component.tsx b/src/components/account-actions/account-actions.component.tsx new file mode 100644 index 0000000..49a8ba9 --- /dev/null +++ b/src/components/account-actions/account-actions.component.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "~/components/button/button.component"; +import { PAGE_HEADER } from "~/components/page-header/page-header.config"; +import { useAddAccountDialog } from "~/lib/accounts/add-account-dialog.store"; +import { COPY } from "~/lib/copy/copy.config"; +import { postJson } from "~/lib/storage/storage.client"; +import { STORAGE_API } from "~/lib/storage/storage.config"; +import { cn } from "~/utils/cn"; +import { restoreLostFocus } from "~/utils/restore-focus"; +import { ACCOUNT_ACTIONS } from "./account-actions.config"; +import type { AccountActionsProps } from "./account-actions.types"; + +/** The two ways to fix a broken account: retry its saved login, or sign in to it again. */ +export function AccountActions({ steamId, className }: AccountActionsProps) { + const copy = COPY.accounts; + const openReauth = useAddAccountDialog((state) => state.openReauth); + const [retrying, setRetrying] = useState(false); + const retryingRef = useRef(false); + const reconnectStarted = useRef(false); + const [failure, setFailure] = useState(); + + useEffect( + () => () => { + // A successful reconnect can replace this whole card before its request resolves. + if (reconnectStarted.current) + requestAnimationFrame(() => + restoreLostFocus(document.getElementById(PAGE_HEADER.headingId)), + ); + }, + [], + ); + + async function reconnect() { + if (retryingRef.current) return; + retryingRef.current = true; + reconnectStarted.current = true; + setRetrying(true); + setFailure(undefined); + try { + await postJson(STORAGE_API.accountReconnect, { accountId: steamId }); + } catch (error) { + reconnectStarted.current = false; + setFailure(error instanceof Error ? error.message : String(error)); + } finally { + retryingRef.current = false; + setRetrying(false); + } + } + + return ( +

+
+ + + {retrying ? copy.reconnecting : ""} + + +
+ {failure && ( +

+ {failure} +

+ )} +
+ ); +} diff --git a/src/components/account-actions/account-actions.config.ts b/src/components/account-actions/account-actions.config.ts new file mode 100644 index 0000000..6191c6b --- /dev/null +++ b/src/components/account-actions/account-actions.config.ts @@ -0,0 +1,5 @@ +export const ACCOUNT_ACTIONS = { + rootClass: "flex flex-col gap-2", + buttonsClass: "flex flex-wrap gap-2", + errorClass: "text-xs text-destructive", +} as const; diff --git a/src/components/account-actions/account-actions.types.ts b/src/components/account-actions/account-actions.types.ts new file mode 100644 index 0000000..f15a8b9 --- /dev/null +++ b/src/components/account-actions/account-actions.types.ts @@ -0,0 +1,4 @@ +export type AccountActionsProps = { + readonly steamId: string; + readonly className?: string; +}; diff --git a/src/components/account-gate/account-gate.component.tsx b/src/components/account-gate/account-gate.component.tsx index 120c3d2..4ea22dd 100644 --- a/src/components/account-gate/account-gate.component.tsx +++ b/src/components/account-gate/account-gate.component.tsx @@ -1,24 +1,51 @@ -import { useEffect, useRef } from "react"; +import { Link } from "@tanstack/react-router"; +import { TriangleAlert } from "lucide-react"; +import { Fragment, useEffect, useId, useLayoutEffect, useRef } from "react"; +import { AccountActions } from "~/components/account-actions/account-actions.component"; import { AccountStatus } from "~/components/account-status/account-status.component"; +import { AccountStatusDot } from "~/components/account-status-dot/account-status-dot.component"; import { Button } from "~/components/button/button.component"; -import { accountName, useAccountScope } from "~/lib/accounts/account-scope.hooks"; +import { PageHeader } from "~/components/page-header/page-header.component"; +import { PAGE_HEADER } from "~/components/page-header/page-header.config"; +import { accountDisplayName, accountNameWithId } from "~/lib/accounts/account-names"; +import { useAccountScope } from "~/lib/accounts/account-scope.hooks"; import { useAccountSelection } from "~/lib/accounts/account-scope.store"; import { ALL_ACCOUNTS_ROUTES, UNGATED_ROUTES } from "~/lib/accounts/accounts.config"; import { COPY } from "~/lib/copy/copy.config"; import { postJson } from "~/lib/storage/storage.client"; import { AUTH_STATUS, STORAGE_API } from "~/lib/storage/storage.config"; +import { restoreLostFocus } from "~/utils/restore-focus"; +import { ACCOUNT_GATE } from "./account-gate.config"; import type { AccountGateProps } from "./account-gate.types"; /** * Decides what a page sees: its account's data once connected, the account's progress or * error while not, or a prompt to pick an account on pages that act on exactly one. * Accounts connect lazily, so viewing one is what asks the server to connect it. + * Pages remount when the account changes, so filters, selections and drafts never carry over. */ export function AccountGate({ pathname, children }: AccountGateProps) { - const { accounts, scoped, isAll } = useAccountScope(); + const { accounts, scope, scoped, isAll } = useAccountScope(); const select = useAccountSelection((state) => state.select); const asked = useRef(new Set()); + const chooseTitleId = useId(); const copy = COPY.accounts; + const hasReadyAccount = scoped.some( + (account) => account.snapshot.auth.status === AUTH_STATUS.ready, + ); + const previousReadiness = useRef({ scope, hasReadyAccount }); + + useLayoutEffect(() => { + const previous = previousReadiness.current; + if ( + previous.scope === scope && + !previous.hasReadyAccount && + hasReadyAccount && + !UNGATED_ROUTES.includes(pathname) + ) + restoreLostFocus(document.getElementById(PAGE_HEADER.headingId)); + previousReadiness.current = { scope, hasReadyAccount }; + }, [scope, hasReadyAccount, pathname]); useEffect(() => { for (const account of scoped) { @@ -31,38 +58,91 @@ export function AccountGate({ pathname, children }: AccountGateProps) { } }, [scoped]); - if (UNGATED_ROUTES.includes(pathname)) return <>{children}; + // Ungated pages such as Settings do not hold account-scoped drafts. Keep them mounted when + // removing the selected account changes scope, so their focus recovery can finish. + const page = ( + {children} + ); + if (UNGATED_ROUTES.includes(pathname)) return page; if (isAll && !ALL_ACCOUNTS_ROUTES.includes(pathname)) { + const pageTitle = ACCOUNT_GATE.pageTitles[pathname]; return ( -
-

{copy.chooseOneTitle}

-

{copy.chooseOneBody}

-
- {accounts.map((account) => ( - - ))} -
-
+ <> + {pageTitle && } +
+

+ {copy.chooseOneTitle} +

+

+ {copy.chooseOneBody[pathname] ?? copy.chooseOneFallback} +

+
+ {accounts.map((account) => { + const { name, suffix } = accountDisplayName(account); + return ( + + ); + })} +
+
+ ); } const ready = scoped.filter((account) => account.snapshot.auth.status === AUTH_STATUS.ready); if (ready.length === 0) { + const pageTitle = ACCOUNT_GATE.pageTitles[pathname]; return ( -
- {scoped.map((account) => ( - - ))} -
+ <> + {pageTitle && } +
+ {scoped.map((account) => ( + + ))} +
+ ); } - const waiting = scoped.length - ready.length; + const waiting = scoped.filter((account) => account.snapshot.auth.status !== AUTH_STATUS.ready); + // One broken account can be fixed right here; otherwise every row in settings has its own actions. + const [fixable] = + waiting.length === 1 && waiting[0]?.snapshot.auth.status === AUTH_STATUS.error ? waiting : []; + const notice = waiting.length > 0 ? copy.partialNotice(waiting.map(accountNameWithId)) : ""; return ( <> - {waiting > 0 &&

{copy.partialNotice(waiting)}

} - {children} + {/* Always mounted, so a newly dropped account is announced politely. */} +

+ {notice} +

+ {notice && ( +
+

+ + {notice} +

+ + {fixable && } + + {copy.partialSettings} + + +
+ )} + {page} ); } diff --git a/src/components/account-gate/account-gate.config.ts b/src/components/account-gate/account-gate.config.ts new file mode 100644 index 0000000..b7473a2 --- /dev/null +++ b/src/components/account-gate/account-gate.config.ts @@ -0,0 +1,20 @@ +import { COPY } from "~/lib/copy/copy.config"; + +export const ACCOUNT_GATE = { + /** Page headings for the routes that ask for one account, so the prompt keeps the page's name. */ + pageTitles: { + "/": COPY.dashboard.title, + "/items": COPY.items.title, + "/storage": COPY.storage.title, + "/terminals": COPY.terminal.pageTitle, + "/tradeup": COPY.tradeup.title, + "/cases": COPY.cases.title, + } as Readonly>, + cardClass: "rounded-lg border bg-card p-4", + /** Foreground text, so the link clears 4.5:1 on the tinted banner in both themes. */ + settingsLinkClass: + "inline-flex h-7 items-center whitespace-nowrap rounded-md px-2.5 font-medium text-foreground underline-offset-2 hover:bg-muted hover:underline", + bannerClass: + "flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 rounded-md border border-warning/40 bg-warning/5 px-3 py-2 text-xs", + bannerIconSize: 14, +} as const; diff --git a/src/components/account-name/account-name.component.tsx b/src/components/account-name/account-name.component.tsx new file mode 100644 index 0000000..546ec99 --- /dev/null +++ b/src/components/account-name/account-name.component.tsx @@ -0,0 +1,13 @@ +import { cn } from "~/utils/cn"; +import { ACCOUNT_NAME } from "./account-name.config"; +import type { AccountNameProps } from "./account-name.types"; + +/** An account's name, truncating, followed by its SteamID tail, which always stays visible. */ +export function AccountName({ name, className }: AccountNameProps) { + return ( + + {name.name} + {name.suffix !== null && {name.suffix}} + + ); +} diff --git a/src/components/account-name/account-name.config.ts b/src/components/account-name/account-name.config.ts new file mode 100644 index 0000000..4340e6d --- /dev/null +++ b/src/components/account-name/account-name.config.ts @@ -0,0 +1,9 @@ +export const ACCOUNT_NAME = { + rootClass: "flex min-w-0 items-baseline gap-1", + nameClass: "truncate", + /** + * The SteamID tail never shrinks, so same-named accounts stay apart however narrow the spot. + * It takes the name's own colour (no fading), so it passes contrast wherever the name does. + */ + suffixClass: "shrink-0 text-[11px] tabular", +} as const; diff --git a/src/components/account-name/account-name.types.ts b/src/components/account-name/account-name.types.ts new file mode 100644 index 0000000..3f9a669 --- /dev/null +++ b/src/components/account-name/account-name.types.ts @@ -0,0 +1,6 @@ +import type { AccountDisplayName } from "~/lib/accounts/accounts.types"; + +export type AccountNameProps = { + readonly name: AccountDisplayName; + readonly className?: string; +}; diff --git a/src/components/account-row/account-row.component.tsx b/src/components/account-row/account-row.component.tsx new file mode 100644 index 0000000..bdc279c --- /dev/null +++ b/src/components/account-row/account-row.component.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { AccountActions } from "~/components/account-actions/account-actions.component"; +import { AccountStatusDot } from "~/components/account-status-dot/account-status-dot.component"; +import { Button } from "~/components/button/button.component"; +import { DangerDialog } from "~/components/danger-dialog/danger-dialog.component"; +import { Select } from "~/components/select/select.component"; +import { AccountName } from "~/components/account-name/account-name.component"; +import { accountDisplayName, accountNameWithId } from "~/lib/accounts/account-names"; +import { authStatusText } from "~/lib/accounts/account-status"; +import { ACCOUNT_LABELS } from "~/lib/accounts/accounts.config"; +import type { AccountLabel } from "~/lib/accounts/accounts.types"; +import { COPY } from "~/lib/copy/copy.config"; +import { postJson } from "~/lib/storage/storage.client"; +import { AUTH_STATUS, STORAGE_API } from "~/lib/storage/storage.config"; +import { ACCOUNT_ROW } from "./account-row.config"; +import type { AccountRowProps } from "./account-row.types"; + +function toLabel(id: string): AccountLabel | null { + return ACCOUNT_LABELS.find((label) => label === id) ?? null; +} + +/** One tracked account in settings: its status, how to fix it, its label, and removing it. */ +export function AccountRow({ account, onRemoved }: AccountRowProps) { + const copy = COPY.accounts; + const [removing, setRemoving] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(); + const { auth } = account.snapshot; + + async function setLabel(id: string) { + // The select stays enabled while saving, so it keeps focus; a change mid-save is ignored. + if (saving) return; + setError(undefined); + setSaving(true); + try { + await postJson(STORAGE_API.accountLabel, { + accountId: account.steamId, + label: toLabel(id), + }); + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + setSaving(false); + } + } + + return ( +
  • +
    +
    + + + {authStatusText(auth.status)} +
    +
    + + {saving ? copy.labelSaving : ""} + + + + {description.filter(Boolean).join(", ")} +
    ); diff --git a/src/components/account-switcher/account-switcher.config.ts b/src/components/account-switcher/account-switcher.config.ts index 2f7efe6..a5d1467 100644 --- a/src/components/account-switcher/account-switcher.config.ts +++ b/src/components/account-switcher/account-switcher.config.ts @@ -1,4 +1,9 @@ export const ACCOUNT_SWITCHER = { - addIconSize: 14, + addIconSize: 16, selectWidth: "min-w-0 flex-1", + /** Wide enough for a long persona name, its SteamID tail and its status line. */ + listMinWidth: 272, + /** At least 36px square, so the add button is an easy touch target. */ + addButtonClass: + "flex size-9 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground", } as const; diff --git a/src/components/accounts-panel/accounts-panel.component.tsx b/src/components/accounts-panel/accounts-panel.component.tsx index 715eb71..df7d9c7 100644 --- a/src/components/accounts-panel/accounts-panel.component.tsx +++ b/src/components/accounts-panel/accounts-panel.component.tsx @@ -1,103 +1,39 @@ import { Plus } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { AccountRow } from "~/components/account-row/account-row.component"; import { Button } from "~/components/button/button.component"; -import { DangerDialog } from "~/components/danger-dialog/danger-dialog.component"; import { Panel } from "~/components/panel/panel.component"; -import { Select } from "~/components/select/select.component"; -import { accountName } from "~/lib/accounts/account-scope.hooks"; -import { authDotClass, authStatusText } from "~/lib/accounts/account-status"; -import { ACCOUNT_LABELS } from "~/lib/accounts/accounts.config"; -import type { AccountLabel } from "~/lib/accounts/accounts.types"; import { useAddAccountDialog } from "~/lib/accounts/add-account-dialog.store"; import { COPY } from "~/lib/copy/copy.config"; -import { postJson, useAppSnapshot } from "~/lib/storage/storage.client"; -import { STORAGE_API } from "~/lib/storage/storage.config"; -import type { AccountState } from "~/lib/storage/storage.types"; -import { cn } from "~/utils/cn"; +import { useAppSnapshot } from "~/lib/storage/storage.client"; +import { restoreLostFocus } from "~/utils/restore-focus"; import { ACCOUNTS_PANEL } from "./accounts-panel.config"; -function toLabel(id: string): AccountLabel | null { - return ACCOUNT_LABELS.find((label) => label === id) ?? null; -} - -function AccountRow({ account }: { readonly account: AccountState }) { - const copy = COPY.accounts; - const [removing, setRemoving] = useState(false); - const [error, setError] = useState(); - const name = accountName(account); - - async function setLabel(id: string) { - setError(undefined); - try { - await postJson(STORAGE_API.accountLabel, { - accountId: account.steamId, - label: toLabel(id), - }); - } catch (failure) { - setError(failure instanceof Error ? failure.message : String(failure)); - } - } - - return ( -
  • -
    -
    - - {name} - - {authStatusText(account.snapshot.auth)} - -
    -
    - ({
    )} - {columns.map((column) => { - const active = sort?.columnId === column.id; + {columns.map((column, index) => { + if (sortMenu && index === 0) + return ( +
    + selection.onToggle(key)} +
    )} {columns.map((column) => ( @@ -250,7 +329,7 @@ export function DataTable({ )}
    - {formatInteger(rows.length)} {DATA_TABLE.rowCountSuffix} + {formatCount(rows.length, DATA_TABLE.rowCount)}
    ); diff --git a/src/components/data-table/data-table.config.ts b/src/components/data-table/data-table.config.ts index bf5e63e..c2383d2 100644 --- a/src/components/data-table/data-table.config.ts +++ b/src/components/data-table/data-table.config.ts @@ -1,11 +1,37 @@ +/** Rows and the header inset their cells by this much on each side (px). */ +const EDGE_PADDING = 16; + export const DATA_TABLE = { rowHeight: 40, overscan: 12, sortAscendingLabel: "ascending", sortDescendingLabel: "descending", emptyLabel: "No rows", - rowCountSuffix: "rows", - selectionColumnWidth: "28px", + /** Pluralised row count in the footer. */ + rowCount: { one: "row", other: "rows" }, + /** Checkbox column in px; touch screens get a 44px hit area instead of the 16px box. */ + selectionColumnWidth: 28, + touchSelectionColumnWidth: 44, + touchQuery: "(pointer: coarse), (max-width: 639px)", + checkboxClass: "flex h-9 items-center", + /** The label around the checkbox is the hit area: 44px tall, the column's full width. */ + touchCheckboxClass: "flex h-11 w-full items-center", + /** Gap between columns, so two right-aligned headers never run together. */ + columnGap: 12, + edgePadding: EDGE_PADDING, + /** Both edges' padding, which the fitted columns can't use. */ + horizontalPadding: EDGE_PADDING * 2, + /** + * The table never squeezes below this height; on a short window the page scrolls instead. + * Shared with the card grid, which sits in the same spot. + */ + frameClass: "flex min-h-72 flex-1 flex-col rounded-lg border bg-card", + /** A stable gutter keeps the rows' width (and so the chosen layout) the same with or without a scrollbar. */ + scrollClass: "min-h-0 flex-1 overflow-auto [scrollbar-gutter:stable]", + skeletonScrollClass: "min-h-0 flex-1 overflow-hidden [scrollbar-gutter:stable]", + headerClass: "grid shrink-0 border-b text-xs text-muted-foreground", + sortSelectClass: "w-full max-w-48", + sortIconSize: 12, selectAllLabel: "Select all rows", selectRowLabel: "Select row", keys: { diff --git a/src/components/data-table/data-table.hooks.ts b/src/components/data-table/data-table.hooks.ts new file mode 100644 index 0000000..66ac6e4 --- /dev/null +++ b/src/components/data-table/data-table.hooks.ts @@ -0,0 +1,40 @@ +import { useMemo, useRef } from "react"; +import { useMediaQuery } from "~/utils/use-media-query"; +import { useScrollArea } from "~/utils/use-scroll-area"; +import { DATA_TABLE } from "./data-table.config"; +import type { FittableColumn } from "./data-table.types"; +import { columnTrack, fitLayout } from "./data-table.utils"; + +/** + * The columns that fit the table's own scroll area and their shared grid template. Attach `ref` + * to the element the rows scroll in; the header, rows and skeleton all use the same template, + * and the header pads its right edge by `gutter` so it lines up with rows beside a scrollbar. + */ +export function useFittedColumns( + columns: readonly C[], + compactColumns: readonly C[] | undefined, + selectable: boolean, +) { + const ref = useRef(null); + const area = useScrollArea(ref); + const touch = useMediaQuery(DATA_TABLE.touchQuery); + const selectionWidth = touch + ? DATA_TABLE.touchSelectionColumnWidth + : DATA_TABLE.selectionColumnWidth; + return useMemo(() => { + const leading = selectable ? [selectionWidth] : []; + const fitted = fitLayout(columns, compactColumns, area?.width, leading); + const gridTemplate = [ + ...leading.map((width) => `${width}px`), + ...fitted.columns.map(columnTrack), + ].join(" "); + return { + ref, + touch, + compact: fitted.compact, + columns: fitted.columns, + gridTemplate, + gutter: area?.gutter ?? 0, + }; + }, [columns, compactColumns, area, selectable, selectionWidth, touch]); +} diff --git a/src/components/data-table/data-table.skeleton.tsx b/src/components/data-table/data-table.skeleton.tsx index 17c9bfb..67fce53 100644 --- a/src/components/data-table/data-table.skeleton.tsx +++ b/src/components/data-table/data-table.skeleton.tsx @@ -1,26 +1,38 @@ import { Skeleton } from "~/components/skeleton/skeleton.component"; import { cn } from "~/utils/cn"; import { DATA_TABLE } from "./data-table.config"; +import { useFittedColumns } from "./data-table.hooks"; import type { DataTableSkeletonProps } from "./data-table.types"; -/** DataTable's frame: real headers and grid, placeholder rows, clipped to the available height. */ +/** + * DataTable's frame: real headers and grid, placeholder rows, clipped to the available height. + * It is decorative (hidden from assistive tech); the surrounding LoadingRegion announces loading. + */ export function DataTableSkeleton({ - columns, - rowHeight = DATA_TABLE.rowHeight, + columns: allColumns, + compact, + rowHeight: fullRowHeight = DATA_TABLE.rowHeight, rows, - selectable, + selectable = false, }: DataTableSkeletonProps) { - const gridTemplate = [ - selectable ? DATA_TABLE.selectionColumnWidth : undefined, - ...columns.map((c) => c.width), - ] - .filter(Boolean) - .join(" "); + const { + ref: rowsRef, + compact: isCompact, + columns, + gridTemplate, + gutter, + } = useFittedColumns(allColumns, compact?.columns, selectable); + const rowHeight = isCompact && compact ? compact.rowHeight : fullRowHeight; + const gridStyle = { gridTemplateColumns: gridTemplate, columnGap: DATA_TABLE.columnGap }; return ( -
    +
    {selectable && (
    @@ -36,12 +48,12 @@ export function DataTableSkeleton({
    ))}
    -
    +
    {Array.from({ length: rows }, (_, row) => (
    {selectable && } {columns.map((column) => ( diff --git a/src/components/data-table/data-table.types.ts b/src/components/data-table/data-table.types.ts index a4d1fd9..76d30d0 100644 --- a/src/components/data-table/data-table.types.ts +++ b/src/components/data-table/data-table.types.ts @@ -2,17 +2,54 @@ import type { ReactNode } from "react"; export type SortDirection = "asc" | "desc"; -export type DataTableColumn = { - readonly id: string; - readonly header: string; +/** A row's sort key; `null` (no price, no float) always sorts last. */ +export type SortValue = string | number | null; + +export type SortState = { readonly columnId: string; readonly direction: SortDirection }; + +/** The sizing half of a column, shared by the table and its skeleton. */ +export type FittableColumn = { /** CSS grid track, e.g. "2fr" or "120px". */ readonly width: string; + /** Floor in px: the track never shrinks below it, so values like prices never clip. */ + readonly minWidth?: number; + /** Set on optional columns; when the minimums don't fit, the lowest priority hides first. */ + readonly hidePriority?: number; +}; + +/** The columns that fit, and whether they are the compact set. */ +export type FittedColumns = { readonly compact: boolean; readonly columns: readonly C[] }; + +export type DataTableColumn = FittableColumn & { + readonly id: string; + readonly header: string; + /** Keeps the header for assistive tech but not on screen, when another control already labels the column. */ + readonly hideHeader?: boolean; readonly align?: "left" | "right"; /** Sort key; omit to make the column unsortable. */ - readonly sortValue?: (row: Row) => string | number; + readonly sortValue?: (row: Row) => SortValue; readonly cell: (row: Row) => ReactNode; }; +/** One entry of the compact layout's sort menu: a column (shown or not) and a direction. */ +export type DataTableSortOption = SortState & { + readonly id: string; + readonly label: string; + readonly triggerLabel?: string; +}; + +/** + * What the table switches to when its own width can't fit the required columns: fewer, denser + * columns, taller rows, and a sort menu standing in for the headers that went away. + */ +export type DataTableCompact = { + readonly columns: readonly DataTableColumn[]; + readonly rowHeight: number; + /** Fills the first header cell; should cover every sort the compact headers can produce. */ + readonly sortOptions: readonly DataTableSortOption[]; + readonly sortLabel: string; +}; + export type DataTableSelection = { readonly selected: ReadonlySet; readonly onToggle: (key: string | number) => void; @@ -22,19 +59,23 @@ export type DataTableSelection = { export type DataTableProps = { readonly rows: readonly Row[]; + /** + * The full layout. Sorting may use any column here or in `compact`, rendered or not, but a + * sort the current layout can't show falls back to `initialSort`. + */ readonly columns: readonly DataTableColumn[]; + readonly compact?: DataTableCompact; readonly getRowKey: (row: Row) => string | number; readonly onRowClick?: (row: Row) => void; readonly rowHeight?: number; /** Adds a checkbox column; clicking a row also toggles it. */ readonly selection?: DataTableSelection; - readonly initialSort?: { readonly columnId: string; readonly direction: SortDirection }; + readonly initialSort?: SortState; }; -export type DataTableSkeletonColumn = { +export type DataTableSkeletonColumn = FittableColumn & { readonly id: string; readonly header: string; - readonly width: string; readonly align?: "left" | "right"; /** Placeholder shown in this column's cell of every row. */ readonly cell: ReactNode; @@ -42,6 +83,11 @@ export type DataTableSkeletonColumn = { export type DataTableSkeletonProps = { readonly columns: readonly DataTableSkeletonColumn[]; + /** The compact layout, chosen by the same width rule as the table's. */ + readonly compact?: { + readonly columns: readonly DataTableSkeletonColumn[]; + readonly rowHeight: number; + }; readonly rowHeight?: number; readonly rows: number; readonly selectable?: boolean; diff --git a/src/components/data-table/data-table.utils.test.ts b/src/components/data-table/data-table.utils.test.ts new file mode 100644 index 0000000..b11818e --- /dev/null +++ b/src/components/data-table/data-table.utils.test.ts @@ -0,0 +1,45 @@ +/* oxlint-disable typescript/no-floating-promises -- node:test registers suites synchronously */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { compareSortValues, fitLayout } from "./data-table.utils.ts"; + +const FULL = [ + { id: "item", width: "4fr", minWidth: 200 }, + { id: "type", width: "1fr", minWidth: 80, hidePriority: 1 }, + { id: "price", width: "1fr", minWidth: 100 }, +]; +const COMPACT = [ + { id: "item", width: "minmax(0, 1fr)" }, + { id: "price", width: "92px" }, +]; +const ids = (columns: readonly { id: string }[]) => columns.map((column) => column.id).join(","); + +// Required: 32 padding + 28 checkbox + 200 + 100 + 2 gaps of 12 = 384; Type adds 80 + 12. +describe("fitLayout", () => { + it("keeps every column when the table's own width fits them", () => { + const fitted = fitLayout(FULL, COMPACT, 476, [28]); + assert.equal(fitted.compact, false); + assert.equal(ids(fitted.columns), "item,type,price"); + }); + it("hides optional columns first, then goes compact only below the required width", () => { + assert.equal(ids(fitLayout(FULL, COMPACT, 475, [28]).columns), "item,price"); + assert.equal(fitLayout(FULL, COMPACT, 384, [28]).compact, false); + const narrow = fitLayout(FULL, COMPACT, 383, [28]); + assert.equal(narrow.compact, true); + assert.equal(ids(narrow.columns), "item,price"); + assert.equal(narrow.columns[0]?.width, "minmax(0, 1fr)"); + }); + it("uses the full layout before the width is measured, or with no compact set", () => { + assert.equal(fitLayout(FULL, COMPACT, undefined, [28]).compact, false); + assert.equal(fitLayout(FULL, undefined, 100, [28]).compact, false); + }); +}); + +describe("compareSortValues", () => { + const sorted = (values: readonly (number | null)[], sign: 1 | -1) => + [...values].sort((a, b) => compareSortValues(a, b, sign)); + it("puts missing values last in both directions", () => { + assert.deepEqual(sorted([2, null, 10, 1], 1), [1, 2, 10, null]); + assert.deepEqual(sorted([2, null, 10, 1], -1), [10, 2, 1, null]); + }); +}); diff --git a/src/components/data-table/data-table.utils.ts b/src/components/data-table/data-table.utils.ts new file mode 100644 index 0000000..ea9ecc9 --- /dev/null +++ b/src/components/data-table/data-table.utils.ts @@ -0,0 +1,75 @@ +import { DATA_TABLE } from "./data-table.config.ts"; +import type { FittableColumn, FittedColumns, SortValue } from "./data-table.types.ts"; + +/** A column's grid track: its flexible width, floored at its minimum so its content never clips. */ +export function columnTrack(column: FittableColumn): string { + return column.minWidth === undefined + ? column.width + : `minmax(${column.minWidth}px, ${column.width})`; +} + +/** + * Px the row needs to show `columns` at their minimums: the row's padding, the fixed leading + * tracks (the checkbox column), every column's floor, and the gap between each pair of tracks. + */ +export function requiredWidth( + columns: readonly FittableColumn[], + leadingTracks: readonly number[], +): number { + const tracks = leadingTracks.length + columns.length; + const gaps = Math.max(0, tracks - 1) * DATA_TABLE.columnGap; + const leading = leadingTracks.reduce((sum, width) => sum + width, 0); + return columns.reduce( + (sum, column) => sum + (column.minWidth ?? 0), + DATA_TABLE.horizontalPadding + leading + gaps, + ); +} + +/** + * Drops optional columns (lowest `hidePriority` first) until the rest fit `available` px at + * their minimum widths. Columns without a priority always stay. Unmeasured: every column. + */ +export function fitColumns( + columns: readonly C[], + available: number | undefined, + leadingTracks: readonly number[], +): readonly C[] { + if (available === undefined) return columns; + const droppable = columns + .filter((column) => column.hidePriority !== undefined) + .sort((a, b) => (a.hidePriority ?? 0) - (b.hidePriority ?? 0)); + let kept = columns; + for (const column of droppable) { + if (requiredWidth(kept, leadingTracks) <= available) break; + kept = kept.filter((candidate) => candidate !== column); + } + return kept; +} + +/** + * The full column set, thinned to fit; or, when even its required columns don't fit, the + * compact set. Decided from the table's own width, so a table in a side pane goes compact even + * on a wide window. + */ +export function fitLayout( + columns: readonly C[], + compactColumns: readonly C[] | undefined, + available: number | undefined, + leadingTracks: readonly number[], +): FittedColumns { + const required = columns.filter((column) => column.hidePriority === undefined); + const compact = + compactColumns !== undefined && + available !== undefined && + requiredWidth(required, leadingTracks) > available; + return compact + ? { compact, columns: fitColumns(compactColumns, available, leadingTracks) } + : { compact, columns: fitColumns(columns, available, leadingTracks) }; +} + +/** Ascending order; values that are missing (`null`) always go last, whichever the direction. */ +export function compareSortValues(a: SortValue, b: SortValue, sign: 1 | -1): number { + if (a === null || b === null) return a === b ? 0 : a === null ? 1 : -1; + if (typeof a === "number" && typeof b === "number") return sign * (a - b); + return sign * String(a).localeCompare(String(b)); +} diff --git a/src/components/empty-state/empty-state.component.tsx b/src/components/empty-state/empty-state.component.tsx new file mode 100644 index 0000000..c7806d9 --- /dev/null +++ b/src/components/empty-state/empty-state.component.tsx @@ -0,0 +1,15 @@ +import { EMPTY_STATE } from "./empty-state.config"; +import type { EmptyStateProps } from "./empty-state.types"; + +/** A section with nothing to show: a headline and a quieter reason, so empty never reads as broken. */ +export function EmptyState({ icon: Icon, title, body }: EmptyStateProps) { + return ( +
    + +
    +

    {title}

    +

    {body}

    +
    +
    + ); +} diff --git a/src/components/empty-state/empty-state.config.ts b/src/components/empty-state/empty-state.config.ts new file mode 100644 index 0000000..a8755a8 --- /dev/null +++ b/src/components/empty-state/empty-state.config.ts @@ -0,0 +1,7 @@ +export const EMPTY_STATE = { + iconSize: 16, + rootClass: "flex shrink-0 items-start gap-3 rounded-lg border border-dashed bg-card px-4 py-3", + iconClass: "mt-0.5 shrink-0 text-muted-foreground", + titleClass: "font-medium", + bodyClass: "mt-0.5 text-xs text-muted-foreground", +} as const; diff --git a/src/components/empty-state/empty-state.types.ts b/src/components/empty-state/empty-state.types.ts new file mode 100644 index 0000000..f0ea901 --- /dev/null +++ b/src/components/empty-state/empty-state.types.ts @@ -0,0 +1,9 @@ +import type { LucideIcon } from "lucide-react"; + +export type EmptyStateProps = { + readonly icon: LucideIcon; + /** The one-line headline: what is missing. */ + readonly title: string; + /** Quieter detail: why, or what to try next. */ + readonly body: string; +}; diff --git a/src/components/export-items/export-items.component.tsx b/src/components/export-items/export-items.component.tsx index 913121a..467c7f8 100644 --- a/src/components/export-items/export-items.component.tsx +++ b/src/components/export-items/export-items.component.tsx @@ -11,7 +11,7 @@ export function ExportItems({ items, currency }: ExportItemsProps) { const isCsv = format === "csv"; const url = URL.createObjectURL( new Blob([isCsv ? itemsToCsv(items, currency) : itemsToJson(items, currency)], { - type: isCsv ? EXPORT_ITEMS.mimeType : "application/json;charset=utf-8", + type: isCsv ? EXPORT_ITEMS.mimeType : EXPORT_ITEMS.jsonMimeType, }), ); const link = document.createElement("a"); @@ -28,8 +28,9 @@ export function ExportItems({ items, currency }: ExportItemsProps) { disabled={items.length === 0} onClick={() => download("csv")} title={EXPORT_ITEMS.description} + className={EXPORT_ITEMS.buttonClass} > - + {EXPORT_ITEMS.label} ); diff --git a/src/components/export-items/export-items.config.ts b/src/components/export-items/export-items.config.ts index ba47848..38b20ab 100644 --- a/src/components/export-items/export-items.config.ts +++ b/src/components/export-items/export-items.config.ts @@ -1,5 +1,10 @@ export const EXPORT_ITEMS = { label: "Export CSV", + jsonLabel: "Export JSON", + jsonMimeType: "application/json;charset=utf-8", + iconSize: 12, + /** Small on desktop; 36px tall on phones so each is an easy tap. */ + buttonClass: "max-sm:h-9", description: "Download the items shown by the current filters", mimeType: "text/csv;charset=utf-8", /** Keeps the blob URL alive until the browser has started the download (Safari reads it late). */ diff --git a/src/components/filtered-empty/filtered-empty.component.tsx b/src/components/filtered-empty/filtered-empty.component.tsx new file mode 100644 index 0000000..b0606f9 --- /dev/null +++ b/src/components/filtered-empty/filtered-empty.component.tsx @@ -0,0 +1,21 @@ +import { SearchX } from "lucide-react"; +import { Button } from "~/components/button/button.component"; +import { FILTERED_EMPTY } from "./filtered-empty.config"; +import type { FilteredEmptyProps } from "./filtered-empty.types"; + +/** Filters hid every row: say so, and offer the one click that brings the rows back. */ +export function FilteredEmpty({ title, body, actionLabel, onAction }: FilteredEmptyProps) { + return ( +
    + +

    {title}

    +

    {body}

    + +
    + ); +} diff --git a/src/components/filtered-empty/filtered-empty.config.ts b/src/components/filtered-empty/filtered-empty.config.ts new file mode 100644 index 0000000..644b9e5 --- /dev/null +++ b/src/components/filtered-empty/filtered-empty.config.ts @@ -0,0 +1,3 @@ +export const FILTERED_EMPTY = { + iconSize: 18, +} as const; diff --git a/src/components/filtered-empty/filtered-empty.types.ts b/src/components/filtered-empty/filtered-empty.types.ts new file mode 100644 index 0000000..46bf2b3 --- /dev/null +++ b/src/components/filtered-empty/filtered-empty.types.ts @@ -0,0 +1,6 @@ +export type FilteredEmptyProps = { + readonly title: string; + readonly body: string; + readonly actionLabel: string; + readonly onAction: () => void; +}; diff --git a/src/components/highlight-disclosure/highlight-disclosure.component.tsx b/src/components/highlight-disclosure/highlight-disclosure.component.tsx new file mode 100644 index 0000000..6315cd6 --- /dev/null +++ b/src/components/highlight-disclosure/highlight-disclosure.component.tsx @@ -0,0 +1,250 @@ +import { useEffect, useId, useRef, useState, type KeyboardEvent, type SyntheticEvent } from "react"; +import { createPortal, flushSync } from "react-dom"; +import { ItemTierBadge } from "~/components/item-highlights/item-highlights.component"; +import { ITEM_HIGHLIGHTS } from "~/components/item-highlights/item-highlights.config"; +import { COPY } from "~/lib/copy/copy.config"; +import { itemHighlights } from "~/lib/items/highlights"; +import { HIGHLIGHT_DISCLOSURE } from "./highlight-disclosure.config"; +import type { + HighlightDisclosurePlacement, + HighlightDisclosureProps, +} from "./highlight-disclosure.types"; + +/** + * Right-aligns the panel to its trigger, below it or (when cramped) above, inside the viewport + * horizontally and inside the page content (clear of the header and bottom nav) vertically. + */ +function placementFor(trigger: HTMLElement): HighlightDisclosurePlacement { + const rect = trigger.getBoundingClientRect(); + const { width: preferred, viewportMargin: margin, gap, minRoomBelow } = HIGHLIGHT_DISCLOSURE; + const bounds = trigger.closest(HIGHLIGHT_DISCLOSURE.boundsSelector)?.getBoundingClientRect(); + const floor = Math.min(bounds?.bottom ?? window.innerHeight, window.innerHeight); + const ceiling = Math.max(bounds?.top ?? 0, 0); + const width = Math.min(preferred, window.innerWidth - margin * 2); + const left = Math.min(Math.max(margin, rect.right - width), window.innerWidth - width - margin); + const below = floor - rect.bottom - gap - margin; + const above = rect.top - ceiling - gap - margin; + return below < minRoomBelow && above > below + ? { left, width, bottom: window.innerHeight - rect.top + gap, maxHeight: above } + : { left, width, top: rect.bottom + gap, maxHeight: below }; +} + +/** Rows toggle selection on click; nothing inside the disclosure should reach them. */ +const stop = (event: SyntheticEvent) => event.stopPropagation(); + +/** + * The item's tier (or a highlight marker) as a button that opens every highlight reason with + * its seed, exact float and attachments. The panel is portalled out of the virtualised row but + * behaves as if it sat right after the button: focus moves into it on open, Tab walks through + * it, Tab past its end or Shift+Tab before its start returns to the button (and Tab carries on + * from there), and Escape, Close or anything that closes it returns focus to the button. + */ +export function HighlightDisclosure({ item, showCount = false }: HighlightDisclosureProps) { + const { tier, reasons } = itemHighlights(item); + const trigger = useRef(null); + const panel = useRef(null); + const [placement, setPlacement] = useState(); + const panelId = useId(); + const titleId = useId(); + const open = placement !== undefined; + const copy = HIGHLIGHT_DISCLOSURE; + + useEffect(() => { + if (!open) return; + panel.current?.focus(); + // Closing on scroll or resize must not strand focus in the panel as it unmounts. + const close = () => { + if (panel.current?.contains(document.activeElement)) + trigger.current?.focus({ preventScroll: true }); + setPlacement(undefined); + }; + const inside = (target: EventTarget | null) => + target instanceof Node && + (panel.current?.contains(target) === true || trigger.current?.contains(target) === true); + // A click elsewhere moves focus there itself, so focus is left alone. + const onPointerDown = (event: PointerEvent) => { + if (!inside(event.target)) setPlacement(undefined); + }; + // The panel follows its trigger as the row (or the page) scrolls, including the scroll that + // brings a half-hidden row into view on click, and closes once the trigger scrolls out of + // the container that scrolled. + const onScroll = (event: Event) => { + const button = trigger.current; + const { target } = event; + if (!button || (target instanceof Node && panel.current?.contains(target))) return; + const rect = button.getBoundingClientRect(); + const box = + target instanceof Element && target.contains(button) + ? target.getBoundingClientRect() + : { top: 0, bottom: window.innerHeight }; + if (rect.bottom <= box.top || rect.top >= box.bottom) close(); + else setPlacement(placementFor(button)); + }; + document.addEventListener("pointerdown", onPointerDown); + window.addEventListener("scroll", onScroll, true); + window.addEventListener("resize", close); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("scroll", onScroll, true); + window.removeEventListener("resize", close); + }; + }, [open]); + + if (tier === null && reasons.length === 0) return null; + + // The panel is unmounted before focus moves, so nothing that happens while it goes away + // (a blur, the row re-rendering) can leave focus anywhere but the button. + function hide(refocus: boolean) { + flushSync(() => setPlacement(undefined)); + if (refocus) trigger.current?.focus(); + } + + function onPanelKeyDown(event: KeyboardEvent) { + stop(event); + if (event.key === copy.keys.close) { + event.preventDefault(); + hide(true); + return; + } + if (event.key !== copy.keys.tab || !panel.current) return; + const stops = [ + panel.current, + ...panel.current.querySelectorAll(copy.focusableSelector), + ]; + const at = stops.findIndex((element) => element === document.activeElement); + const next = at + (event.shiftKey ? -1 : 1); + if (next >= stops.length) { + // Past the end: back on the button, and the browser's own Tab carries on from there. + hide(true); + return; + } + event.preventDefault(); + if (next < 0) hide(true); + else stops[next]?.focus(); + } + + const MarkerIcon = ITEM_HIGHLIGHTS.markerIcon; + const CloseIcon = copy.closeIcon; + const attachments = [ + { + label: copy.details.stickers, + names: item.stickers.map((sticker) => sticker.name.replace(copy.stickerPrefix, "")), + }, + { + label: copy.details.charms, + names: item.charms.map((charm) => charm.name.replace(copy.charmPrefix, "")), + }, + ].filter(({ names }) => names.length > 0); + + return ( + <> + + {open && + createPortal( +
    +
    +
    +

    + {item.name} +

    + {tier !== null &&

    {COPY.items.tierRank(tier)}

    } +
    + +
    + {reasons.length > 0 && ( +
    +

    {copy.reasonsHeading}

    +
      + {reasons.map((reason) => { + const Icon = ITEM_HIGHLIGHTS.kindIcons[reason.kind]; + return ( +
    • + + {reason.label} +
    • + ); + })} +
    +
    + )} +
    + {item.paintSeed != null && ( + <> +
    {copy.details.seed}
    +
    {copy.seedValue(item.paintSeed)}
    + + )} + {item.wear !== null && ( + <> +
    {copy.details.float}
    +
    {item.wear.toFixed(copy.floatDigits)}
    + + )} + {attachments.map(({ label, names }) => ( +
    +
    {label}
    +
    +
      + {names.map((name, index) => ( +
    • {name}
    • + ))} +
    +
    +
    + ))} +
    +
    , + document.body, + )} + + ); +} diff --git a/src/components/highlight-disclosure/highlight-disclosure.config.ts b/src/components/highlight-disclosure/highlight-disclosure.config.ts new file mode 100644 index 0000000..eed984d --- /dev/null +++ b/src/components/highlight-disclosure/highlight-disclosure.config.ts @@ -0,0 +1,61 @@ +import { X } from "lucide-react"; + +export const HIGHLIGHT_DISCLOSURE = { + /** Panel width in px, narrowed to fit the viewport on phones. */ + width: 288, + viewportMargin: 8, + gap: 6, + /** Below this much room under the trigger (px), the panel opens upwards when there is more above. */ + minRoomBelow: 220, + /** + * The panel stays inside this element's box (the page content), so on phones it never covers + * the header or the bottom navigation. + */ + boundsSelector: "main", + keys: { close: "Escape", tab: "Tab" }, + /** What the panel can move focus between, in order; the panel itself comes first. */ + focusableSelector: "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])", + closeIcon: X, + iconSize: 12, + closeIconSize: 14, + /** Digits shown for the exact float in the panel; the table rounds to four. */ + floatDigits: 9, + /** Starts with the badge's visible text (e.g. "T2"), so the spoken name matches what's shown. */ + triggerLabel: (visibleTier: string | null, name: string, count: number, showCount: boolean) => { + const visible = `${visibleTier ?? ""}${showCount && count > 0 ? `+${count}` : ""}`; + return `${visible ? `${visible} highlights` : "Highlights"} for ${name}: ${count} ${count === 1 ? "reason" : "reasons"}`; + }, + close: "Close highlights", + reasonsHeading: "Why it stands out", + details: { seed: "Pattern seed", float: "Float", stickers: "Stickers", charms: "Charms" }, + seedValue: (seed: number) => `#${seed}`, + /** The row already says what kind of attachment each list holds, so the name's prefix is dropped. */ + stickerPrefix: "Sticker | ", + charmPrefix: "Charm | ", + listSeparator: ", ", + /** + * Touch screens and phones (the table's touch rule) get a 36px button, and an invisible + * overhang stretches its tap area to 44px without growing the row; desktop stays compact. + */ + triggerClass: + "relative inline-flex shrink-0 items-center justify-center gap-1 rounded-md p-0.5 text-muted-foreground outline-offset-1 hover:bg-muted aria-expanded:bg-muted max-sm:min-h-9 max-sm:min-w-9 pointer-coarse:min-h-9 pointer-coarse:min-w-9 max-sm:before:absolute max-sm:before:-inset-1 pointer-coarse:before:absolute pointer-coarse:before:-inset-1 max-sm:before:content-[''] pointer-coarse:before:content-['']", + markerClass: "m-0.5", + countClass: + "tabular inline-flex items-center gap-0.5 rounded bg-foreground/[0.07] px-1.5 py-px text-[11px] leading-4 text-foreground/80", + panelClass: + "fixed z-50 flex flex-col gap-2 overflow-y-auto rounded-md border bg-card p-3 text-xs text-foreground shadow-lg outline-none", + closeClass: + "-mr-1 -mt-1 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground max-sm:size-9 pointer-coarse:size-9", + headClass: "flex items-start justify-between gap-2", + titleBlockClass: "min-w-0", + titleClass: "text-sm font-medium", + tierClass: "mt-0.5 text-muted-foreground", + reasonsHeadingClass: "font-medium text-muted-foreground", + reasonsClass: "mt-1 flex flex-col gap-1", + reasonClass: "flex items-center gap-1.5", + reasonIconClass: "shrink-0 text-muted-foreground", + detailsClass: "grid grid-cols-[auto_1fr] gap-x-3 gap-y-1", + termClass: "text-muted-foreground", + numberClass: "tabular", + attachmentClass: "contents", +} as const; diff --git a/src/components/highlight-disclosure/highlight-disclosure.types.ts b/src/components/highlight-disclosure/highlight-disclosure.types.ts new file mode 100644 index 0000000..e5f70fa --- /dev/null +++ b/src/components/highlight-disclosure/highlight-disclosure.types.ts @@ -0,0 +1,16 @@ +import type { ItemView } from "~/lib/items/item.types"; + +export type HighlightDisclosureProps = { + readonly item: ItemView; + /** Adds the number of reasons beside the tier badge, for rows with no room for chips. */ + readonly showCount?: boolean; +}; + +/** Where the open panel sits, in viewport pixels (it is `position: fixed`). */ +export type HighlightDisclosurePlacement = { + readonly left: number; + readonly width: number; + readonly top?: number; + readonly bottom?: number; + readonly maxHeight: number; +}; diff --git a/src/components/history-panel/history-panel.config.ts b/src/components/history-panel/history-panel.config.ts index 60baf30..88289cb 100644 --- a/src/components/history-panel/history-panel.config.ts +++ b/src/components/history-panel/history-panel.config.ts @@ -7,7 +7,7 @@ export const HISTORY_PANEL = { { key: "1m", label: "1M", windowMs: 30 * DAY_MS }, { key: "3m", label: "3M", windowMs: 90 * DAY_MS }, { key: "ytd", label: "YTD", windowMs: "ytd" }, - { key: "all", label: "Total", windowMs: null }, + { key: "all", label: "All time", windowMs: null }, ], defaultRange: "all", series: [ diff --git a/src/components/holdings-table/holdings-table.component.tsx b/src/components/holdings-table/holdings-table.component.tsx index dd9f4f0..035e611 100644 --- a/src/components/holdings-table/holdings-table.component.tsx +++ b/src/components/holdings-table/holdings-table.component.tsx @@ -8,43 +8,81 @@ import type { HoldingsTableProps } from "./holdings-table.types"; export function HoldingsTable({ holdings, currency }: HoldingsTableProps) { const columns = COPY.dashboard.holdingsColumns; return ( - - - - - - - - - - + <> +
      {holdings.map((holding) => ( -
    - - - - - +
  • + + + +
    +
    +
    {columns.qty}
    +
    {formatInteger(holding.count)}
    +
    +
    +
    {columns.unit}
    +
    {formatMoney(holding.unitPrice, currency)}
    +
    +
    +
    {columns.total}
    +
    {formatMoney(holding.totalValue, currency)}
    +
    +
    +
  • ))} - -
    {columns.item}{columns.qty}{columns.unit}{columns.total}
    - - - - - {formatInteger(holding.count)} - - {formatMoney(holding.unitPrice, currency)} - {formatMoney(holding.totalValue, currency)}
    + + + + + + + + + + + + {holdings.map((holding) => ( + + + + + + + ))} + +
    {columns.item}{columns.qty}{columns.unit}{columns.total}
    + + + + + {formatInteger(holding.count)} + + {formatMoney(holding.unitPrice, currency)} + + {formatMoney(holding.totalValue, currency)} +
    + ); } diff --git a/src/components/holdings-table/holdings-table.skeleton.tsx b/src/components/holdings-table/holdings-table.skeleton.tsx index 46c389a..7ec5eb4 100644 --- a/src/components/holdings-table/holdings-table.skeleton.tsx +++ b/src/components/holdings-table/holdings-table.skeleton.tsx @@ -7,42 +7,65 @@ import { HOLDINGS_TABLE } from "./holdings-table.config"; export function HoldingsTableSkeleton() { const columns = COPY.dashboard.holdingsColumns; return ( - - - - - - - - - - + <> +
      {Array.from({ length: DASHBOARD.topHoldingCount }, (_, row) => ( -
    - - - - - + +
    + + +
    + + ))} - -
    {columns.item}{columns.qty}{columns.unit}{columns.total}
    -
    - -
    - - -
    +
  • +
    + +
    + +
    -
  • - - - - - -
    + + + + + + + + + + + + {Array.from({ length: DASHBOARD.topHoldingCount }, (_, row) => ( + + + + + + + ))} + +
    {columns.item}{columns.qty}{columns.unit}{columns.total}
    +
    + +
    + + +
    +
    +
    + + + + + +
    + ); } diff --git a/src/components/item-cell/item-cell.component.tsx b/src/components/item-cell/item-cell.component.tsx index 9874c04..cf8ea91 100644 --- a/src/components/item-cell/item-cell.component.tsx +++ b/src/components/item-cell/item-cell.component.tsx @@ -6,12 +6,63 @@ import { TerminalActivate } from "~/components/terminal-activate/terminal-activa import { INVENTORY_LOCATION_ID } from "~/lib/items/item.types"; import { ItemLabel } from "~/components/item-label/item-label.component"; import { ItemHighlights } from "~/components/item-highlights/item-highlights.component"; +import { AccountName } from "~/components/account-name/account-name.component"; +import { HighlightDisclosure } from "~/components/highlight-disclosure/highlight-disclosure.component"; +import { itemHighlights } from "~/lib/items/highlights"; import { ITEM_CELL } from "./item-cell.config"; import type { ItemCellProps } from "./item-cell.types"; -export function ItemCell({ item }: ItemCellProps) { +export function ItemCell({ item, account, detail, compact = false, nameFit }: ItemCellProps) { const { imageSize, stickerSize, charmSize, maxStickers, patternLabel } = ITEM_CELL; - const hasAttachments = item.stickers.length > 0 || item.charms.length > 0; + // A sticker without an image would render as a broken-image glyph; its name is in the item data. + const stickers = item.stickers + .filter((sticker) => sticker.imageUrl !== null) + .slice(0, maxStickers); + const hasAttachments = stickers.length > 0 || item.charms.length > 0; + const { tier, reasons } = itemHighlights(item); + const hasHighlights = tier !== null || reasons.length > 0; + const attachments = hasAttachments && ( + + {stickers.map((sticker, index) => ( + {sticker.name} + ))} + {item.charms.map((charm, index) => ( + + {charm.imageUrl !== null && ( + {charm.name} + )} + {charm.pattern !== null && #{charm.pattern}} + {charm.videoUrl !== null && ( + + )} + + ))} + + ); return ( - {item.pattern !== null && ( + {detail && ( +

    + {detail} +

    + )} + {account && } + {item.pattern !== null && !compact && (

    {patternLabel} #{item.pattern}

    )} - {item.terminal !== null && (

    {item.terminal.isActive ? COPY.terminal.active : COPY.terminal.sealed} @@ -44,45 +102,16 @@ export function ItemCell({ item }: ItemCellProps) { )} {item.videoUrl !== null && } - {hasAttachments && ( -

    - {item.stickers.slice(0, maxStickers).map((sticker, index) => ( - {sticker.name} - ))} - {item.charms.map((charm, index) => ( - - {charm.name} - {charm.pattern !== null && #{charm.pattern}} - {charm.videoUrl !== null && ( - - )} - - ))} -
    + {/* Reasons and attachments share one line, so the row keeps its fixed height. */} + {compact ? ( + (hasAttachments || hasHighlights) && ( +
    + + {attachments} +
    + ) + ) : ( + {attachments} )}
    ); diff --git a/src/components/item-cell/item-cell.config.ts b/src/components/item-cell/item-cell.config.ts index 4deb34c..9a935dc 100644 --- a/src/components/item-cell/item-cell.config.ts +++ b/src/components/item-cell/item-cell.config.ts @@ -4,4 +4,7 @@ export const ITEM_CELL = { charmSize: 18, maxStickers: 5, patternLabel: "Pattern", + compactLineClass: "mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden", + detailClass: "mt-0.5 truncate text-xs leading-4 text-muted-foreground", + accountClass: "mt-0.5 text-xs leading-4 text-muted-foreground", } as const; diff --git a/src/components/item-cell/item-cell.types.ts b/src/components/item-cell/item-cell.types.ts index f88bbea..4ea83e5 100644 --- a/src/components/item-cell/item-cell.types.ts +++ b/src/components/item-cell/item-cell.types.ts @@ -1,3 +1,15 @@ +import type { AccountDisplayName } from "~/lib/accounts/accounts.types"; +import type { ItemLabelNameFit } from "~/components/item-label/item-label.types"; import type { ItemView } from "~/lib/items/item.types"; -export type ItemCellProps = { readonly item: ItemView }; +export type ItemCellProps = { + readonly item: ItemView; + /** The holding account, as a line under the name; for narrow rows, where there is no Account column. */ + readonly account?: AccountDisplayName; + /** A muted line under the name, e.g. where the item is, its float and pattern; narrow rows put the pattern here, not on a line of its own. */ + readonly detail?: string; + /** Narrow rows: chips don't fit, so the tier and a count open the full highlight list instead. */ + readonly compact?: boolean; + /** How the name fits; fixed-height table rows pass what their height allows. */ + readonly nameFit?: ItemLabelNameFit; +}; diff --git a/src/components/item-collection/item-collection.component.tsx b/src/components/item-collection/item-collection.component.tsx index 07dd7c4..8049015 100644 --- a/src/components/item-collection/item-collection.component.tsx +++ b/src/components/item-collection/item-collection.component.tsx @@ -1,8 +1,8 @@ -import { ItemGrid } from "~/components/item-grid/item-grid.component"; -import { ItemTable } from "~/components/item-table/item-table.component"; +import { ITEM_COLLECTION } from "./item-collection.config"; import type { ItemCollectionProps } from "./item-collection.types"; /** Items as a table or a card grid; both take the same props, so pages only pick the view. */ export function ItemCollection({ view, ...props }: ItemCollectionProps) { - return view === "grid" ? : ; + const View = ITEM_COLLECTION.views[view]; + return ; } diff --git a/src/components/item-collection/item-collection.config.ts b/src/components/item-collection/item-collection.config.ts new file mode 100644 index 0000000..296ef2f --- /dev/null +++ b/src/components/item-collection/item-collection.config.ts @@ -0,0 +1,13 @@ +import type { ComponentType } from "react"; +import { ItemGrid } from "~/components/item-grid/item-grid.component"; +import { ItemTable } from "~/components/item-table/item-table.component"; +import type { ItemTableProps } from "~/components/item-table/item-table.types"; +import type { ItemViewMode } from "~/lib/items/view-mode.types"; + +export const ITEM_COLLECTION = { + /** The renderer for each view mode; a new mode won't compile until it has one. */ + views: { list: ItemTable, grid: ItemGrid } satisfies Record< + ItemViewMode, + ComponentType + >, +} as const; diff --git a/src/components/item-grid-card/item-grid-card.component.tsx b/src/components/item-grid-card/item-grid-card.component.tsx index 42612b2..277226c 100644 --- a/src/components/item-grid-card/item-grid-card.component.tsx +++ b/src/components/item-grid-card/item-grid-card.component.tsx @@ -7,6 +7,7 @@ import { splitItemName } from "~/components/item-label/item-label.utils"; import { COPY } from "~/lib/copy/copy.config"; import { cn } from "~/utils/cn"; import { formatFloat, formatMoney } from "~/utils/format"; +import { AccountName } from "~/components/account-name/account-name.component"; import { ITEM_GRID_CARD } from "./item-grid-card.config"; import type { ItemGridCardProps } from "./item-grid-card.types"; @@ -26,7 +27,7 @@ export function ItemGridCard({ onKeyDown, }: ItemGridCardProps) { const [weapon, skin] = splitItemName(item.name); - const { stickerSize, maxStickers, thumbClass, selectRowLabel } = ITEM_GRID_CARD; + const { stickerSize, maxStickers, thumbClass, selectRowLabel, classes } = ITEM_GRID_CARD; const status = item.terminal !== null ? item.terminal.isActive @@ -45,21 +46,19 @@ export function ItemGridCard({ onKeyDown={onKeyDown} onClick={selectable ? onToggle : undefined} className={cn( - "group relative flex h-full min-w-0 flex-col rounded-lg border bg-card p-3 text-left transition-colors focus-visible:outline-offset-2", - selectable && "cursor-pointer hover:bg-muted", - checked && "border-primary/60 bg-muted", + classes.card, + selectable && classes.cardSelectable, + checked && classes.cardChecked, )} > -
    +
    - + {selectable && ( )}
    -
    - {weapon && ( -

    - {weapon} -

    - )} -

    +

    + {weapon &&

    {weapon}

    } +

    {skin} - {item.customName && “{item.customName}”} + {item.customName && “{item.customName}”}

    -
    -

    +

    +

    {item.price === null ? COPY.common.empty : formatMoney(item.price, currency)}

    - {status !== null && ( -

    {status}

    - )} + {status !== null &&

    {status}

    }
    - + {showLocation && ( -

    - {accountName ? `${accountName} · ${item.locationName}` : item.locationName} +

    + {accountName && ( + <> + + {ITEM_GRID_CARD.locationSeparator} + + )} + {item.locationName}

    )} -
    +
    {item.stickers .filter((sticker) => sticker.imageUrl !== null) .slice(0, maxStickers) @@ -116,7 +115,7 @@ export function ItemGridCard({ /> ))} {item.charms.length > 0 && ( - + {item.charms.map((charm) => charm.name).join(", ")} )} diff --git a/src/components/item-grid-card/item-grid-card.config.ts b/src/components/item-grid-card/item-grid-card.config.ts index f4450a0..4fb4f0b 100644 --- a/src/components/item-grid-card/item-grid-card.config.ts +++ b/src/components/item-grid-card/item-grid-card.config.ts @@ -4,4 +4,34 @@ export const ITEM_GRID_CARD = { stickerSize: 16, maxStickers: 5, selectRowLabel: "Select item", + /** A 24px hit area on desktop; 36px on phones and touch screens. */ + checkboxClass: + "absolute left-1.5 top-1.5 flex rounded bg-background/80 p-1 transition-opacity max-sm:p-2.5 [@media(hover:none)]:opacity-100 [@media(pointer:coarse)]:p-2.5", + /** Cards are narrow at every width, so one reason chip shows and the rest fold into `+N`. */ + visibleHighlights: 1, + /** Account and location share one line; each truncates, but the account's SteamID tail stays. */ + locationClass: "mt-0.5 flex min-w-0 items-baseline gap-1 text-xs text-muted-foreground", + locationSeparator: "·", + classes: { + card: "group relative flex h-full min-w-0 flex-col rounded-lg border bg-card p-3 text-left transition-colors focus-visible:outline-offset-2", + cardSelectable: "cursor-pointer hover:bg-muted", + cardChecked: "border-primary/60 bg-muted", + media: "relative", + tierBadge: "absolute right-1.5 top-1.5 shadow-sm", + checkboxShown: "opacity-100", + checkboxHidden: "opacity-0 focus-within:opacity-100 group-hover:opacity-100", + checkbox: "size-4 accent-primary", + text: "mt-2.5 min-w-0", + weapon: + "mb-0.5 truncate text-[11px] uppercase leading-none tracking-wide text-muted-foreground", + skin: "truncate leading-tight", + muted: "text-muted-foreground", + figures: "mt-1.5 flex items-baseline justify-between gap-2", + price: "tabular text-sm font-medium", + status: "tabular shrink-0 text-xs text-muted-foreground", + account: "shrink", + location: "truncate", + attachments: "mt-auto flex h-6 items-end gap-0.5 overflow-hidden", + charms: "ml-1 text-[11px] text-muted-foreground", + }, } as const; diff --git a/src/components/item-grid-card/item-grid-card.types.ts b/src/components/item-grid-card/item-grid-card.types.ts index bac14ae..c1564e2 100644 --- a/src/components/item-grid-card/item-grid-card.types.ts +++ b/src/components/item-grid-card/item-grid-card.types.ts @@ -1,4 +1,5 @@ import type { KeyboardEvent } from "react"; +import type { AccountDisplayName } from "~/lib/accounts/accounts.types"; import type { ItemView } from "~/lib/items/item.types"; export type ItemGridCardProps = { @@ -6,7 +7,7 @@ export type ItemGridCardProps = { readonly currency: string; readonly showLocation: boolean; /** The holding account, shown ahead of the location in the all-accounts view. */ - readonly accountName?: string; + readonly accountName?: AccountDisplayName; readonly index: number; /** Selection is on; the card toggles when activated. */ readonly selectable: boolean; diff --git a/src/components/item-grid/item-grid.component.tsx b/src/components/item-grid/item-grid.component.tsx index 3bfb383..9a85b0e 100644 --- a/src/components/item-grid/item-grid.component.tsx +++ b/src/components/item-grid/item-grid.component.tsx @@ -8,10 +8,12 @@ import { useState, type KeyboardEvent, } from "react"; +import { DATA_TABLE } from "~/components/data-table/data-table.config"; import { ItemGridCard } from "~/components/item-grid-card/item-grid-card.component"; import { Select } from "~/components/select/select.component"; import { itemRowKey } from "~/lib/items/item-key"; -import { formatInteger } from "~/utils/format"; +import { formatCount } from "~/utils/plural"; +import { cn } from "~/utils/cn"; import { ITEM_GRID } from "./item-grid.config"; import type { ItemGridProps, ItemGridSortId } from "./item-grid.types"; import { columnsFor, sortItems } from "./item-grid.utils"; @@ -95,7 +97,16 @@ export function ItemGrid({ ); if (!target) return; focusPending.current = false; - target.focus(); + target.focus({ preventScroll: true }); + // Keep the outline and its offset inside the scroll viewport when moving upward. + const viewport = scrollRef.current; + if (!viewport) return; + const card = target.getBoundingClientRect(); + const visible = viewport.getBoundingClientRect(); + if (card.top < visible.top + ITEM_GRID.focusInset) + viewport.scrollTop += card.top - visible.top - ITEM_GRID.focusInset; + else if (card.bottom > visible.bottom - ITEM_GRID.focusInset) + viewport.scrollTop += card.bottom - visible.bottom + ITEM_GRID.focusInset; }); function moveFocus(index: number) { @@ -127,13 +138,7 @@ export function ItemGrid({ } return ( -
    +
    {selectable ? (
    0 && selectable ? true : undefined} > {ordered.length === 0 ? (
    {ITEM_GRID.emptyLabel}
    @@ -214,7 +225,7 @@ export function ItemGrid({ )}
    - {formatInteger(ordered.length)} {ITEM_GRID.countSuffix} + {formatCount(ordered.length, ITEM_GRID.count)}
    ); diff --git a/src/components/item-grid/item-grid.config.ts b/src/components/item-grid/item-grid.config.ts index da8cc5f..c00bc3b 100644 --- a/src/components/item-grid/item-grid.config.ts +++ b/src/components/item-grid/item-grid.config.ts @@ -1,25 +1,21 @@ +import { ITEM_SORT_LABEL, ITEM_SORTS } from "../../lib/items/item-sort.config.ts"; + export const ITEM_GRID = { /** Cards share the row evenly, so this is the narrowest a card may get before a column is dropped. */ minCardWidth: 160, gap: 12, padding: 12, - /** Fixed so rows can be virtualised; the location line adds height. */ - cardHeight: { withLocation: 248, withoutLocation: 230 }, + focusInset: 6, + /** Fixed so rows can be virtualised; includes the highlight chip line and location line. */ + cardHeight: { withLocation: 272, withoutLocation: 254 }, overscan: 3, selectAllLabel: "Select all items", selectAllText: "Select all", - sortLabel: "Sort items", + sortLabel: ITEM_SORT_LABEL, emptyLabel: "No items", - countSuffix: "items", + count: { one: "item", other: "items" }, defaultSort: "price-desc", - sorts: [ - { id: "price-desc", label: "Price: high to low" }, - { id: "price-asc", label: "Price: low to high" }, - { id: "float-asc", label: "Float: low to high" }, - { id: "float-desc", label: "Float: high to low" }, - { id: "highlights-desc", label: "Highlights first" }, - { id: "name", label: "Name" }, - ], + sorts: ITEM_SORTS, keys: { left: "ArrowLeft", right: "ArrowRight", diff --git a/src/components/item-grid/item-grid.types.ts b/src/components/item-grid/item-grid.types.ts index 7e24550..82f092a 100644 --- a/src/components/item-grid/item-grid.types.ts +++ b/src/components/item-grid/item-grid.types.ts @@ -1,14 +1,15 @@ +import type { AccountDisplayName } from "../../lib/accounts/accounts.types.ts"; import type { ItemView } from "../../lib/items/item.types.ts"; -import type { ITEM_GRID } from "./item-grid.config.ts"; +import type { ItemSortId } from "../../lib/items/item-sort.types.ts"; -export type ItemGridSortId = (typeof ITEM_GRID.sorts)[number]["id"]; +export type ItemGridSortId = ItemSortId; export type ItemGridProps = { readonly items: readonly ItemView[]; readonly currency: string; readonly showLocation?: boolean; - /** Account display names by SteamID. When given, cards say which account holds the item. */ - readonly accountNames?: ReadonlyMap; + /** Account names by SteamID. When given, cards say which account holds the item. */ + readonly accountNames?: ReadonlyMap; readonly selected?: ReadonlySet; readonly onSelectionChange?: (next: ReadonlySet) => void; }; diff --git a/src/components/item-grid/item-grid.utils.test.ts b/src/components/item-grid/item-grid.utils.test.ts index 8bced33..6e8f854 100644 --- a/src/components/item-grid/item-grid.utils.test.ts +++ b/src/components/item-grid/item-grid.utils.test.ts @@ -49,7 +49,8 @@ describe("sortItems", () => { assert.equal(ids(sortItems(ITEMS, "float-desc")), "acb"); }); it("sorts by name and leaves the input untouched", () => { - assert.equal(ids(sortItems(ITEMS, "name")), "abc"); + assert.equal(ids(sortItems(ITEMS, "name-asc")), "abc"); + assert.equal(ids(sortItems(ITEMS, "name-desc")), "cba"); assert.equal(ids(ITEMS), "abc"); }); it("sorts highlights first: by tier, then price, then untiered, without mutating the input", () => { diff --git a/src/components/item-grid/item-grid.utils.ts b/src/components/item-grid/item-grid.utils.ts index 9005784..257de4a 100644 --- a/src/components/item-grid/item-grid.utils.ts +++ b/src/components/item-grid/item-grid.utils.ts @@ -29,8 +29,10 @@ export function sortItems(items: readonly ItemView[], sort: ItemGridSortId): rea return sorted.sort((a, b) => compareNullsLast(a.wear, b.wear, -1)); case "highlights-desc": return sortByHighlights(sorted); - case "name": + case "name-asc": return sorted.sort((a, b) => a.name.localeCompare(b.name)); + case "name-desc": + return sorted.sort((a, b) => b.name.localeCompare(a.name)); } } diff --git a/src/components/item-highlights/item-highlights.component.tsx b/src/components/item-highlights/item-highlights.component.tsx index ef6db1c..d3f74df 100644 --- a/src/components/item-highlights/item-highlights.component.tsx +++ b/src/components/item-highlights/item-highlights.component.tsx @@ -4,41 +4,76 @@ import { cn } from "~/utils/cn"; import { ITEM_HIGHLIGHTS } from "./item-highlights.config"; import type { ItemHighlightsProps, ItemTierBadgeProps } from "./item-highlights.types"; -/** Neutral reason chips under the item name. */ -export function ItemHighlights({ item }: ItemHighlightsProps) { +/** + * Reason chips on one line under the item name, so a row never grows past its fixed height. + * Chips that don't fit collapse into `+N`, which names the hidden reasons for screen readers. + */ +export function ItemHighlights({ item, children, visible }: ItemHighlightsProps) { const { reasons } = itemHighlights(item); - if (reasons.length === 0) return null; + if (reasons.length === 0 && !children) return null; + const { chipVisibility, moreVisibility } = ITEM_HIGHLIGHTS; + // A fixed count (e.g. a grid card) applies at every width; otherwise the line's width decides. + const chipClass = (index: number) => + visible === undefined ? (chipVisibility[index] ?? "hidden") : index < visible ? "" : "hidden"; + const more = (from: number, className: string) => + reasons.length > from && ( + reason.label) + .join(", ")} + > + {COPY.items.moreHighlights(reasons.length - from)} + + {reasons + .slice(from) + .map((reason) => reason.label) + .join(", ")} + + + ); return ( -
    - {reasons.map((reason) => { +
    + {reasons.map((reason, index) => { const Icon = ITEM_HIGHLIGHTS.kindIcons[reason.kind]; return ( 0 && "shrink-0", chipClass(index))} > - - {reason.label} + + {reason.label} ); })} + {visible === undefined + ? moreVisibility.map((className, from) => more(from + 1, className)) + : more(visible, "")} + {children}
    ); } /** Gem icon and "T1".."TX"; the most valuable tier is accented and lower tiers fade. */ -export function ItemTierBadge({ item, className }: ItemTierBadgeProps) { +export function ItemTierBadge({ item, className, decorative = false }: ItemTierBadgeProps) { const { tier } = itemHighlights(item); if (tier === null) return null; const Icon = ITEM_HIGHLIGHTS.tierIcon; const description = COPY.items.tierDescription(tier); return ( , tierIcon: Gem, + /** Marks a highlighted item without a pattern tier (float or craft only) in the Highlights column. */ + markerIcon: Sparkle, + markerIconSize: 13, /** Badge classes by tier number (T1 first); T1 is solid accent and lower tiers fade out. */ tierStyles: [ "bg-accent font-semibold text-accent-foreground", diff --git a/src/components/item-highlights/item-highlights.types.ts b/src/components/item-highlights/item-highlights.types.ts index 9335dde..3390805 100644 --- a/src/components/item-highlights/item-highlights.types.ts +++ b/src/components/item-highlights/item-highlights.types.ts @@ -1,9 +1,17 @@ +import type { ReactNode } from "react"; import type { ItemView } from "~/lib/items/item.types"; export type ItemHighlightsProps = { readonly item: ItemView; + /** Extra inline content kept on the same line, such as sticker thumbnails. */ + readonly children?: ReactNode; + /** Chips shown before `+N` at every width; by default it follows the viewport (narrow vs wide). */ + readonly visible?: number; }; -export type ItemTierBadgeProps = ItemHighlightsProps & { +export type ItemTierBadgeProps = { + readonly item: ItemView; readonly className?: string; + /** Inside a control that already names the tier, so the badge is visual only. */ + readonly decorative?: boolean; }; diff --git a/src/components/item-label/item-label.component.tsx b/src/components/item-label/item-label.component.tsx index 3a4636f..87a7ada 100644 --- a/src/components/item-label/item-label.component.tsx +++ b/src/components/item-label/item-label.component.tsx @@ -1,4 +1,6 @@ import { ItemThumb } from "~/components/item-thumb/item-thumb.component"; +import { cn } from "~/utils/cn"; +import { ITEM_LABEL } from "./item-label.config"; import type { ItemLabelProps } from "./item-label.types"; import { splitItemName } from "./item-label.utils"; @@ -9,22 +11,32 @@ export function ItemLabel({ rarityColor, size, customName, + nameFit = "responsive", + compactOnNarrow = false, children, }: ItemLabelProps) { const [weapon, skin] = splitItemName(name); return (
    - -
    + + {/* Fills the row, so the highlight line's container query sees the real space available. */} +
    {weapon && ( -

    +

    {weapon}

    )} -

    +

    {skin} {customName && “{customName}”}

    diff --git a/src/components/item-label/item-label.config.ts b/src/components/item-label/item-label.config.ts index ee256c5..5569e4e 100644 --- a/src/components/item-label/item-label.config.ts +++ b/src/components/item-label/item-label.config.ts @@ -1,3 +1,14 @@ export const ITEM_LABEL = { nameSeparator: " | ", + /** + * How the skin name fits: `single` truncates to one line (fixed-height wide table rows), + * `double` wraps to two (compact rows, sized for it), `responsive` wraps on phones only. + */ + nameClass: { + single: "truncate leading-tight", + double: "break-words leading-tight line-clamp-2 whitespace-normal", + wrap: "break-words leading-tight whitespace-normal", + responsive: + "break-words leading-tight max-md:line-clamp-2 max-md:whitespace-normal md:truncate", + }, } as const; diff --git a/src/components/item-label/item-label.types.ts b/src/components/item-label/item-label.types.ts index 7502f7f..930ae34 100644 --- a/src/components/item-label/item-label.types.ts +++ b/src/components/item-label/item-label.types.ts @@ -1,4 +1,7 @@ import type { ReactNode } from "react"; +import type { ITEM_LABEL } from "./item-label.config"; + +export type ItemLabelNameFit = keyof typeof ITEM_LABEL.nameClass; export type ItemLabelProps = { readonly name: string; @@ -6,6 +9,10 @@ export type ItemLabelProps = { readonly rarityColor: string | null; readonly size: number; readonly customName?: string | null; + /** Defaults to `responsive`; a fixed-height row passes the fit its height allows. */ + readonly nameFit?: ItemLabelNameFit; + /** On the tightest compact tables, give the name the thumbnail's width. */ + readonly compactOnNarrow?: boolean; /** Rendered under the name, e.g. stickers. */ readonly children?: ReactNode; }; diff --git a/src/components/item-table/item-table.component.tsx b/src/components/item-table/item-table.component.tsx index 86062b1..aa2d9ec 100644 --- a/src/components/item-table/item-table.component.tsx +++ b/src/components/item-table/item-table.component.tsx @@ -1,17 +1,24 @@ import { useMemo } from "react"; +import { AccountName } from "~/components/account-name/account-name.component"; import { DataTable } from "~/components/data-table/data-table.component"; -import type { DataTableColumn } from "~/components/data-table/data-table.types"; -import { ItemTierBadge } from "~/components/item-highlights/item-highlights.component"; +import type { DataTableColumn, DataTableCompact } from "~/components/data-table/data-table.types"; +import { HighlightDisclosure } from "~/components/highlight-disclosure/highlight-disclosure.component"; import { ItemCell } from "~/components/item-cell/item-cell.component"; +import { ITEM_CELL } from "~/components/item-cell/item-cell.config"; import { COPY } from "~/lib/copy/copy.config"; import { itemRowKey } from "~/lib/items/item-key"; import { highlightSortValue } from "~/lib/items/highlights"; import type { ItemView } from "~/lib/items/item.types"; import { formatFloat, formatMoney } from "~/utils/format"; -import { useMediaQuery } from "~/utils/use-media-query"; import { ITEM_TABLE } from "./item-table.config"; +import { joinDetail } from "./item-table.utils"; import type { ItemTableProps } from "./item-table.types"; +/** + * Items as a sortable, selectable table. The table picks its layout from its own width: every + * column where there is room, or (in a side pane or on a phone) the compact item + price rows + * with a sort menu for the columns that went away. + */ export function ItemTable({ items, currency, @@ -20,39 +27,26 @@ export function ItemTable({ selected, onSelectionChange, }: ItemTableProps) { - const wide = useMediaQuery(ITEM_TABLE.wideQuery); - const columns = useMemo[]>(() => { - const item: DataTableColumn = { + const { columns, compact } = useMemo(() => { + const sizes = ITEM_TABLE.columns; + const name: Omit, "width" | "cell"> = { id: "item", header: COPY.itemColumns.item, - width: wide ? ITEM_TABLE.widths.item.wide : ITEM_TABLE.widths.item.narrow, sortValue: (r) => r.name, - cell: (r) => , }; - const price: DataTableColumn = { + const price: Omit, "width"> = { id: "price", header: COPY.itemColumns.price, - width: wide ? ITEM_TABLE.widths.price.wide : ITEM_TABLE.widths.price.narrow, align: "right", - sortValue: (r) => r.price ?? -1, + sortValue: (r) => r.price, cell: (r) => (r.price === null ? COPY.common.empty : formatMoney(r.price, currency)), }; - // Orders rows T1 first, then by price; the cell shows only the tier badge. - const highlights: DataTableColumn = { - id: "highlights", - header: COPY.items.highlights, - width: wide ? ITEM_TABLE.widths.highlights : ITEM_TABLE.widths.highlightsNarrow, - align: "right", - sortValue: highlightSortValue, - cell: (r) => , - }; - if (!wide) return [item, highlights, price]; const location: DataTableColumn[] = showLocation ? [ { id: "location", header: COPY.itemColumns.location, - width: ITEM_TABLE.widths.location, + ...sizes.location, sortValue: (r) => r.locationName, cell: (r) => {r.locationName}, }, @@ -63,37 +57,80 @@ export function ItemTable({ { id: "account", header: COPY.accounts.column, - width: ITEM_TABLE.widths.account, - sortValue: (r) => accountNames.get(r.accountId) ?? "", - cell: (r) => ( - {accountNames.get(r.accountId)} - ), + ...sizes.account, + sortValue: (r) => accountNames.get(r.accountId)?.name ?? null, + cell: (r) => { + const owner = accountNames.get(r.accountId); + return owner && ; + }, }, ] : []; - return [ - item, + const full: readonly DataTableColumn[] = [ + { ...name, ...sizes.item.wide, cell: (r) => }, ...account, ...location, { id: "type", header: COPY.itemColumns.type, - width: ITEM_TABLE.widths.type, + ...sizes.type, sortValue: (r) => r.category, cell: (r) => {r.category}, }, { id: "float", header: COPY.itemColumns.float, - width: ITEM_TABLE.widths.float, + ...sizes.float, align: "right", - sortValue: (r) => r.wear ?? -1, + sortValue: (r) => r.wear, cell: (r) => (r.wear === null ? COPY.common.empty : formatFloat(r.wear)), }, - highlights, - price, + // Orders rows T1 first, then other highlights, then by price; the cell's tier (or marker) + // opens every reason. + { + id: "highlights", + header: COPY.items.highlights, + ...sizes.highlights, + align: "right", + sortValue: highlightSortValue, + cell: (r) => , + }, + { ...price, ...sizes.price.wide }, ]; - }, [currency, showLocation, wide, accountNames]); + // Compact rows carry where the item is and its float, the owner and the highlight button under the name. + const narrow: DataTableCompact = { + columns: [ + { + ...name, + ...sizes.item.narrow, + cell: (r) => ( + + ), + }, + // The sort menu already says "Price"; the header stays for screen readers only. + { ...price, ...sizes.price.narrow, sortValue: undefined, hideHeader: true }, + ], + rowHeight: accountNames + ? ITEM_TABLE.rowHeight.narrowWithAccount + : ITEM_TABLE.rowHeight.narrow, + sortOptions: ITEM_TABLE.sortOptions, + sortLabel: ITEM_TABLE.sortLabel, + }; + return { columns: full, compact: narrow }; + }, [currency, showLocation, accountNames]); const selection = useMemo( () => @@ -116,9 +153,10 @@ export function ItemTable({ ); diff --git a/src/components/item-table/item-table.config.ts b/src/components/item-table/item-table.config.ts index 4ca2e08..107afb0 100644 --- a/src/components/item-table/item-table.config.ts +++ b/src/components/item-table/item-table.config.ts @@ -1,17 +1,44 @@ +import type { + DataTableSortOption, + FittableColumn, + SortState, +} from "~/components/data-table/data-table.types"; +import { ITEM_SORT_LABEL, ITEM_SORTS } from "~/lib/items/item-sort.config"; + export const ITEM_TABLE = { - rowHeight: 64, - wideQuery: "(min-width: 768px)", - /** CSS grid tracks, shared by the table and its loading skeleton. */ - widths: { - item: { wide: "4fr", narrow: "minmax(0, 1fr)" }, - account: "1.3fr", - location: "1.6fr", - type: "1.2fr", - float: "0.9fr", - highlights: "0.7fr", - highlightsNarrow: "84px", - price: { wide: "0.9fr", narrow: "92px" }, - }, + /** + * Narrow rows are taller: the item name may wrap to two lines above the location / float line + * and the highlight button, and `narrowWithAccount` adds the owning account's line in the + * all-accounts view. + */ + rowHeight: { wide: 64, narrow: 104, narrowWithAccount: 120 }, + /** Between the location and the float in a narrow row's detail line. */ + detailSeparator: " · ", + /** + * Grid tracks, shared by the table and its loading skeleton. Minimums keep prices and floats + * whole; when the table can't fit them all, Type hides first, then Location, then Account. + * When even item, float, highlights and price don't fit the table's own width, it switches + * to the narrow (compact) tracks: item and price, with the rest under the name. + */ + columns: { + item: { wide: { width: "4fr", minWidth: 208 }, narrow: { width: "minmax(0, 1fr)" } }, + account: { width: "1.3fr", minWidth: 112, hidePriority: 3 }, + location: { width: "1.6fr", minWidth: 96, hidePriority: 2 }, + type: { width: "1.2fr", minWidth: 80, hidePriority: 1 }, + float: { width: "0.9fr", minWidth: 64 }, + highlights: { width: "0.7fr", minWidth: 76 }, + price: { wide: { width: "0.9fr", minWidth: 96 }, narrow: { width: "92px" } }, + } satisfies Record, + /** The compact table's sort menu, standing in for the Float and Highlights headers. */ + sortOptions: ITEM_SORTS.map(({ id, label, triggerLabel, field, direction }) => ({ + id, + label, + triggerLabel, + columnId: field, + direction, + })) satisfies readonly DataTableSortOption[], + sortLabel: ITEM_SORT_LABEL, + initialSort: { columnId: "price", direction: "desc" } satisfies SortState, /** Enough rows to fill a tall screen; the rest are clipped. */ skeletonRows: 16, } as const; diff --git a/src/components/item-table/item-table.skeleton.tsx b/src/components/item-table/item-table.skeleton.tsx index 002aa60..ba847a5 100644 --- a/src/components/item-table/item-table.skeleton.tsx +++ b/src/components/item-table/item-table.skeleton.tsx @@ -3,20 +3,21 @@ import type { DataTableSkeletonColumn } from "~/components/data-table/data-table import { ITEM_CELL } from "~/components/item-cell/item-cell.config"; import { Skeleton } from "~/components/skeleton/skeleton.component"; import { COPY } from "~/lib/copy/copy.config"; -import { useMediaQuery } from "~/utils/use-media-query"; import { ITEM_TABLE } from "./item-table.config"; import type { ItemTableSkeletonProps } from "./item-table.types"; const textBar = (width: string) => ; /** ItemTable's frame (same columns at the same breakpoints) with placeholder rows. */ -export function ItemTableSkeleton({ showLocation = true, selectable }: ItemTableSkeletonProps) { - const wide = useMediaQuery(ITEM_TABLE.wideQuery); - const { widths } = ITEM_TABLE; - const item: DataTableSkeletonColumn = { +export function ItemTableSkeleton({ + showLocation = true, + showAccount = false, + selectable, +}: ItemTableSkeletonProps) { + const sizes = ITEM_TABLE.columns; + const item = { id: "item", header: COPY.itemColumns.item, - width: wide ? widths.item.wide : widths.item.narrow, cell: (
    ), - }; - const price: DataTableSkeletonColumn = { + } as const; + const price = { id: "price", header: COPY.itemColumns.price, - width: wide ? widths.price.wide : widths.price.narrow, align: "right", cell: textBar("w-14"), - }; + } as const; const highlights: DataTableSkeletonColumn = { id: "highlights", header: COPY.items.highlights, - width: wide ? widths.highlights : widths.highlightsNarrow, + ...sizes.highlights, align: "right", cell: null, }; - const columns: readonly DataTableSkeletonColumn[] = wide - ? [ - item, - ...(showLocation - ? [ - { - id: "location", - header: COPY.itemColumns.location, - width: widths.location, - cell: textBar("w-28"), - }, - ] - : []), - { id: "type", header: COPY.itemColumns.type, width: widths.type, cell: textBar("w-16") }, - { - id: "float", - header: COPY.itemColumns.float, - width: widths.float, - align: "right", - cell: textBar("w-14"), - }, - highlights, - price, - ] - : [item, highlights, price]; + const columns: readonly DataTableSkeletonColumn[] = [ + { ...item, ...sizes.item.wide }, + ...(showAccount + ? [ + { + id: "account", + header: COPY.accounts.column, + ...sizes.account, + cell: textBar("w-20"), + }, + ] + : []), + ...(showLocation + ? [ + { + id: "location", + header: COPY.itemColumns.location, + ...sizes.location, + cell: textBar("w-28"), + }, + ] + : []), + { id: "type", header: COPY.itemColumns.type, ...sizes.type, cell: textBar("w-16") }, + { + id: "float", + header: COPY.itemColumns.float, + ...sizes.float, + align: "right", + cell: textBar("w-14"), + }, + highlights, + { ...price, ...sizes.price.wide }, + ]; + const compact = { + columns: [ + { ...item, ...sizes.item.narrow }, + { ...price, ...sizes.price.narrow }, + ], + rowHeight: showAccount ? ITEM_TABLE.rowHeight.narrowWithAccount : ITEM_TABLE.rowHeight.narrow, + }; return ( diff --git a/src/components/item-table/item-table.types.ts b/src/components/item-table/item-table.types.ts index a6e7fb4..b30f6a3 100644 --- a/src/components/item-table/item-table.types.ts +++ b/src/components/item-table/item-table.types.ts @@ -1,11 +1,12 @@ +import type { AccountDisplayName } from "~/lib/accounts/accounts.types"; import type { ItemView } from "~/lib/items/item.types"; export type ItemTableProps = { readonly items: readonly ItemView[]; readonly currency: string; readonly showLocation?: boolean; - /** Account display names by SteamID. When given, rows show which account holds each item. */ - readonly accountNames?: ReadonlyMap; + /** Account names by SteamID. When given, rows show which account holds each item. */ + readonly accountNames?: ReadonlyMap; /** Enables row selection; keys are `rowKey(item)`. */ readonly selected?: ReadonlySet; readonly onSelectionChange?: (next: ReadonlySet) => void; @@ -13,6 +14,8 @@ export type ItemTableProps = { export type ItemTableSkeletonProps = { readonly showLocation?: boolean; + /** Reserves the Account column of the all-accounts view. */ + readonly showAccount?: boolean; /** Reserves the checkbox column so the real table does not shift sideways. */ readonly selectable?: boolean; }; diff --git a/src/components/item-table/item-table.utils.test.ts b/src/components/item-table/item-table.utils.test.ts new file mode 100644 index 0000000..67c6fa4 --- /dev/null +++ b/src/components/item-table/item-table.utils.test.ts @@ -0,0 +1,16 @@ +/* oxlint-disable typescript/no-floating-promises -- node:test registers suites synchronously */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { joinDetail } from "./item-table.utils.ts"; + +describe("joinDetail", () => { + it("joins the location and the float", () => { + assert.equal(joinDetail(["Knives", "0.1234"], " · "), "Knives · 0.1234"); + }); + + it("skips what is missing without a dangling separator", () => { + assert.equal(joinDetail(["Inventory", null], " · "), "Inventory"); + assert.equal(joinDetail([null, "0.5000"], " · "), "0.5000"); + assert.equal(joinDetail(["", null], " · "), ""); + }); +}); diff --git a/src/components/item-table/item-table.utils.ts b/src/components/item-table/item-table.utils.ts new file mode 100644 index 0000000..7a4429e --- /dev/null +++ b/src/components/item-table/item-table.utils.ts @@ -0,0 +1,4 @@ +/** The muted line of a compact row: whichever of the location and the (already formatted) float are known. */ +export function joinDetail(parts: readonly (string | null)[], separator: string): string { + return parts.filter((part): part is string => part !== null && part !== "").join(separator); +} diff --git a/src/components/kind-pills/kind-pills.component.tsx b/src/components/kind-pills/kind-pills.component.tsx index 22f9574..fefb908 100644 --- a/src/components/kind-pills/kind-pills.component.tsx +++ b/src/components/kind-pills/kind-pills.component.tsx @@ -18,7 +18,7 @@ export function KindPills({ selected, counts, onSelect }: KindPillsProps) { > {COPY.cases.kinds[kind].pill}{" "} {counts ? ( - {formatInteger(counts[kind])} + {formatInteger(counts[kind])} ) : ( )} diff --git a/src/components/load-error/load-error.component.tsx b/src/components/load-error/load-error.component.tsx new file mode 100644 index 0000000..407d215 --- /dev/null +++ b/src/components/load-error/load-error.component.tsx @@ -0,0 +1,37 @@ +import { RefreshCw } from "lucide-react"; +import { Button } from "~/components/button/button.component"; +import { LOAD_ERROR } from "./load-error.config"; +import type { LoadErrorProps } from "./load-error.types"; + +const { classes } = LOAD_ERROR; + +/** A failed load or action: what went wrong and a way to try again, in place of an endless skeleton. */ +export function LoadError({ + title = LOAD_ERROR.title, + message, + onRetry, + retrying = false, +}: LoadErrorProps) { + return ( +
    +
    +

    {title}

    +

    {message}

    +
    + {/* Stays focusable while retrying (a disabled button would drop focus to the page); a press is ignored. */} + +
    + ); +} diff --git a/src/components/load-error/load-error.config.ts b/src/components/load-error/load-error.config.ts new file mode 100644 index 0000000..abec451 --- /dev/null +++ b/src/components/load-error/load-error.config.ts @@ -0,0 +1,15 @@ +export const LOAD_ERROR = { + title: "Couldn't load this page", + retry: "Try again", + retrying: "Retrying…", + iconSize: 12, + /** The button keeps focus while retrying, so it dims instead of using `disabled`. */ + busyClass: "opacity-60", + spinClass: "motion-safe:animate-spin", + classes: { + root: "flex shrink-0 flex-wrap items-center justify-between gap-3 rounded-lg border border-destructive/40 bg-card px-4 py-3", + body: "min-w-0", + title: "font-medium", + message: "mt-0.5 text-destructive", + }, +} as const; diff --git a/src/components/load-error/load-error.types.ts b/src/components/load-error/load-error.types.ts new file mode 100644 index 0000000..e74243d --- /dev/null +++ b/src/components/load-error/load-error.types.ts @@ -0,0 +1,9 @@ +export type LoadErrorProps = { + /** What failed; defaults to the page load. */ + readonly title?: string; + /** Why the load failed, as the server or browser reported it. */ + readonly message: string; + readonly onRetry: () => void; + /** A retry is in flight: the button stays mounted and focusable, but presses are ignored. */ + readonly retrying?: boolean; +}; diff --git a/src/components/login-card/login-card.component.tsx b/src/components/login-card/login-card.component.tsx index b2a2bc1..9724dcb 100644 --- a/src/components/login-card/login-card.component.tsx +++ b/src/components/login-card/login-card.component.tsx @@ -1,12 +1,24 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Button } from "~/components/button/button.component"; import { COPY } from "~/lib/copy/copy.config"; import { postJson } from "~/lib/storage/storage.client"; import { AUTH_STATUS, STORAGE_API } from "~/lib/storage/storage.config"; +import { restoreLostFocus } from "~/utils/restore-focus"; +import { LOGIN_CARD } from "./login-card.config"; import type { LoginCardProps } from "./login-card.types"; /** The Steam QR sign-in, shared by the first-run screen and the add-account dialog. */ -export function LoginCard({ login, title, body, buttonLabel }: LoginCardProps) { +export function LoginCard({ + login, + title, + body, + buttonLabel, + framed = true, + headingId, + bodyId, + headingLevel = 2, +}: LoginCardProps) { + const Heading = headingLevel === 1 ? "h1" : "h2"; const copy = COPY.signIn; const [requestError, setRequestError] = useState(); @@ -22,30 +34,56 @@ export function LoginCard({ login, title, body, buttonLabel }: LoginCardProps) { const message = login.status === AUTH_STATUS.error ? login.message : requestError; const idle = login.status === AUTH_STATUS.signedOut || login.status === AUTH_STATUS.error; + // Each step replaces the last (the button gives way to the QR code, the QR code to + // "connecting", an error brings the button back), so focus on a step that vanished moves to + // whatever stands in its place, marked as the step's focus target. + const root = useRef(null); + const shownStatus = useRef(login.status); + useEffect(() => { + if (shownStatus.current === login.status) return; + shownStatus.current = login.status; + restoreLostFocus(root.current?.querySelector(LOGIN_CARD.stepSelector)); + }, [login.status]); + return ( -
    -

    {title}

    -

    {body}

    +
    + + {title} + +

    + {body} +

    {login.status === AUTH_STATUS.qrPending && ( <>
    -

    {copy.waiting}

    +

    {copy.waiting}

    )} {login.status === AUTH_STATUS.connecting && ( -

    {login.detail}

    +

    + {login.detail} +

    )} {message && ( -

    +

    {message}

    )} {idle && ( - )} diff --git a/src/components/login-card/login-card.config.ts b/src/components/login-card/login-card.config.ts new file mode 100644 index 0000000..4833887 --- /dev/null +++ b/src/components/login-card/login-card.config.ts @@ -0,0 +1,15 @@ +export const LOGIN_CARD = { + /** Its own card on the first-run screen; unframed inside a dialog, which is already the surface. */ + frameClass: "rounded-lg border bg-card p-6", + headingClass: "text-[15px] font-semibold tracking-tight", + bodyClass: "mt-1.5 text-muted-foreground", + /** White behind the QR code in both themes, so phone cameras read it. */ + qrClass: "mx-auto mt-5 size-52 rounded-md bg-white p-2 outline-offset-2 [&>svg]:size-full", + waitingClass: "mt-3 text-center text-xs text-muted-foreground", + detailClass: "mt-5 text-muted-foreground", + errorClass: "mt-4 text-destructive", + buttonClass: "mt-5 w-full", + /** Marks the current step's element, which takes focus when the previous step's vanishes. */ + stepAttribute: { "data-login-step": "" }, + stepSelector: "[data-login-step]", +} as const; diff --git a/src/components/login-card/login-card.types.ts b/src/components/login-card/login-card.types.ts index 4e061bc..08da34c 100644 --- a/src/components/login-card/login-card.types.ts +++ b/src/components/login-card/login-card.types.ts @@ -6,4 +6,11 @@ export type LoginCardProps = { readonly title: string; readonly body: string; readonly buttonLabel: string; + /** Draws its own card; off inside a dialog, which is already the surface. */ + readonly framed?: boolean; + /** Ids for the heading and body, so a surrounding dialog can name and describe itself by them. */ + readonly headingId?: string; + readonly bodyId?: string; + /** The first-run screen's card is the page's main heading; inside a dialog it sits under the page's. */ + readonly headingLevel?: 1 | 2; }; diff --git a/src/components/login-outcome/login-outcome.component.tsx b/src/components/login-outcome/login-outcome.component.tsx new file mode 100644 index 0000000..19483fb --- /dev/null +++ b/src/components/login-outcome/login-outcome.component.tsx @@ -0,0 +1,76 @@ +import { CircleCheck, TriangleAlert } from "lucide-react"; +import { Button } from "~/components/button/button.component"; +import { accountNameWithId } from "~/lib/accounts/account-names"; +import { useAccountScope } from "~/lib/accounts/account-scope.hooks"; +import { useAccountSelection } from "~/lib/accounts/account-scope.store"; +import { useAddAccountDialog } from "~/lib/accounts/add-account-dialog.store"; +import { COPY } from "~/lib/copy/copy.config"; +import { LOGIN_OUTCOME } from "./login-outcome.config"; + +/** Says how the last sign-in from the dialog went, with a shortcut to the account it signed in. */ +export function LoginOutcome() { + const outcome = useAddAccountDialog((state) => state.outcome); + const dismiss = useAddAccountDialog((state) => state.dismissOutcome); + const select = useAccountSelection((state) => state.select); + const { accounts, scope } = useAccountScope(); + const copy = COPY.accounts.outcome; + + const account = accounts.find((a) => a.steamId === outcome?.steamId); + const target = accounts.find((a) => a.steamId === outcome?.target); + const name = account ? accountNameWithId(account) : ""; + const wrongAccount = account !== undefined && target !== undefined && target !== account; + const message = + !outcome || !account + ? "" + : wrongAccount + ? copy.otherAccount(name, accountNameWithId(target)) + : outcome.added + ? copy.added(name) + : copy.reconnected(name); + const Icon = wrongAccount ? TriangleAlert : CircleCheck; + + return ( + <> + {/* Always mounted, so screen readers announce the message when it appears. */} +

    + {message} +

    + {account && message && ( +
    +

    + + {message} +

    +
    + {scope !== account.steamId && ( + + )} + +
    +
    + )} + + ); +} diff --git a/src/components/login-outcome/login-outcome.config.ts b/src/components/login-outcome/login-outcome.config.ts new file mode 100644 index 0000000..a57f323 --- /dev/null +++ b/src/components/login-outcome/login-outcome.config.ts @@ -0,0 +1,10 @@ +export const LOGIN_OUTCOME = { + iconSize: 14, + /** Takes focus after a sign-in when the button that opened the dialog is gone. */ + bannerId: "login-outcome", + bannerClass: + "flex shrink-0 flex-wrap items-center justify-between gap-3 rounded-lg border bg-card px-4 py-3 outline-offset-2", + messageClass: "flex min-w-0 items-center gap-2", + iconClass: { warning: "shrink-0 text-warning", success: "shrink-0 text-success" }, + actionsClass: "flex gap-2", +} as const; diff --git a/src/components/logo/logo.component.tsx b/src/components/logo/logo.component.tsx index 402aea7..7a5c500 100644 --- a/src/components/logo/logo.component.tsx +++ b/src/components/logo/logo.component.tsx @@ -6,13 +6,13 @@ export function LogoMark({ className }: LogoProps) { return ; } -export function Logo({ className }: LogoProps) { +export function Logo({ className, compactBelow360 = false }: LogoProps) { return ( - {LOGO.name} + {LOGO.name} ); } diff --git a/src/components/logo/logo.types.ts b/src/components/logo/logo.types.ts index 422d0e9..30b41c1 100644 --- a/src/components/logo/logo.types.ts +++ b/src/components/logo/logo.types.ts @@ -1 +1,4 @@ -export type LogoProps = { readonly className?: string }; +export type LogoProps = { + readonly className?: string; + readonly compactBelow360?: boolean; +}; diff --git a/src/components/monthly-bars/monthly-bars.component.tsx b/src/components/monthly-bars/monthly-bars.component.tsx index 4d25866..dfb42a5 100644 --- a/src/components/monthly-bars/monthly-bars.component.tsx +++ b/src/components/monthly-bars/monthly-bars.component.tsx @@ -2,26 +2,61 @@ import { formatInteger } from "~/utils/format"; import { MONTHLY_BARS } from "./monthly-bars.config"; import type { MonthlyBarsProps } from "./monthly-bars.types"; -/** One bar per month; hover (or long-press) a bar for the exact count. */ +const { classes } = MONTHLY_BARS; + +/** + * One bar per month, with an expandable count list for touch and keyboard users. Up to a dozen + * months each bar shows its count and month (in full while there are few) and stays narrow; with + * more, only the first and last month are labelled. + */ export function MonthlyBars({ months }: MonthlyBarsProps) { const shown = months.slice(-MONTHLY_BARS.maxMonths); const max = Math.max(...shown.map((m) => m.count), 1); + const labelled = shown.length <= MONTHLY_BARS.labelledMax; + const fullMonths = shown.length <= MONTHLY_BARS.fullMonthMax; + const monthLabel = (month: string) => + fullMonths ? month : (month.split(MONTHLY_BARS.monthSeparator)[1] ?? month); return (
    -
    - {shown.map((m) => ( -
    - ))} -
    -
    - {shown[0]?.month} - {shown[shown.length - 1]?.month} +