Repository: DestinyItemManager/DIM
Pull request #5564 (2020-08-09 05:38:13)

Split character tile into new components
I wanted to make some changes to the store header, but first wanted to split the components up a bit, as well as flatten/simplify the DOM/styles. I've also made a base character tile that is reused across all of the views. 

This touches a lot of surfaces, including D1 and D2, so I've included screenshots of it on desktop and mobile. Hoping to make some future iterations, but wanted to land this before making any more stylistic changes.

I tried to keep the height of the header the same in the before + after. I did update the width on mobile so that it takes up a little more space, since it was available. I also updated the button icon to the vertical triple dot instead of the chevron. Happy to revert that as well and keep this a refactor that excludes any redesigns.

## Desktop
| View | Before | After |
|-----|----|----|
| D2 Inventory | <img width="981" alt="Screen Shot 2020-08-08 at 10 11 36 PM" src="https://user-images.githubusercontent.com/424158/89725565-bd8adb00-d9c5-11ea-8525-ed9ec53448a7.png"> | <img width="982" alt="Screen Shot 2020-08-08 at 10 12 34 PM" src="https://user-images.githubusercontent.com/424158/89725572-c380bc00-d9c5-11ea-8f79-0b0b180edfd4.png"> |
| D2 Progress | <img width="417" alt="Screen Shot 2020-08-08 at 10 12 56 PM" src="https://user-images.githubusercontent.com/424158/89725583-dc896d00-d9c5-11ea-91eb-072998c44d58.png"> | <img width="465" alt="Screen Shot 2020-08-08 at 10 12 49 PM" src="https://user-images.githubusercontent.com/424158/89725587-e27f4e00-d9c5-11ea-8eed-f7f40badcaa3.png"> |
| D1 Inventory | <img width="980" alt="Screen Shot 2020-08-08 at 10 21 14 PM" src="https://user-images.githubusercontent.com/424158/89725636-51f53d80-d9c6-11ea-839e-4e5420d53b35.png"> | <img width="982" alt="Screen Shot 2020-08-08 at 10 21 25 PM" src="https://user-images.githubusercontent.com/424158/89725637-591c4b80-d9c6-11ea-855d-852791e67954.png"> |
| D1 Optimizer | <img width="930" alt="Screen Shot 2020-08-08 at 10 14 01 PM" src="https://user-images.githubusercontent.com/424158/89725594-f925a500-d9c5-11ea-99f4-70d75317cafc.png"> | <img width="949" alt="Screen Shot 2020-08-08 at 10 13 50 PM" src="https://user-images.githubusercontent.com/424158/89725601-ff1b8600-d9c5-11ea-96b5-169dc3aa1a06.png"> |
| D1 Activities | <img width="867" alt="Screen Shot 2020-08-08 at 10 14 16 PM" src="https://user-images.githubusercontent.com/424158/89725619-3be77d00-d9c6-11ea-9435-c9a19b532229.png"> | <img width="833" alt="Screen Shot 2020-08-08 at 10 14 25 PM" src="https://user-images.githubusercontent.com/424158/89725622-3ee26d80-d9c6-11ea-8ce4-c6c7443d5010.png">

## Mobile
| View | Before | After |
|-----|----|----|
| D1 Inventory | ![Screenshot_20200808-220552](https://user-images.githubusercontent.com/424158/89725647-76e9b080-d9c6-11ea-8dff-1332a8254247.png) | ![Screenshot_20200808-223012](https://user-images.githubusercontent.com/424158/89725684-d5169380-d9c6-11ea-9414-d7f54a0f6f1f.png)
| D2 Inventory | ![Screenshot_20200808-220523](https://user-images.githubusercontent.com/424158/89725729-46564680-d9c7-11ea-8c2a-91b13dafb6a1.png)  | ![Screenshot_20200808-220952](https://user-images.githubusercontent.com/424158/89725744-7d2c5c80-d9c7-11ea-9180-26e11cc93cb3.png)
| D2 Loadout Builder | ![Screenshot_20200808-220537](https://user-images.githubusercontent.com/424158/89725750-97fed100-d9c7-11ea-8cf1-548007004fca.png) | ![Screenshot_20200808-221008](https://user-images.githubusercontent.com/424158/89725753-9d5c1b80-d9c7-11ea-9e6f-d3dfb9a010cb.png)





Patch:
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 10ed9c12e..05376bccf 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,26 +1,12 @@
 ## Next
 
-## 6.24.0 <span className="changelog-date">(2020-08-09)</span>
-
 * Configure a custom armor stat per-class in Settings, and it'll show up in item popups, Organizer, Compare, and the new `stat:custom:` search.
-* Speed improvements to wishlist processing.
-* `is:smg` for if you're as bad at remembering "submachine gun" as.. some of us are.
-* No more accidental app reloads when swiping down hard on the page on mobile.
-* Spring (Summer?) cleaning in the Item Popup. Some less important elements have been moved or removed, to make room for more functionality and stats.
-* Bar-based stat values in the Mod preview menu are no longer extremely large bois.
-* Anti-champion damage types are now interpreted in tooltip descriptions.
-* Seasonal Artifact is now previewable, but be warned:
-  * Some data from the API is wrong, and the Season 11 artifact is incorrectly labeled.
-  * It can show seasonal mods you have equipped, but Season 11 mods still aren't in Collections data, so mod unlocks aren't displayed.
-* Spreadsheet columns slightly adjusted to get them back to their usual column names.
-* Lots going on behind the scenes to clear up errors and get Loadout Optimizer ready for upgrades!
 
 ## 6.23.0 <span className="changelog-date">(2020-08-02)</span>
 
 * You can add tags and notes to shaders! Keep track of your favorites and which shaders you could do without.
 * Searches now support parentheses for grouping, the "and" keyword, and the "not" keyword. Example: `(is:weapon and is:sniperrifle) or not (is:armor and modslot:arrival)`. "and" has higher precedence than "or", which has higher precedence than just a space (which still means "and").
 * Fixed the size of damage type icons in D1.
-* Our Content Security Policy is more restrictive now, external and injected scripts may fail but this keeps your account and data safer.
 
 ## 6.22.1 <span className="changelog-date">(2020-07-27)</span>
 
diff --git a/package.json b/package.json
index 51621a781..c832f7167 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "dim",
-  "version": "6.24.0",
+  "version": "6.23.0",
   "description": "An item manager for Destiny.",
   "main": "app/index.html",
   "scripts": {
diff --git a/src/app/character-tile/CharacterHeaderXP.m.scss b/src/app/character-tile/CharacterHeaderXP.m.scss
new file mode 100644
index 000000000..0d2d31d8a
--- /dev/null
+++ b/src/app/character-tile/CharacterHeaderXP.m.scss
@@ -0,0 +1,27 @@
+@import '../variables.scss';
+
+$xp-bar-height: 2px;
+
+.xpBar {
+  position: absolute;
+  width: 100%;
+  bottom: 0;
+}
+
+.levelBar {
+  position: absolute;
+  height: $xp-bar-height;
+  width: 100%;
+  left: 0;
+  bottom: -$xp-bar-height;
+  background: #666;
+}
+
+.levelBarProgress {
+  background-color: $xp;
+  height: 100%;
+
+  &.moteProgress {
+    background-color: rgb(40, 132, 179);
+  }
+}
diff --git a/src/app/dim-ui/ExpandableTextBlock.m.scss.d.ts b/src/app/character-tile/CharacterHeaderXP.m.scss.d.ts
similarity index 62%
rename from src/app/dim-ui/ExpandableTextBlock.m.scss.d.ts
rename to src/app/character-tile/CharacterHeaderXP.m.scss.d.ts
index 56eae93e7..a0ba5366c 100644
--- a/src/app/dim-ui/ExpandableTextBlock.m.scss.d.ts
+++ b/src/app/character-tile/CharacterHeaderXP.m.scss.d.ts
@@ -1,8 +1,10 @@
 // This file is automatically generated.
 // Please do not change this file!
 interface CssExports {
-  'open': string;
-  'textBlockWrapper': string;
+  'levelBar': string;
+  'levelBarProgress': string;
+  'moteProgress': string;
+  'xpBar': string;
 }
 export const cssExports: CssExports;
 export default cssExports;
diff --git a/src/app/character-tile/CharacterHeaderXP.tsx b/src/app/character-tile/CharacterHeaderXP.tsx
new file mode 100644
index 000000000..4e58c3f2f
--- /dev/null
+++ b/src/app/character-tile/CharacterHeaderXP.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import clsx from 'clsx';
+import { t } from 'app/i18next-t';
+
+import { D1Store } from '../inventory/store-types';
+import PressTip from '../dim-ui/PressTip';
+import { percent } from '../shell/filters';
+import { D1ProgressionHashes } from 'app/search/d1-known-values';
+
+import styles from './CharacterHeaderXP.m.scss';
+
+function getLevelBar(store: D1Store) {
+  const prestige = store.progression?.progressions.find(
+    (p) => p.progressionHash === D1ProgressionHashes.Prestige
+  );
+  let levelBar = store?.percentToNextLevel ?? 0;
+  let xpTillMote: string | undefined = undefined;
+  if (prestige) {
+    levelBar = prestige.progressToNextLevel / prestige.nextLevelAt;
+    xpTillMote = t('Stats.Prestige', {
+      level: prestige.level,
+      exp: prestige.nextLevelAt - prestige.progressToNextLevel,
+    });
+  }
+  return {
+    levelBar,
+    xpTillMote,
+  };
+}
+
+// This is just a D1 feature, so it only accepts a D1 store.
+export default function CharacterHeaderXPBar({ store }: { store: D1Store }) {
+  const { levelBar, xpTillMote } = getLevelBar(store);
+  return (
+    <div className={styles.xpBar}>
+      <PressTip tooltip={xpTillMote}>
+        <div className={styles.levelBar}>
+          <div
+            className={clsx(styles.levelBarProgress, {
+              [styles.moteProgress]: !store.percentToNextLevel,
+            })}
+            style={{ width: percent(levelBar) }}
+          />
+        </div>
+      </PressTip>
+    </div>
+  );
+}
diff --git a/src/app/character-tile/CharacterTile.scss b/src/app/character-tile/CharacterTile.scss
new file mode 100644
index 000000000..50728fabb
--- /dev/null
+++ b/src/app/character-tile/CharacterTile.scss
@@ -0,0 +1,113 @@
+@import '../variables.scss';
+
+.character-tile {
+  flex: 1;
+  display: flex;
+  position: relative;
+  z-index: 0;
+
+  .background {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    filter: brightness(70%);
+    background-size: cover;
+    background-position: left center;
+    background-repeat: no-repeat;
+    background-color: black;
+    z-index: -1;
+  }
+
+  .character-text {
+    flex: 1;
+    text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5), 0px 0px 10px rgba(0, 0, 0, 0.5);
+    display: flex;
+    flex-direction: column;
+    margin: 4px 6px 2px 0px;
+
+    .vault & {
+      flex-direction: row;
+      align-items: center;
+    }
+
+    .app-icon {
+      filter: drop-shadow(1px 1px 1px rgba(0, 0, 0, 0.5))
+        drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.5));
+    }
+  }
+
+  .emblem {
+    width: 32px;
+    height: 32px;
+    margin: 0 8px;
+    align-self: center;
+    background-color: rgba(0, 0, 0, 0.2);
+    background-size: cover;
+
+    @include phone-portrait {
+      margin-right: 15px;
+    }
+
+    .destiny2 & {
+      visibility: hidden;
+    }
+
+    &.vault {
+      visibility: visible;
+      height: 40px;
+      width: 40px;
+      margin: 8px;
+    }
+  }
+
+  .class,
+  .race-gender {
+    text-transform: capitalize;
+    flex: 1;
+    text-overflow: ellipsis;
+    overflow: hidden;
+    line-height: 1.4em;
+  }
+
+  .powerLevel {
+    font-family: 'Helvetica Neue', 'Helvetica', Arial, sans-serif;
+    font-weight: 500;
+  }
+
+  .powerLevel,
+  .light {
+    color: $power;
+
+    .app-icon {
+      vertical-align: 70%;
+      font-size: 40%;
+      margin-right: 2px;
+    }
+  }
+
+  .level {
+    margin-right: 1px;
+  }
+
+  .top,
+  .bottom {
+    // flex: 1;
+    display: flex;
+    flex-wrap: nowrap;
+    flex-direction: row;
+    align-items: center;
+    font-weight: 400;
+  }
+
+  .top {
+    font-size: 20px;
+    font-weight: 400;
+    height: 24px;
+  }
+
+  .bottom {
+    font-size: 12px;
+    color: white;
+    height: 14px;
+  }
+}
diff --git a/src/app/character-tile/CharacterTile.tsx b/src/app/character-tile/CharacterTile.tsx
new file mode 100644
index 000000000..863504ace
--- /dev/null
+++ b/src/app/character-tile/CharacterTile.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import clsx from 'clsx';
+import { DimStore } from '../inventory/store-types';
+import { AppIcon, powerActionIcon } from '../shell/icons';
+import './CharacterTile.scss';
+
+const CharacterEmblem = ({ store }: { store: DimStore }) => (
+  <div
+    className={clsx('emblem', { vault: store.isVault })}
+    style={{ backgroundImage: `url("${store.icon}")` }}
+  />
+);
+
+/**
+ * Render a basic character tile without any event handlers
+ * This is currently being shared between StoreHeading and CharacterTileButton
+ */
+export default function CharacterTile({ store }: { store: DimStore }) {
+  return (
+    <div className="character-tile">
+      <div className="background" style={{ backgroundImage: `url("${store.background}")` }} />
+      <CharacterEmblem store={store} />
+      <div className="character-text">
+        <div className="top">
+          <div className="class">{store.className}</div>
+          {!store.isVault && (
+            <div className="powerLevel">
+              <AppIcon icon={powerActionIcon} />
+              {store.powerLevel}
+            </div>
+          )}
+        </div>
+        {!store.isVault && (
+          <div className="bottom">
+            <div className="race-gender">{store.genderRace}</div>
+            {store.isDestiny1() && store.level < 40 && <div className="level">{store.level}</div>}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
diff --git a/src/app/character-tile/CharacterTileButton.tsx b/src/app/character-tile/CharacterTileButton.tsx
new file mode 100644
index 000000000..16b5671af
--- /dev/null
+++ b/src/app/character-tile/CharacterTileButton.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import clsx from 'clsx';
+import { DimStore } from '../inventory/store-types';
+import CharacterTile from './CharacterTile';
+import './StoreHeading.scss';
+
+/** Render a {CharacterTile} as a button */
+export default function CharacterTileButton({
+  character,
+  onClick,
+}: {
+  character: DimStore;
+  onClick?(id: string): void;
+}) {
+  const handleClick = () => onClick?.(character.id);
+
+  return (
+    <div
+      onClick={handleClick}
+      className={clsx('character', {
+        current: character.current,
+        destiny2: character.isDestiny2(),
+      })}
+    >
+      <CharacterTile store={character} />
+    </div>
+  );
+}
diff --git a/src/app/character-tile/StoreHeading.scss b/src/app/character-tile/StoreHeading.scss
new file mode 100644
index 000000000..deafbc1fc
--- /dev/null
+++ b/src/app/character-tile/StoreHeading.scss
@@ -0,0 +1,63 @@
+@import '../variables.scss';
+
+.character {
+  cursor: pointer;
+  display: flex;
+  flex-direction: row;
+  position: relative;
+  height: 100%;
+  height: 46px;
+
+  &.vault {
+    width: calc(6px + var(--character-column-width) - var(--item-margin));
+  }
+
+  &.current:before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    z-index: 1;
+    border-top: 13px solid $orange;
+    border-right: 13px solid transparent;
+  }
+
+  &.destiny2 {
+    .background {
+      filter: none;
+    }
+  }
+
+  &:hover,
+  &:active {
+    .background {
+      filter: brightness(130%) !important;
+    }
+  }
+}
+
+.loadout-button {
+  opacity: 0.8;
+  background-color: rgba(0, 0, 0, 0.7);
+  width: 16px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+
+  &.loadout-open {
+    opacity: 1;
+    background-color: rgba(0, 0, 0, 1);
+  }
+}
+
+.lang-ru {
+  .vault .class {
+    font-size: 16px;
+  }
+}
+
+.lang-ja {
+  .class {
+    font-size: 16px;
+  }
+}
diff --git a/src/app/character-tile/StoreHeading.tsx b/src/app/character-tile/StoreHeading.tsx
new file mode 100644
index 000000000..18a62bdf4
--- /dev/null
+++ b/src/app/character-tile/StoreHeading.tsx
@@ -0,0 +1,116 @@
+import React from 'react';
+import clsx from 'clsx';
+import { DimStore } from '../inventory/store-types';
+import { t } from 'app/i18next-t';
+import './StoreHeading.scss';
+import LoadoutPopup from '../loadout/LoadoutPopup';
+import ClickOutside from '../dim-ui/ClickOutside';
+import ReactDOM from 'react-dom';
+import { AppIcon, faEllipsisV } from '../shell/icons';
+import CharacterHeaderXPBar from './CharacterHeaderXP';
+import CharacterTile from './CharacterTile';
+
+interface Props {
+  store: DimStore;
+  /** If this ref is provided, the loadout menu will be placed inside of it instead of in this tile. */
+  loadoutMenuRef?: React.RefObject<HTMLElement>;
+  /** For mobile, this is whichever store is visible at the time. */
+  selectedStore?: DimStore;
+  /** Fires if a store other than the selected store is tapped. */
+  onTapped?(storeId: string): void;
+}
+
+interface State {
+  loadoutMenuOpen: boolean;
+}
+
+// Wrap the {CharacterTile} with a button for the loadout menu and the D1 XP progress bar
+const CharacterHeader = ({
+  store,
+  loadoutMenuOpen,
+  menuRef,
+  onClick,
+}: {
+  store: DimStore;
+  loadoutMenuOpen: boolean;
+  menuRef: React.RefObject<HTMLDivElement>;
+  onClick: () => void;
+}) => (
+  <div
+    className={clsx('character', {
+      current: store.current,
+      destiny1: store.isDestiny1(),
+      destiny2: store.isDestiny2(),
+      vault: store.isVault,
+    })}
+    ref={menuRef}
+    onClick={onClick}
+  >
+    <CharacterTile store={store} />
+    <div
+      className={clsx('loadout-button', {
+        'loadout-open': loadoutMenuOpen,
+      })}
+    >
+      <AppIcon icon={faEllipsisV} title={t('Loadouts.Loadouts')} />
+    </div>
+    {!store.isVault && store.isDestiny1() && <CharacterHeaderXPBar store={store} />}
+  </div>
+);
+
+/**
+ * This is the character dropdown used at the top of the inventory page.
+ * It will render a {CharacterTile} in addition to a button for the loadout menu
+ */
+export default class StoreHeading extends React.Component<Props, State> {
+  state: State = { loadoutMenuOpen: false };
+  private menuTrigger = React.createRef<HTMLDivElement>();
+
+  render() {
+    const { store, loadoutMenuRef } = this.props;
+    const { loadoutMenuOpen } = this.state;
+
+    let loadoutMenu;
+    if (loadoutMenuOpen) {
+      const menuContents = (
+        <ClickOutside onClickOutside={this.clickOutsideLoadoutMenu} className="loadout-menu">
+          <LoadoutPopup dimStore={store} onClick={this.clickOutsideLoadoutMenu} />
+        </ClickOutside>
+      );
+
+      loadoutMenu = loadoutMenuRef
+        ? ReactDOM.createPortal(menuContents, loadoutMenuRef.current!)
+        : menuContents;
+    }
+
+    return (
+      <div>
+        <CharacterHeader
+          store={store}
+          loadoutMenuOpen={loadoutMenuOpen}
+          menuRef={this.menuTrigger}
+          onClick={this.openLoadoutPopup}
+        />
+        {loadoutMenu}
+      </div>
+    );
+  }
+
+  private openLoadoutPopup = () => {
+    const { store, selectedStore, onTapped } = this.props;
+    const { loadoutMenuOpen } = this.state;
+
+    if (store !== selectedStore && onTapped) {
+      onTapped?.(store.id);
+      return;
+    }
+
+    this.setState({ loadoutMenuOpen: !loadoutMenuOpen });
+  };
+
+  private clickOutsideLoadoutMenu = (e) => {
+    if (!e || !this.menuTrigger.current || !this.menuTrigger.current.contains(e.target)) {
+      this.setState({ loadoutMenuOpen: false });
+    }
+  };
+}
diff --git a/src/app/destiny1/activities/Activities.tsx b/src/app/destiny1/activities/Activities.tsx
index 1825d082f..5cb4a6673 100644
--- a/src/app/destiny1/activities/Activities.tsx
+++ b/src/app/destiny1/activities/Activities.tsx
@@ -4,7 +4,7 @@ import BungieImage, { bungieBackgroundStyle } from '../../dim-ui/BungieImage';
 import { DimStore, D1Store } from '../../inventory/store-types';
 import { RootState } from '../../store/reducers';
 import { sortedStoresSelector } from '../../inventory/selectors';
-import SimpleCharacterTile from '../../inventory/SimpleCharacterTile';
+import CharacterTileButton from '../../character-tile/CharacterTileButton';
 import CollapsibleTitle from '../../dim-ui/CollapsibleTitle';
 import { AppIcon, starIcon } from '../../shell/icons';
 import { t } from 'app/i18next-t';
@@ -86,7 +86,7 @@ class Activities extends React.Component<Props> {
         <div className="activities-characters">
           {characters.map((store) => (
             <div key={store.id} className="activities-character">
-              <SimpleCharacterTile character={store} />
+              <CharacterTileButton character={store} />
             </div>
           ))}
         </div>
diff --git a/src/app/destiny1/activities/activities.scss b/src/app/destiny1/activities/activities.scss
index 91ceb8575..efa2c5c52 100644
--- a/src/app/destiny1/activities/activities.scss
+++ b/src/app/destiny1/activities/activities.scss
@@ -7,6 +7,7 @@
 
   .activities-characters {
     display: flex;
+    justify-content: space-between;
     margin-top: 12px;
     margin-bottom: 12px;
   }
@@ -15,15 +16,10 @@
     flex-shrink: 0;
     display: flex;
     margin-right: 12px;
-    width: calc(40px + var(--item-size) + (3 * (var(--item-size) + 8px)));
 
     .character {
       height: 46px;
-    }
-
-    .character-box {
-      cursor: default;
-      border-bottom: none;
+      width: 220px;
     }
 
     &:last-child {
diff --git a/src/app/dim-ui/CharacterSelect.m.scss b/src/app/dim-ui/CharacterSelect.m.scss
index 24a5a5be5..8de9e657d 100644
--- a/src/app/dim-ui/CharacterSelect.m.scss
+++ b/src/app/dim-ui/CharacterSelect.m.scss
@@ -2,13 +2,7 @@
 
 .select {
   display: flex;
-
-  :global(.character-box) {
-    border-bottom: none;
-    @include phone-portrait {
-      max-width: 100%;
-    }
-  }
+  justify-content: space-between;
 }
 
 .horizontal {
@@ -42,9 +36,15 @@
 }
 
 .tile {
+  height: 46px;
   min-width: 230px;
   @include phone-portrait {
+    min-width: 260px;
     margin-bottom: 6px;
+
+    > div {
+      margin: 0 5px;
+    }
   }
 }
 
@@ -54,9 +54,12 @@
 
 .pager {
   overflow: hidden;
-  padding-left: 20px;
   > div {
     max-width: 250px;
+    @include phone-portrait {
+      max-width: 260px;
+    }
+
     margin: 0 auto;
     overflow: visible !important;
   }
diff --git a/src/app/dim-ui/CharacterSelect.tsx b/src/app/dim-ui/CharacterSelect.tsx
index af318be2c..37c0f9bd6 100644
--- a/src/app/dim-ui/CharacterSelect.tsx
+++ b/src/app/dim-ui/CharacterSelect.tsx
@@ -1,7 +1,7 @@
 import React from 'react';
 import clsx from 'clsx';
 import { DimStore } from '../inventory/store-types';
-import SimpleCharacterTile from '../inventory/SimpleCharacterTile';
+import CharacterTileButton from '../character-tile/CharacterTileButton';
 import { Frame, Track, View, ViewPager } from 'react-view-pager';
 import styles from './CharacterSelect.m.scss';
 
@@ -38,7 +38,7 @@ export default function CharacterSelect({
                 .filter((s) => !s.isVault)
                 .map((store) => (
                   <View className={styles.tile} key={store.id}>
-                    <SimpleCharacterTile character={store} onClick={onCharacterChanged} />
+                    <CharacterTileButton character={store} onClick={onCharacterChanged} />
                   </View>
                 ))}
             </Track>
@@ -59,7 +59,7 @@ export default function CharacterSelect({
               [styles.unselected]: store.id !== selectedStore.id,
             })}
           >
-            <SimpleCharacterTile character={store} onClick={onCharacterChanged} />
+            <CharacterTileButton character={store} onClick={onCharacterChanged} />
           </div>
         ))}
     </div>
diff --git a/src/app/dim-ui/ExpandableTextBlock.m.scss b/src/app/dim-ui/ExpandableTextBlock.m.scss
deleted file mode 100644
index 7ce308ea6..000000000
--- a/src/app/dim-ui/ExpandableTextBlock.m.scss
+++ /dev/null
@@ -1,22 +0,0 @@
-.textBlockWrapper {
-  position: relative;
-  overflow: hidden;
-  &:hover {
-    cursor: pointer;
-  }
-  &::after {
-    content: '';
-    position: absolute;
-    top: 0;
-    right: 0;
-    bottom: 0;
-    left: 0;
-    box-shadow: inset 0 -31px 20px -20px black;
-  }
-  &.open:hover {
-    cursor: default;
-  }
-  &.open::after {
-    display: none;
-  }
-}
diff --git a/src/app/dim-ui/ExpandableTextBlock.tsx b/src/app/dim-ui/ExpandableTextBlock.tsx
deleted file mode 100644
index 360a64ad9..000000000
--- a/src/app/dim-ui/ExpandableTextBlock.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import clsx from 'clsx';
-import React, { useState, useRef, useLayoutEffect, useEffect } from 'react';
-import styles from './ExpandableTextBlock.m.scss';
-
-export function ExpandableTextBlock({
-  linesWhenClosed,
-  alreadyOpen,
-  children,
-  className,
-}: {
-  linesWhenClosed: number;
-  alreadyOpen?: boolean;
-  children: React.ReactNode;
-  className?: string;
-}) {
-  const [isOpen, setOpen] = useState(alreadyOpen);
-  const [closedHeight, setClosedHeight] = useState<number>();
-  const contentRef = useRef<HTMLDivElement>(null);
-  const wrapperRef = useRef<HTMLDivElement>(null);
-
-  // measure the element height, with lines clamped, the first time this component exists
-  useLayoutEffect(() => {
-    setClosedHeight(contentRef.current!.clientHeight);
-  }, []);
-  // after the element has been measured, set isOpen if the unclamped text still fits in clamped height
-  useEffect(() => {
-    if (closedHeight && wrapperRef.current!.clientHeight >= contentRef.current!.clientHeight) {
-      setOpen(true);
-    }
-  }, [closedHeight]);
-
-  return (
-    <div
-      className={clsx(className, styles.textBlockWrapper, { [styles.open]: isOpen })}
-      ref={wrapperRef}
-      onClick={() => setOpen(true)}
-      style={{ height: isOpen ? 'max-content' : closedHeight, overflow: 'hidden' }}
-    >
-      <div
-        ref={contentRef}
-        style={
-          closedHeight
-            ? undefined
-            : {
-                WebkitLineClamp: linesWhenClosed,
-                WebkitBoxOrient: 'vertical',
-                display: '-webkit-box',
-              }
-        }
-      >
-        {children}
-      </div>
-    </div>
-  );
-}
diff --git a/src/app/inventory/SimpleCharacterTile.tsx b/src/app/inventory/SimpleCharacterTile.tsx
deleted file mode 100644
index 832da12d7..000000000
--- a/src/app/inventory/SimpleCharacterTile.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import React from 'react';
-import clsx from 'clsx';
-import { DimStore } from './store-types';
-import { AppIcon, powerActionIcon } from '../shell/icons';
-import './StoreHeading.scss';
-
-export default function SimpleCharacterTile({
-  character,
-  onClick,
-}: {
-  character: DimStore;
-  onClick?(id: string): void;
-}) {
-  const handleClick = () => onClick?.(character.id);
-
-  return (
-    <div onClick={handleClick} className={clsx('character', { current: character.current })}>
-      <div
-        className={clsx('character-box', {
-          destiny2: character.isDestiny2(),
-        })}
-      >
-        <div className="background" style={{ backgroundImage: `url("${character.background}")` }} />
-        <div className="details">
-          <div className="emblem" style={{ backgroundImage: `url("${character.icon}")` }} />
-          <div className="character-text">
-            <div className="top">
-              <div className="class">{character.className}</div>
-              <div className="powerLevel">
-                <AppIcon icon={powerActionIcon} />
-                {character.powerLevel}
-              </div>
-            </div>
-            <div className="bottom">
-              <div className="race-gender">{character.genderRace}</div>
-              {character.isDestiny1() && <div className="level">{character.level}</div>}
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-  );
-}
diff --git a/src/app/inventory/StoreHeading.scss b/src/app/inventory/StoreHeading.scss
deleted file mode 100644
index 70e1164ee..000000000
--- a/src/app/inventory/StoreHeading.scss
+++ /dev/null
@@ -1,223 +0,0 @@
-@import '../variables.scss';
-
-$xp-bar-height: 2px;
-
-.character {
-  flex: 1;
-  width: 100%;
-}
-
-.powerLevel,
-.light {
-  color: $power;
-}
-
-.character-box {
-  position: relative;
-  border-bottom: $xp-bar-height solid #666;
-  height: 46px;
-  max-width: 230px;
-  cursor: pointer;
-
-  .powerLevel {
-    font-family: 'Helvetica Neue', 'Helvetica', Arial, sans-serif;
-    font-weight: 500;
-  }
-
-  .powerLevel,
-  .light {
-    color: $power;
-
-    .app-icon {
-      vertical-align: 70%;
-      font-size: 40%;
-      margin-right: 2px;
-    }
-  }
-
-  .phone-portrait & {
-    margin: 0 auto;
-  }
-
-  .details {
-    display: flex;
-    flex-direction: row;
-    align-items: center;
-    padding: 4px 6px 4px 8px;
-    position: relative;
-    z-index: 0;
-  }
-
-  .background {
-    position: absolute;
-    width: 100%;
-    height: 100%;
-    filter: brightness(70%);
-    background-size: cover;
-    background-position: left center;
-    background-repeat: no-repeat;
-    z-index: 0;
-  }
-
-  .overlay {
-    display: none;
-    z-index: 1;
-    position: absolute;
-    width: 100%;
-    height: 100%;
-    top: 0;
-    left: 0;
-    opacity: 0.1;
-    background-color: white;
-  }
-
-  &:hover,
-  &:active {
-    .background {
-      filter: brightness(130%) !important;
-    }
-  }
-
-  .character-text {
-    flex: 1;
-    text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5), 0px 0px 10px rgba(0, 0, 0, 0.5);
-    min-width: 0;
-    display: flex;
-    flex-direction: column;
-    .vault & {
-      flex-direction: row;
-      align-items: center;
-    }
-
-    .app-icon {
-      filter: drop-shadow(1px 1px 1px rgba(0, 0, 0, 0.5))
-        drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.5));
-    }
-  }
-
-  .top,
-  .bottom {
-    width: 100%;
-    display: flex;
-    flex-wrap: nowrap;
-    flex-direction: row;
-    align-items: center;
-    font-weight: 400;
-  }
-  .top {
-    font-size: 20px;
-    font-weight: 400;
-    height: 24px;
-  }
-  .bottom {
-    font-size: 12px;
-    color: white;
-    height: 14px;
-  }
-  .emblem {
-    width: 32px;
-    height: 32px;
-    margin-right: 4px;
-    background-color: rgba(0, 0, 0, 0.2);
-    background-size: cover;
-  }
-  .class,
-  .race-gender {
-    text-transform: capitalize;
-    flex: 1;
-    text-overflow: ellipsis;
-    overflow: hidden;
-    line-height: 1.4em;
-  }
-  .level {
-    margin-right: 1px;
-  }
-
-  &.destiny2 {
-    border-bottom: none;
-
-    .emblem {
-      visibility: hidden;
-    }
-    .background {
-      filter: none;
-    }
-  }
-}
-
-.level-bar {
-  height: $xp-bar-height;
-  width: 100%;
-  position: absolute;
-  bottom: -$xp-bar-height;
-  left: 0;
-  .level-bar-progress {
-    background-color: $xp;
-    height: 100%;
-
-    &.mote-progress {
-      background-color: rgb(40, 132, 179);
-    }
-  }
-}
-
-.character-box.vault {
-  width: calc(6px + var(--character-column-width) - var(--item-margin));
-  border-bottom: 0;
-  .background {
-    background-color: black;
-    background-position: center right;
-    background-size: contain;
-    filter: none;
-  }
-  .emblem {
-    background-color: transparent;
-    width: 40px;
-    height: 40px;
-    margin-right: 6px;
-  }
-  .details {
-    padding-left: 6px;
-    padding-top: 0;
-    padding-bottom: 0;
-    height: 100%;
-  }
-}
-
-.loadout-button {
-  color: white;
-  text-align: center;
-  font-size: 18px !important;
-  cursor: pointer;
-  outline: none;
-  margin-left: 6px;
-  margin-top: 1px;
-  opacity: 0.8;
-}
-
-.current {
-  &.character,
-  &.character-swipe {
-    .character-box:before {
-      content: '';
-      position: absolute;
-      top: 0;
-      left: 0;
-      z-index: 1;
-      border-top: 13px solid gold;
-      border-right: 13px solid transparent;
-    }
-  }
-}
-
-.lang-ru {
-  .vault .class {
-    font-size: 16px;
-  }
-}
-
-.lang-ja {
-  .class {
-    font-size: 16px;
-  }
-}
diff --git a/src/app/inventory/StoreHeading.tsx b/src/app/inventory/StoreHeading.tsx
deleted file mode 100644
index 07e92fe44..000000000
--- a/src/app/inventory/StoreHeading.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-import React from 'react';
-import clsx from 'clsx';
-import { DimStore, DimVault } from './store-types';
-import PressTip from '../dim-ui/PressTip';
-import { t } from 'app/i18next-t';
-import './StoreHeading.scss';
-import LoadoutPopup from '../loadout/LoadoutPopup';
-import ClickOutside from '../dim-ui/ClickOutside';
-import ReactDOM from 'react-dom';
-import { AppIcon, powerActionIcon, openDropdownIcon } from '../shell/icons';
-import { percent } from '../shell/filters';
-
-interface Props {
-  store: DimStore;
-  /** If this ref is provided, the loadout menu will be placed inside of it instead of in this tile. */
-  loadoutMenuRef?: React.RefObject<HTMLElement>;
-  /** For mobile, this is whichever store is visible at the time. */
-  selectedStore?: DimStore;
-  /** Fires if a store other than the selected store is tapped. */
-  onTapped?(storeId: string): void;
-}
-
-interface State {
-  loadoutMenuOpen: boolean;
-}
-
-function isVault(store: DimStore): store is DimVault {
-  return store.isVault;
-}
-
-export default class StoreHeading extends React.Component<Props, State> {
-  state: State = { loadoutMenuOpen: false };
-  private menuTrigger = React.createRef<HTMLDivElement>();
-
-  render() {
-    const { store, loadoutMenuRef } = this.props;
-    const { loadoutMenuOpen } = this.state;
-
-    let loadoutMenu;
-    if (loadoutMenuOpen) {
-      const menuContents = (
-        <ClickOutside onClickOutside={this.clickOutsideLoadoutMenu} className="loadout-menu">
-          <LoadoutPopup dimStore={store} onClick={this.clickOutsideLoadoutMenu} />
-        </ClickOutside>
-      );
-
-      loadoutMenu = loadoutMenuRef
-        ? ReactDOM.createPortal(menuContents, loadoutMenuRef.current!)
-        : menuContents;
-    }
-
-    const loadoutButton = (
-      <AppIcon className="loadout-button" icon={openDropdownIcon} title={t('Loadouts.Loadouts')} />
-    );
-    const background = (
-      <div className="background" style={{ backgroundImage: `url("${store.background}")` }} />
-    );
-    const emblem = <div className="emblem" style={{ backgroundImage: `url("${store.icon}")` }} />;
-
-    // TODO: break up into some pure components
-
-    if (isVault(store)) {
-      return (
-        <div className="character" aria-label={store.name}>
-          <div
-            className="character-box vault"
-            ref={this.menuTrigger}
-            onClick={this.openLoadoutPopup}
-          >
-            {background}
-            <div className="details">
-              {emblem}
-              <div className="character-text">
-                <div className="top">
-                  <div className="class">{store.className}</div>
-                </div>
-              </div>
-              {loadoutButton}
-            </div>
-          </div>
-          {loadoutMenu}
-        </div>
-      );
-    }
-
-    const { levelBar, xpTillMote } = getLevelBar(store);
-
-    return (
-      <div className={clsx('character', { current: store.current })}>
-        <div
-          className={clsx('character-box', {
-            destiny2: store.isDestiny2(),
-          })}
-          aria-label={store.name}
-          onClick={this.openLoadoutPopup}
-          ref={this.menuTrigger}
-        >
-          {background}
-          <div className="details">
-            {emblem}
-            <div className="character-text">
-              <div className="top">
-                <div className="class">{store.className}</div>
-                <div className="powerLevel">
-                  <AppIcon icon={powerActionIcon} />
-                  {store.powerLevel}
-                </div>
-              </div>
-              <div className="bottom">
-                <div className="race-gender">{store.genderRace}</div>
-                {store.isDestiny1() && store.level < 40 && (
-                  <div className="level">{store.level}</div>
-                )}
-              </div>
-            </div>
-            {loadoutButton}
-          </div>
-          {store.isDestiny1() && (
-            <PressTip tooltip={xpTillMote}>
-              <div className="level-bar">
-                <div
-                  className={clsx('level-bar-progress', {
-                    'mote-progress': !store.percentToNextLevel,
-                  })}
-                  style={{ width: percent(levelBar) }}
-                />
-              </div>
-            </PressTip>
-          )}
-        </div>
-        {loadoutMenu}
-      </div>
-    );
-  }
-
-  private openLoadoutPopup = () => {
-    const { store, selectedStore, onTapped } = this.props;
-    const { loadoutMenuOpen } = this.state;
-
-    if (store !== selectedStore && onTapped) {
-      onTapped?.(store.id);
-      return;
-    }
-
-    this.setState({ loadoutMenuOpen: !loadoutMenuOpen });
-  };
-
-  private clickOutsideLoadoutMenu = (e) => {
-    if (!e || !this.menuTrigger.current || !this.menuTrigger.current.contains(e.target)) {
-      this.setState({ loadoutMenuOpen: false });
-    }
-  };
-}
-
-function getLevelBar(store: DimStore) {
-  if (store.isDestiny2()) {
-    return {
-      levelBar: 0,
-      xpTillMote: undefined,
-    };
-  }
-  if (store.percentToNextLevel) {
-    return {
-      levelBar: store.percentToNextLevel,
-      xpTillMote: undefined,
-    };
-  }
-  if (store.progression?.progressions) {
-    const prestige = store.progression.progressions.find((p) => p.progressionHash === 2030054750); // some kind of d1 hash
-    if (prestige) {
-      const data = {
-        level: prestige.level,
-        exp: prestige.nextLevelAt - prestige.progressToNextLevel,
-      };
-      return {
-        xpTillMote: t('Stats.Prestige', data),
-        levelBar: prestige.progressToNextLevel / prestige.nextLevelAt,
-      };
-    }
-  }
-  return {
-    levelBar: 0,
-    xpTillMote: undefined,
-  };
-}
diff --git a/src/app/inventory/Stores.scss b/src/app/inventory/Stores.scss
index 68c095de5..a1ee290ef 100644
--- a/src/app/inventory/Stores.scss
+++ b/src/app/inventory/Stores.scss
@@ -174,15 +174,21 @@
       margin: 0;
       width: 100%;
       padding: 8px 0 4px 0;
+      .character.vault {
+        width: 100%;
+      }
     }
     > div {
-      max-width: 250px;
+      max-width: calc(100% - 179px);
       margin: 0 auto;
       overflow: visible !important;
       .frame {
         overflow: visible !important;
         touch-action: none;
       }
+      .character {
+        margin: 0 5px;
+      }
     }
   }
 }
diff --git a/src/app/inventory/Stores.tsx b/src/app/inventory/Stores.tsx
index 1906193aa..5f62eeeeb 100644
--- a/src/app/inventory/Stores.tsx
+++ b/src/app/inventory/Stores.tsx
@@ -3,7 +3,7 @@ import { DimStore, DimVault } from './store-types';
 import { InventoryBuckets } from './inventory-buckets';
 import { t } from 'app/i18next-t';
 import './Stores.scss';
-import StoreHeading from './StoreHeading';
+import StoreHeading from '../character-tile/StoreHeading';
 import { RootState } from '../store/reducers';
 import { connect } from 'react-redux';
 import { Frame, Track, View, ViewPager } from 'react-view-pager';
diff --git a/src/app/item-popup/ItemDescription.m.scss b/src/app/item-popup/ItemDescription.m.scss
index 567682b7e..075721c6d 100644
--- a/src/app/item-popup/ItemDescription.m.scss
+++ b/src/app/item-popup/ItemDescription.m.scss
@@ -35,12 +35,40 @@
   overflow: auto;
 }
 
-.wishListLabel {
-  font-weight: bold;
-}
-.wishListTextContent {
-  font-style: italic;
+.wishListNotes {
+  composes: description;
+  position: relative;
+  max-height: 46px;
+  overflow: hidden;
+  &:hover {
+    cursor: pointer;
+  }
+  &::after {
+    content: '';
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    box-shadow: inset 0 -31px 20px -20px black;
+  }
+  &:focus {
+    max-height: fit-content;
+    &:hover {
+      cursor: default;
+    }
+    &::after {
+      display: none;
+    }
+  }
+  .wishListLabel {
+    font-weight: bold;
+  }
+  .wishListTextContent {
+    font-style: italic;
+  }
 }
+
 .loreLink {
   white-space: nowrap;
   img {
diff --git a/src/app/item-popup/ItemDescription.m.scss.d.ts b/src/app/item-popup/ItemDescription.m.scss.d.ts
index 0bedb6d0a..562f03e7a 100644
--- a/src/app/item-popup/ItemDescription.m.scss.d.ts
+++ b/src/app/item-popup/ItemDescription.m.scss.d.ts
@@ -9,6 +9,7 @@ interface CssExports {
   'noNotesYet': string;
   'officialDescription': string;
   'wishListLabel': string;
+  'wishListNotes': string;
   'wishListTextContent': string;
 }
 export const cssExports: CssExports;
diff --git a/src/app/item-popup/ItemDescription.tsx b/src/app/item-popup/ItemDescription.tsx
index a3fc693ac..21b070694 100644
--- a/src/app/item-popup/ItemDescription.tsx
+++ b/src/app/item-popup/ItemDescription.tsx
@@ -9,7 +9,6 @@ import { connect } from 'react-redux';
 import { RootState } from 'app/store/reducers';
 import { inventoryWishListsSelector } from 'app/wishlists/reducer';
 import { InventoryWishListRoll } from 'app/wishlists/wishlists';
-import { ExpandableTextBlock } from 'app/dim-ui/ExpandableTextBlock';
 
 interface ProvidedProps {
   item: DimItem;
@@ -60,12 +59,12 @@ function ItemDescription({ item, inventoryWishListRoll }: Props) {
           <div className={styles.officialDescription}>{item.displaySource}</div>
         )}
       {inventoryWishListRoll?.notes && inventoryWishListRoll.notes.length > 0 && (
-        <ExpandableTextBlock linesWhenClosed={3} className={styles.description}>
+        <div tabIndex={-1} className={styles.wishListNotes}>
           <span className={styles.wishListLabel}>
             {t('WishListRoll.WishListNotes', { notes: '' })}
           </span>
           <span className={styles.wishListTextContent}>{inventoryWishListRoll.notes}</span>
-        </ExpandableTextBlock>
+        </div>
       )}
       <NotesArea item={item} />
     </>
diff --git a/src/app/item-popup/SocketDetails.tsx b/src/app/item-popup/SocketDetails.tsx
index 4a17b9b62..5174e7e8b 100644
--- a/src/app/item-popup/SocketDetails.tsx
+++ b/src/app/item-popup/SocketDetails.tsx
@@ -28,7 +28,6 @@ import { getModCostInfo } from 'app/collections/Mod';
 interface ProvidedProps {
   item: D2Item;
   socket: DimSocket;
-  initialSelectedPlug?: DestinyInventoryItemDefinition;
   onClose(): void;
 }
 
@@ -110,18 +109,9 @@ export function plugIsInsertable(plug: DestinyItemPlug | DestinyItemPlugBase) {
   return plug.canInsert || plug.insertFailIndexes.length;
 }
 
-function SocketDetails({
-  defs,
-  item,
-  socket,
-  initialSelectedPlug,
-  unlockedPlugs,
-  inventoryPlugs,
-  onClose,
-}: Props) {
-  const initialPlug = initialSelectedPlug || socket.plug?.plugItem;
+function SocketDetails({ defs, item, socket, unlockedPlugs, inventoryPlugs, onClose }: Props) {
   const [selectedPlug, setSelectedPlug] = useState<DestinyInventoryItemDefinition | null>(
-    initialPlug || null
+    socket.plug?.plugItem || null
   );
 
   const socketType = defs.SocketType.get(socket.socketDefinition.socketTypeHash);
@@ -184,9 +174,9 @@ function SocketDetails({
       )
     );
 
-  if (initialPlug) {
-    mods = mods.filter((m) => m.hash !== initialPlug.hash);
-    mods.unshift(initialPlug);
+  if (socket.plug?.plugItem) {
+    mods = mods.filter((m) => m.hash !== socket.plug!.plugItem.hash);
+    mods.unshift(socket.plug.plugItem);
   }
 
   const requiresEnergy = mods.some((i) => i.plug?.energyCost?.energyCost);
diff --git a/src/app/item-popup/SocketDetailsSelectedPlug.m.scss b/src/app/item-popup/SocketDetailsSelectedPlug.m.scss
index 912eab3f4..eae8212af 100644
--- a/src/app/item-popup/SocketDetailsSelectedPlug.m.scss
+++ b/src/app/item-popup/SocketDetailsSelectedPlug.m.scss
@@ -41,7 +41,6 @@
 .itemStats {
   grid-area: stat;
   margin: 0;
-  height: min-content;
 }
 
 .material {
diff --git a/src/app/loadout-builder/generated-sets/GeneratedSetItem.tsx b/src/app/loadout-builder/generated-sets/GeneratedSetItem.tsx
index 4b3add958..d093aa0d9 100644
--- a/src/app/loadout-builder/generated-sets/GeneratedSetItem.tsx
+++ b/src/app/loadout-builder/generated-sets/GeneratedSetItem.tsx
@@ -10,11 +10,13 @@ import { showItemPicker } from 'app/item-picker/item-picker';
 import { t } from 'app/i18next-t';
 import { lockedItemsEqual } from '../utils';
 import { generateMixesFromPerks } from '../utils';
+import { SocketDetailsMod } from 'app/item-popup/SocketDetails';
 import { D2ManifestDefinitions } from 'app/destiny2/d2-definitions';
+import './GeneratedSetItemLockedMods.scss';
 import { DestinyInventoryItemDefinition } from 'bungie-api-ts/destiny2';
+import clsx from 'clsx';
 import { matchLockedItem } from '../preProcessFilter';
 import { LoadoutBuilderAction } from '../loadoutBuilderReducer';
-import GeneratedSetSockets from './GeneratedSetSockets';
 
 /**
  * An individual item in a generated set. Includes a perk display and a button for selecting
@@ -36,7 +38,7 @@ export default function GeneratedSetItem({
   statValues: number[];
   itemOptions: DimItem[];
   statOrder: StatTypes[];
-  lockedMods: LockedArmor2Mod[];
+  lockedMods?: LockedArmor2Mod[];
   lbDispatch: Dispatch<LoadoutBuilderAction>;
 }) {
   const altPerks = useMemo(() => generateMixesFromPerks(item, statValues, statOrder), [
@@ -129,7 +131,25 @@ export default function GeneratedSetItem({
       )}
       {$featureFlags.armor2ModPicker && (
         <div className={styles.lockedSockets}>
-          <GeneratedSetSockets item={item} lockedMods={lockedMods} defs={defs} />
+          {Boolean(lockedMods?.length) && (
+            <div className={clsx('lockedItems', styles.lockedSocketsRow)}>
+              {lockedMods?.map((mod) => (
+                <SocketDetailsMod key={mod.key} itemDef={mod.mod} defs={defs} />
+              ))}
+            </div>
+          )}
+          {Boolean(lockedOldMods.length) && (
+            <div className={clsx('lockedItems', styles.lockedSocketsRow)}>
+              {lockedOldMods?.map((def) => (
+                <SocketDetailsMod key={def.hash} itemDef={def} defs={defs} />
+              ))}
+            </div>
+          )}
+          <div className={clsx('lockedItems', 'lockedPerks', styles.lockedSocketsRow)}>
+            {lockedPerks?.map((def) => (
+              <SocketDetailsMod key={def.hash} itemDef={def} defs={defs} />
+            ))}
+          </div>
         </div>
       )}
     </div>
diff --git a/src/app/loadout-builder/generated-sets/GeneratedSetItemLockedMods.scss b/src/app/loadout-builder/generated-sets/GeneratedSetItemLockedMods.scss
new file mode 100644
index 000000000..51f927c92
--- /dev/null
+++ b/src/app/loadout-builder/generated-sets/GeneratedSetItemLockedMods.scss
@@ -0,0 +1,44 @@
+@import '../../variables.scss';
+
+.lockedItems {
+  display: flex;
+  flex-direction: row;
+
+  .item {
+    margin-right: 4px;
+    display: flex;
+    flex-direction: column;
+
+    width: calc(0.64 * var(--item-size));
+    height: calc(0.64 * var(--item-size));
+
+    &:last-child {
+      margin-right: 0;
+    }
+
+    img {
+      -webkit-touch-callout: none;
+      width: calc(0.64 * var(--item-size));
+      height: calc(0.64 * var(--item-size));
+      display: block;
+    }
+
+    .energyCostOverlay {
+      height: calc(0.64 * var(--item-size) - #{2 * $item-border-width});
+      width: calc(0.64 * var(--item-size) - #{2 * $item-border-width});
+      background-size: calc(0.64 * var(--item-size));
+    }
+
+    .energyCost {
+      top: calc(#{$item-border-width} + ((0.64 * var(--item-size)) / 13));
+      right: calc(#{$item-border-width} + ((0.64 * var(--item-size)) / 10.5));
+      font-size: calc((0.64 * var(--item-size)) / 5.5);
+    }
+  }
+}
+
+.lockedPerks {
+  img {
+    border: none;
+  }
+}
diff --git a/src/app/loadout-builder/generated-sets/GeneratedSetMod.m.scss b/src/app/loadout-builder/generated-sets/GeneratedSetMod.m.scss
deleted file mode 100644
index 8bb390efa..000000000
--- a/src/app/loadout-builder/generated-sets/GeneratedSetMod.m.scss
+++ /dev/null
@@ -1,40 +0,0 @@
-@import '../../variables.scss';
-
-.item {
-  cursor: pointer;
-  margin: 0 4px 4px 0;
-  display: flex;
-  flex-direction: column;
-
-  width: calc(0.59 * var(--item-size));
-  height: calc(0.59 * var(--item-size));
-
-  &:last-child {
-    margin-right: 0;
-  }
-
-  img {
-    -webkit-touch-callout: none;
-    width: calc(0.59 * var(--item-size));
-    height: calc(0.59 * var(--item-size));
-    display: block;
-  }
-}
-
-.energyCostOverlay {
-  height: calc(0.59 * var(--item-size) - #{2 * $item-border-width});
-  width: calc(0.59 * var(--item-size) - #{2 * $item-border-width});
-  background-size: calc(0.59 * var(--item-size));
-}
-
-.energyCost {
-  top: calc(#{$item-border-width} + ((0.59 * var(--item-size)) / 13));
-  right: calc(#{$item-border-width} + ((0.59 * var(--item-size)) / 10.5));
-  font-size: calc((0.59 * var(--item-size)) / 5.5);
-}
-
-.perk {
-  img {
-    border: none;
-  }
-}
diff --git a/src/app/loadout-builder/generated-sets/GeneratedSetMod.m.scss.d.ts b/src/app/loadout-builder/generated-sets/GeneratedSetMod.m.scss.d.ts
deleted file mode 100644
index fc5792523..000000000
--- a/src/app/loadout-builder/generated-sets/GeneratedSetMod.m.scss.d.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-// This file is automatically generated.
-// Please do not change this file!
-interface CssExports {
-  'energyCost': string;
-  'energyCostOverlay': string;
-  'item': string;
-  'perk': string;
-}
-export const cssExports: CssExports;
-export default cssExports;
diff --git a/src/app/loadout-builder/generated-sets/GeneratedSetMod.tsx b/src/app/loadout-builder/generated-sets/GeneratedSetMod.tsx
deleted file mode 100644
index f3a4ea34d..000000000
--- a/src/app/loadout-builder/generated-sets/GeneratedSetMod.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import React from 'react';
-import { D2ManifestDefinitions } from 'app/destiny2/d2-definitions';
-import { getModCostInfo } from 'app/collections/Mod';
-import BungieImage, { bungieBackgroundStyle } from 'app/dim-ui/BungieImage';
-import { PlugCategoryHashes } from 'data/d2/generated-enums';
-import clsx from 'clsx';
-import { DestinyInventoryItemDefinition } from 'bungie-api-ts/destiny2';
-import styles from './GeneratedSetMod.m.scss';
-
-interface Props {
-  plugDef: DestinyInventoryItemDefinition;
-  defs: D2ManifestDefinitions;
-  gridColumn: number;
-  onClick(): void;
-}
-
-function GeneratedSetMod({ plugDef, defs, gridColumn, onClick }: Props) {
-  const { energyCost, energyCostElementOverlay } = getModCostInfo(plugDef, defs);
-
-  const classes = {
-    [styles.item]: true,
-    [styles.perk]: plugDef.plug.plugCategoryHash === PlugCategoryHashes.Intrinsics,
-  };
-
-  return (
-    <div
-      className={clsx(classes)}
-      style={{ gridColumn }}
-      title={plugDef.displayProperties.name}
-      tabIndex={0}
-      onClick={onClick}
-    >
-      <BungieImage className="item-img" src={plugDef.displayProperties.icon} />
-      {energyCostElementOverlay && (
-        <>
-          <div
-            style={bungieBackgroundStyle(energyCostElementOverlay)}
-            className={styles.energyCostOverlay}
-          />
-          <div className={styles.energyCost}>{energyCost}</div>
-        </>
-      )}
-    </div>
-  );
-}
-
-export default GeneratedSetMod;
diff --git a/src/app/loadout-builder/generated-sets/GeneratedSetSockets.m.scss b/src/app/loadout-builder/generated-sets/GeneratedSetSockets.m.scss
deleted file mode 100644
index 13ce9abda..000000000
--- a/src/app/loadout-builder/generated-sets/GeneratedSetSockets.m.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-.lockedItems {
-  display: grid;
-}
diff --git a/src/app/loadout-builder/generated-sets/GeneratedSetSockets.m.scss.d.ts b/src/app/loadout-builder/generated-sets/GeneratedSetSockets.m.scss.d.ts
deleted file mode 100644
index 25cf652da..000000000
--- a/src/app/loadout-builder/generated-sets/GeneratedSetSockets.m.scss.d.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-// This file is automatically generated.
-// Please do not change this file!
-interface CssExports {
-  'lockedItems': string;
-}
-export const cssExports: CssExports;
-export default cssExports;
diff --git a/src/app/loadout-builder/generated-sets/GeneratedSetSockets.tsx b/src/app/loadout-builder/generated-sets/GeneratedSetSockets.tsx
deleted file mode 100644
index 1a48d8611..000000000
--- a/src/app/loadout-builder/generated-sets/GeneratedSetSockets.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import React, { useState } from 'react';
-import { DimItem, DimSocket } from 'app/inventory/item-types';
-import { LockedArmor2Mod } from '../types';
-import { D2ManifestDefinitions } from 'app/destiny2/d2-definitions';
-import GeneratedSetMod from './GeneratedSetMod';
-import { PlugCategoryHashes } from 'data/d2/generated-enums';
-import { DestinyInventoryItemDefinition } from 'bungie-api-ts/destiny2';
-import SocketDetails from '../../item-popup/SocketDetails';
-import ReactDOM from 'react-dom';
-import styles from './GeneratedSetSockets.m.scss';
-
-const undesireablePlugs = [
-  PlugCategoryHashes.ArmorSkinsEmpty,
-  PlugCategoryHashes.Shader,
-  PlugCategoryHashes.V460PlugsArmorMasterworksStatResistance1,
-  PlugCategoryHashes.V460PlugsArmorMasterworksStatResistance2,
-  PlugCategoryHashes.V460PlugsArmorMasterworksStatResistance3,
-  PlugCategoryHashes.V460PlugsArmorMasterworksStatResistance4,
-];
-
-interface SocketAndPlug {
-  plug: DestinyInventoryItemDefinition;
-  socket: DimSocket;
-}
-
-interface Props {
-  item: DimItem;
-  lockedMods: LockedArmor2Mod[];
-  defs: D2ManifestDefinitions;
-}
-
-function GeneratedSetSockets({ item, lockedMods, defs }: Props) {
-  const [socketInfo, setSocketInfo] = useState<SocketAndPlug | null>(null);
-  if (!item.isDestiny2()) {
-    return null;
-  }
-
-  const modsAndPerks: SocketAndPlug[] = [];
-  const modsToUse = [...lockedMods];
-
-  for (const socket of item.sockets?.sockets || []) {
-    const socketType = defs.SocketType.get(socket.socketDefinition.socketTypeHash);
-    let toSave: DestinyInventoryItemDefinition | undefined;
-
-    for (let modIndex = 0; modIndex < modsToUse.length; modIndex++) {
-      const mod = modsToUse[modIndex].mod;
-      if (
-        socketType.plugWhitelist.some((plug) => plug.categoryHash === mod.plug.plugCategoryHash)
-      ) {
-        toSave = mod;
-        modsToUse.splice(modIndex, 1);
-      }
-    }
-
-    if (!toSave) {
-      toSave = defs.InventoryItem.get(socket.socketDefinition.singleInitialItemHash);
-    }
-
-    if (toSave && !undesireablePlugs.includes(toSave.plug.plugCategoryHash)) {
-      modsAndPerks.push({ plug: toSave, socket });
-    }
-  }
-
-  return (
-    <>
-      <div className={styles.lockedItems}>
-        {modsAndPerks.map((socketAndPlug, index) => (
-          <GeneratedSetMod
-            key={index}
-            gridColumn={(index % 2) + 1}
-            plugDef={socketAndPlug.plug}
-            defs={defs}
-            onClick={() => setSocketInfo(socketAndPlug)}
-          />
-        ))}
-      </div>
-      {socketInfo &&
-        ReactDOM.createPortal(
-          <SocketDetails
-            key={socketInfo.socket.socketIndex}
-            item={item}
-            socket={socketInfo.socket}
-            initialSelectedPlug={socketInfo.plug}
-            onClose={() => setSocketInfo(null)}
-          />,
-          document.body
-        )}
-    </>
-  );
-}
-
-export default GeneratedSetSockets;
diff --git a/src/app/loadout-builder/mod-utils.ts b/src/app/loadout-builder/mod-utils.ts
index afc6040f9..1cbbf4586 100644
--- a/src/app/loadout-builder/mod-utils.ts
+++ b/src/app/loadout-builder/mod-utils.ts
@@ -1,6 +1,6 @@
 import { DimItem, D2Item } from '../inventory/item-types';
 import _ from 'lodash';
-import { LockedArmor2ModMap, LockedArmor2Mod } from './types';
+import { LockedArmor2ModMap, LockedArmor2Mod, ModPickerCategories } from './types';
 import { DestinyEnergyType } from 'bungie-api-ts/destiny2';
 import {
   sortProcessModsOrProcessItems,
@@ -113,5 +113,18 @@ export function assignModsToArmorSet(
   );
 
   const modsByHash = _.keyBy(Object.values(lockedArmor2Mods).flat(), (mod) => mod.mod.hash);
-  return _.mapValues(assignments, (modHashes) => modHashes.map((modHash) => modsByHash[modHash]));
+  return _.mapValues(assignments, (modHashes) =>
+    modHashes
+      .map((modHash) => modsByHash[modHash])
+      .sort((a) => {
+        // Sort the mods so that they appear in the order general, slot specific, seasonal (mimic the game).
+        if (a.category === ModPickerCategories.general) {
+          return -1;
+        } else if (a.category === ModPickerCategories.seasonal) {
+          return 1;
+        } else {
+          return 0;
+        }
+      })
+  );
 }
diff --git a/src/app/loadout/loadout-popup.scss b/src/app/loadout/loadout-popup.scss
index 058d52646..968ab4d99 100644
--- a/src/app/loadout/loadout-popup.scss
+++ b/src/app/loadout/loadout-popup.scss
@@ -107,7 +107,6 @@
     var(--character-column-width) -
     var(--item-margin)
   );
-  max-width: 230px;
   box-sizing: border-box;
   max-height: 60vh;
   overflow: auto;
@@ -132,6 +131,10 @@
     max-height: calc(100vh - #{120px + $header-height});
     max-height: calc(100vh - #{120px + $header-height} - constant(safe-area-inset-top));
     max-height: calc(100vh - #{120px + $header-height} - env(safe-area-inset-top));
-    left: calc((100% - 230px) / 2);
+
+    max-width: calc(100% - 179px);
+    left: 50%;
+    transform: translateX(-50%);
+    padding: 0 5px;
   }
 }
diff --git a/src/app/progress/progress.scss b/src/app/progress/progress.scss
index c95f30e47..9dfe2d612 100644
--- a/src/app/progress/progress.scss
+++ b/src/app/progress/progress.scss
@@ -17,16 +17,6 @@
       display: flex;
       justify-content: center;
     }
-
-    .character-box {
-      flex: 1;
-      cursor: default;
-      border-bottom: none;
-    }
-
-    &:last-child .character-box {
-      margin-right: 0;
-    }
   }
 
   .small-icon {
diff --git a/src/app/search/d1-known-values.ts b/src/app/search/d1-known-values.ts
index 8656f00c5..7ea5d29a0 100644
--- a/src/app/search/d1-known-values.ts
+++ b/src/app/search/d1-known-values.ts
@@ -17,6 +17,11 @@ export const enum D1_StatHashes {
   Attack = 368428387,
 }
 
+/** hashes representing D1 Progressions */
+export const enum D1ProgressionHashes {
+  Prestige = 2030054750,
+}
+
 //
 // ITEMS / ITEMCATERGORY KNOWN VALUES
 //
diff --git a/src/app/search/search-filter.ts b/src/app/search/search-filter.ts
index 0e4e57330..fa072cea1 100644
--- a/src/app/search/search-filter.ts
+++ b/src/app/search/search-filter.ts
@@ -118,15 +118,9 @@ const replaceSeasonTagWithNumber = (s: string) => s.replace(/[a-z]+$/i, (tag) =>
 
 /** outputs a string combination of the identifying features of an item, or the hash if classified */
 export const makeDupeID = (item: DimItem) =>
-  (item.classified && `${item.hash}`) ||
+  (item.classified && String(item.hash)) ||
   `${item.name}${item.classType}${item.tier}${item.itemCategoryHashes.join('.')}`;
 
-export const makeSeasonalDupeID = (item: DimItem) =>
-  (item.classified && `${item.hash}`) ||
-  `${item.name}${item.classType}${item.tier}${
-    item.isDestiny2() ? `${item.collectibleHash}${item.powerCap}` : ''
-  }${item.itemCategoryHashes.join('.')}`;
-
 //
 // Selectors
 //
@@ -242,7 +236,6 @@ export function buildSearchConfig(destinyVersion: DestinyVersion): SearchConfig
     ],
     classType: ['titan', 'hunter', 'warlock'],
     dupe: ['dupe', 'duplicate'],
-    seasonaldupe: ['seasonaldupe', 'seasonalduplicate'],
     dupelower: ['dupelower'],
     locked: ['locked'],
     unlocked: ['unlocked'],
@@ -505,7 +498,6 @@ function searchFilters(
 ): SearchFilters {
   // TODO: do these with memoize-one
   let _duplicates: { [dupeID: string]: DimItem[] } | null = null; // Holds a map from item hash to count of occurrances of that hash
-  let _duplicates_seasonal: { [dupeID: string]: DimItem[] } | null = null; // Holds a map from item hash to count of occurrances of that hash
   const _maxPowerLoadoutItems: string[] = [];
   const _maxStatLoadoutItems: { [key: string]: string[] } = {};
   let _maxStatValues: {
@@ -532,19 +524,13 @@ function searchFilters(
 
     if (_duplicates === null) {
       _duplicates = {};
-      _duplicates_seasonal = {};
       for (const store of stores) {
         for (const i of store.items) {
           const dupeID = makeDupeID(i);
-          const seasonalDupeID = makeSeasonalDupeID(i);
           if (!_duplicates[dupeID]) {
             _duplicates[dupeID] = [];
           }
-          if (!_duplicates_seasonal[seasonalDupeID]) {
-            _duplicates_seasonal[seasonalDupeID] = [];
-          }
           _duplicates[dupeID].push(i);
-          _duplicates_seasonal[seasonalDupeID].push(i);
         }
       }
 
@@ -563,22 +549,6 @@ function searchFilters(
           }
         }
       });
-
-      _.forIn(_duplicates_seasonal, (dupes: DimItem[]) => {
-        if (dupes.length > 1) {
-          dupes.sort(dupeComparator);
-          const bestDupe = dupes[0];
-          for (const dupe of dupes) {
-            if (
-              dupe.bucket &&
-              (dupe.bucket.sort === 'Weapons' || dupe.bucket.sort === 'Armor') &&
-              !dupe.notransfer
-            ) {
-              _lowerDupes[dupe.id] = dupe !== bestDupe;
-            }
-          }
-        }
-      });
     }
   }
 
@@ -909,19 +879,6 @@ function searchFilters(
           _duplicates[dupeId].length > 1
         );
       },
-      seasonaldupe(item: DimItem) {
-        initDupes();
-        const dupeId = makeSeasonalDupeID(item);
-        // We filter out the InventoryItem "Default Shader" because everybody has one per character
-        return (
-          _duplicates_seasonal &&
-          !item.itemCategoryHashes.includes(ItemCategoryHashes.ClanBanner) &&
-          item.hash !== DEFAULT_SHADER &&
-          item.bucket.hash !== SEASONAL_ARTIFACT_BUCKET &&
-          _duplicates_seasonal[dupeId] &&
-          _duplicates_seasonal[dupeId].length > 1
-        );
-      },
       count(item: DimItem, filterValue: string) {
         initDupes();
         const dupeId = makeDupeID(item);
diff --git a/src/app/shell/icons/Library.ts b/src/app/shell/icons/Library.ts
index ae7a79402..13b514b3c 100644
--- a/src/app/shell/icons/Library.ts
+++ b/src/app/shell/icons/Library.ts
@@ -65,6 +65,7 @@ const faStickyNote = 'fas fa-sticky-note';
 const faMinusSquare = 'fas fa-minus-square';
 const faRandom = 'fas fa-random';
 const faEquals = 'fas fa-equals';
+const faEllipsisV = 'fas fa-ellipsis-v';
 const faArrowCircleDown = 'fas fa-arrow-circle-down';
 const faExclamationCircle = 'fas fa-exclamation-circle';
 const faMinus = 'fas fa-minus';
@@ -116,6 +117,7 @@ export {
   faCog as settingsIcon,
   faCopy as copyIcon,
   faDownload as downloadIcon,
+  faEllipsisV,
   faEnvelope as sendIcon,
   faEraser as clearIcon,
   faLevelUpAlt as levellingIcon,
diff --git a/src/data/d2/objective-richTexts.json b/src/data/d2/objective-richTexts.json
new file mode 100644
index 000000000..51878e267
--- /dev/null
+++ b/src/data/d2/objective-richTexts.json
@@ -0,0 +1,42 @@
+{
+  "[Arc]": ["Objective", 232832120],
+  "[Auto Rifle]": ["Objective", 60057218],
+  "[Bow]": ["Objective", 1213975022],
+  "[Disruption]": ["SandboxPerk", 740674429],
+  "[Fusion Rifle]": ["Objective", 579143207],
+  "[Grenade Launcher]": ["Objective", 5924951],
+  "[Grenade]": ["Objective", 45245118],
+  "[Hand Cannon]": ["Objective", 53304862],
+  "[Headshot]": ["Objective", 194527784],
+  "[Hunter: Arcstrider Super]": ["Objective", 269520342],
+  "[Hunter: Gunslinger Super]": ["Objective", 1633845729],
+  "[Hunter: Nightstalker Super]": ["Objective", 2904388000],
+  "[Insert Medal Here]": ["Objective", 2254471463],
+  "[Kill]": ["Objective", 114960131],
+  "[Large Blocker]": ["Objective", 2031240843],
+  "[Linear Fusion Rifle]": ["Objective", 1612898592],
+  "[Machine Gun]": ["Objective", 799860822],
+  "[Medium Blocker]": ["Objective", 3792840449],
+  "[Melee]": ["Objective", 314405660],
+  "[Pulse Rifle]": ["Objective", 420092262],
+  "[Rocket Launcher]": ["Objective", 238063032],
+  "[SMG]": ["Objective", 218704521],
+  "[Scout Rifle]": ["Objective", 236969823],
+  "[Shield-Piercing]": ["SandboxPerk", 1427016431],
+  "[Shotgun]": ["Objective", 258599004],
+  "[Sidearm]": ["Objective", 299893109],
+  "[Small Blocker]": ["Objective", 276438067],
+  "[Sniper Rifle]": ["Objective", 188176814],
+  "[Solar]": ["Objective", 244790908],
+  "[Stagger]": ["SandboxPerk", 72139184],
+  "[Sword]": ["Objective", 989767424],
+  "[Titan: Sentinel Super]": ["Objective", 1043633269],
+  "[Titan: Striker Super]": ["Objective", 2975056954],
+  "[Titan: Sunbreaker Super]": ["Objective", 2905697046],
+  "[Trace Rifle]": ["Objective", 1375652735],
+  "[Void]": ["Objective", 54906502],
+  "[Warlock: Dawnblade Super]": ["Objective", 1363382181],
+  "[Warlock: Voidwalker Super]": ["Objective", 1733112051],
+  "": ["Objective", 4231452845],
+  "": ["Objective", 2258101260]
+}
diff --git a/src/locale/de/dim.json b/src/locale/de/dim.json
index 63a227b77..d37e78ec4 100644
--- a/src/locale/de/dim.json
+++ b/src/locale/de/dim.json
@@ -379,7 +379,7 @@
     "FilterMatchCount": "1 Item",
     "FilterMatchCount_plural": "{{count}} Items",
     "Filters": "Filter",
-    "InstallDIM": "Install as an App",
+    "InstallDIM": "Installiere DIM",
     "Inventory": "Inventar",
     "IosPwaPrompt": "Klicke im mobilen Safari auf das Teilen-Symbol (mittlere Taste unten) und wähle \"Zu Home Screen hinzufügen\".",
     "Menu": "Menü",
@@ -696,7 +696,6 @@
     "PowerCap": "",
     "PullItem": "Ziehe von {{bucket}} nach {{store}}",
     "ReadLore": "Lese Überlieferungen und Hintergründe auf Ishtar Collective",
-    "ReadLoreLink": "Lese Lore",
     "ReviewsTab": "Berichte",
     "Rewards": "Belohnungen:",
     "Split": "Teilen",
@@ -811,7 +810,6 @@
     "ColumnSize": "{{num}} Items",
     "ColumnSizeAuto": "Automatisch",
     "CsvImport": "Tags/Notizen von CSV importieren",
-    "CustomStatDesc": "Wähle Rüstungsstatistiken, um eine benutzerdefinierte Gesamtstatistik zu erstellen, die im Popup, Organizer und Vergleich angezeigt wird. Suche mit stat:custom:>=30.",
     "Data": "Excel Tabelle",
     "DefaultItemSizeNote": "Eine Itemgröße von 50px wird am schärfsten aussehen, ohne das Itembild oder den Text zu verwischen.",
     "DontForgetDupes": "Vergiss nicht, dass du nach is:dupe suchen kannst, um schnell doppelte Items zu finden und du kannst das Vergleichstool nutzen um verwandte Items zu evaluieren.",
@@ -848,8 +846,6 @@
     "SortName": "Name"
   },
   "Stats": {
-    "Custom": "Benutzerdefinierte Summe",
-    "CustomDesc": "Benutzerdefinierte Gesamtanzahl in den Einstellungen. Dies fügt nur Basisstatistiken hinzu, ignoriert Mods oder Meisterwerk.",
     "Discipline": "Disziplin",
     "Intellect": "Intellekt",
     "MaxGearPower": "Maximale Power von verfügbarer Ausrüstung",
diff --git a/src/locale/es-mx/dim.json b/src/locale/es-mx/dim.json
index 4b36b31a4..9e4f0d344 100644
--- a/src/locale/es-mx/dim.json
+++ b/src/locale/es-mx/dim.json
@@ -379,7 +379,7 @@
     "FilterMatchCount": "1 objeto",
     "FilterMatchCount_plural": "{{count}} objetos",
     "Filters": "Filtros",
-    "InstallDIM": "Instalar como aplicación",
+    "InstallDIM": "Instalar DIM",
     "Inventory": "Inventario",
     "IosPwaPrompt": "En Safari Móvil, haz click en el botón de compartir (botón central en la parte inferior) y selecciona \"Añadir a Pantalla de Inicio\".",
     "Menu": "Menú",
@@ -696,7 +696,6 @@
     "PowerCap": "",
     "PullItem": "Mover de {{bucket}} a {{store}}",
     "ReadLore": "Lee historia en Ishtar Collective",
-    "ReadLoreLink": "Leer lore",
     "ReviewsTab": "Reseñas",
     "Rewards": "Recompensas:",
     "Split": "Partir",
@@ -811,7 +810,6 @@
     "ColumnSize": "{{num}} objetos",
     "ColumnSizeAuto": "Automático",
     "CsvImport": "Importar etiquetas/notas del CSV",
-    "CustomStatDesc": "Elige estadísticas de armadura para crear una estadística total personalizada que se mostrará en la ventana del objeto, Organizador y Comparar. Busca con stat:custom:>30.",
     "Data": "Hojas de cálculo",
     "DefaultItemSizeNote": "Un objeto de 50px de tamaño será más nítido, sin hacer borrosa la imagen o el texto del objeto.",
     "DontForgetDupes": "No te olvides de que puedes buscar is:dupe para buscar rápidamente objetos duplicados, y puedes usar la herramienta de comparación para evaluar objetos relacionados.",
@@ -848,8 +846,6 @@
     "SortName": "Nombre"
   },
   "Stats": {
-    "Custom": "Total Personalizado",
-    "CustomDesc": "Total personalizado configurado en ajustes. Esto sólo añade estadísticas base, ignorando modificadores u Obras Maestras.",
     "Discipline": "Disciplina",
     "Intellect": "Intelecto",
     "MaxGearPower": "Poder Máximo del equipamiento equipable",
diff --git a/src/locale/es/dim.json b/src/locale/es/dim.json
index 8e99deb8c..66744f766 100644
--- a/src/locale/es/dim.json
+++ b/src/locale/es/dim.json
@@ -379,7 +379,7 @@
     "FilterMatchCount": "1 objeto",
     "FilterMatchCount_plural": "{{count}} objetos",
     "Filters": "Filtros",
-    "InstallDIM": "Instalar como App",
+    "InstallDIM": "Instalar DIM",
     "Inventory": "Inventario",
     "IosPwaPrompt": "En Safari Móvil, haga click en el botón de compartir (botón central en la parte inferior) y selecciona \"Añadir a Pantalla de Inicio\".",
     "Menu": "Menú",
@@ -696,7 +696,6 @@
     "PowerCap": "",
     "PullItem": "Traer de {{bucket}} a {{store}}",
     "ReadLore": "Lee historia en Ishtar Collective",
-    "ReadLoreLink": "Leer historia",
     "ReviewsTab": "Calificaciones",
     "Rewards": "Recompensas:",
     "Split": "Partir",
@@ -811,7 +810,6 @@
     "ColumnSize": "{{num}} objetos",
     "ColumnSizeAuto": "Automático",
     "CsvImport": "Importar etiquetas/notas del CSV",
-    "CustomStatDesc": "Elige estadísticas de armadura para crear una estadística total personalizada que se mostrará en la ventana del objeto, Organizador y Comparar. Busca con stat:custom:>30.",
     "Data": "Hojas de cálculo",
     "DefaultItemSizeNote": "Un objeto de 50px de tamaño parecerá lo más nítido, sin desdibujar la imagen o el texto del objeto.",
     "DontForgetDupes": "No te olvides de que puedes buscar is:dupe para buscar rápidamente objetos duplicados, y puedes usar la herramienta de comparación para evaluar objetos relacionados.",
@@ -848,8 +846,6 @@
     "SortName": "Nombre"
   },
   "Stats": {
-    "Custom": "Total Personalizado",
-    "CustomDesc": "Total personalizado configurado en ajustes. Esto sólo añade estadísticas base, ignorando modificadores u Obras Maestras.",
     "Discipline": "Disciplina",
     "Intellect": "Intelecto",
     "MaxGearPower": "Poder Máximo del equipamiento equipable",
diff --git a/src/locale/fr/dim.json b/src/locale/fr/dim.json
index 971e7d4b1..a6c54503d 100644
--- a/src/locale/fr/dim.json
+++ b/src/locale/fr/dim.json
@@ -379,7 +379,7 @@
     "FilterMatchCount": "1 objet",
     "FilterMatchCount_plural": "{{count}} objets",
     "Filters": "Filtres",
-    "InstallDIM": "Install as an App",
+    "InstallDIM": "Installer DIM",
     "Inventory": "Inventaire",
     "IosPwaPrompt": "Dans Safari Mobile, cliquez sur l'icône de partage (bouton central en bas) et sélectionnez \"Ajouter à l'écran d'accueil\".",
     "Menu": "Menu",
@@ -696,7 +696,6 @@
     "PowerCap": "",
     "PullItem": "Retirez depuis {{bucket}} vers {{store}}",
     "ReadLore": "Lire le lore de Ishtar Collective",
-    "ReadLoreLink": "Read lore",
     "ReviewsTab": "Évaluations",
     "Rewards": "Récompenses:",
     "Split": "Couper",
@@ -811,7 +810,6 @@
     "ColumnSize": "{{num}} objets",
     "ColumnSizeAuto": "Auto",
     "CsvImport": "Importer des balises/notes depuis CSV",
-    "CustomStatDesc": "Choose armor stats to make a custom total stat that will show on the item popup, Organizer, and Compare. Search with stat:custom:>=30.",
     "Data": "Tableur",
     "DefaultItemSizeNote": "Une taille d'objet de 50px aura le rendu le plus net, sans flouter l'image de l'objet ou le texte.",
     "DontForgetDupes": "N'oubliez pas que vous pouvez chercher is:dupe pour trouver rapidement vos doublons, et vous pouvez utiliser l'outil de comparaison pour évaluer les objets entre eux.",
@@ -848,8 +846,6 @@
     "SortName": "Nom"
   },
   "Stats": {
-    "Custom": "Custom Total",
-    "CustomDesc": "Custom total configured in settings. This only adds up base stats, ignoring mods or masterworking.",
     "Discipline": "Discipline",
     "Intellect": "Intelligence",
     "MaxGearPower": "Puissance maximale des équipements pouvant être équipés",
diff --git a/src/locale/it/dim.json b/src/locale/it/dim.json
index f87695db0..5a4b21ec9 100644
--- a/src/locale/it/dim.json
+++ b/src/locale/it/dim.json
@@ -379,7 +379,7 @@
     "FilterMatchCount": "1 oggetto",
     "FilterMatchCount_plural": "{{count}} oggetti",
     "Filters": "Filtri",
-    "InstallDIM": "Installa come App",
+    "InstallDIM": "Installa DIM",
     "Inventory": "Inventario",
     "IosPwaPrompt": "Su Safari Mobile, clicca sull'icona \"condividi\" (il bottone centrale in basso) e seleziona \"Aggiungi a schermata Home\".",
     "Menu": "Menù",
@@ -696,7 +696,6 @@
     "PowerCap": "",
     "PullItem": "Prendi da {{bucket}} per {{store}}",
     "ReadLore": "Leggi la storia su Ishtar Collective (solo in Inglese)",
-    "ReadLoreLink": "Leggi storia",
     "ReviewsTab": "Recensioni",
     "Rewards": "Ricompense:",
     "Split": "Dividi",
@@ -811,7 +810,6 @@
     "ColumnSize": "{{num}} oggetti",
     "ColumnSizeAuto": "Automatico",
     "CsvImport": "Importa etichette/note da CSV",
-    "CustomStatDesc": "Scegli delle statistiche delle armature per raggiungere un totale statistica personalizzato che verrà mostrato nel popup dell'oggetto, Organizzatore Deposito e Confronta. Cerca con statcustom:>=30.",
     "Data": "Fogli di calcolo",
     "DefaultItemSizeNote": "Una dimensione di 50px sarà la più nitida, senza sfocare l'immagine dell'oggetto o il testo.",
     "DontForgetDupes": "Non dimenticare che puoi cercare is:dupe per trovare velocemente oggetti duplicati, e che puoi usare lo strumento di confronto per valutarli.",
@@ -848,8 +846,6 @@
     "SortName": "Nome"
   },
   "Stats": {
-    "Custom": "Totale Personalizzato",
-    "CustomDesc": "Totale personalizzato configurato nelle impostazioni. Questo aggiunge solo alle statistiche di base, ignorando le Modifiche o il Prodigio.",
     "Discipline": "Disciplina",
     "Intellect": "Intelletto",
     "MaxGearPower": "Potere Massimo degli oggetti equipaggiabili",
diff --git a/src/locale/ja/dim.json b/src/locale/ja/dim.json
index db7c22d71..34714b741 100644
--- a/src/locale/ja/dim.json
+++ b/src/locale/ja/dim.json
@@ -379,7 +379,7 @@
     "FilterMatchCount": "1つのアイテム",
     "FilterMatchCount_plural": "{{count}} アイテム",
     "Filters": "フィルター",
-    "InstallDIM": "アプリとしてインストールします。",
+    "InstallDIM": "DIMのインストール",
     "Inventory": "所持品",
     "IosPwaPrompt": "Mobile Safariで共有アイコン(下部の中央ボタン) をクリックし、「ホーム画面に追加」を選択します。",
     "Menu": "メニュー",
@@ -556,7 +556,7 @@
     "NumStatMixes": "{{count, number}} stat mix",
     "NumStatMixes_plural": "{{count, number}} stat mixes",
     "OptimizerExplanation": "パークを選択したり、統計をフィルタして並べ替えたり、項目をロックして潜在的なロードアウトを絞り込みます。",
-    "OptimizerExplanationArmour2Mods": "Armor 2.0 Modによるステータス変更は可能である場合除外されます。詳細については {{link}} をご覧ください。",
+    "OptimizerExplanationArmour2Mods": "Stat changes from Armor 2.0 Mods are excluded where possible, for more info please see {{link}}.",
     "OptimizerExplanationDesktop": "Shiftキーを押しながらパークをクリックして選択します。 Shiftキーを押しながらアイテムをクリックして除外します。アイテムをサイドバーにドラッグして、除外またはロックします。",
     "ProcessingSets": "{{character}} のセットを処理しています",
     "ResetLocked": "ロックされたアイテムをリセットする",
@@ -569,7 +569,7 @@
     "SelectPower": "最小パワー",
     "SelectPowerDescription": "ロードアウト全体の最小パワーレベルを設定します。",
     "SelectedPerkKey": "パークを選択",
-    "StatIncorrectWarning": "正確な結果を得るには、ステータスペナルティまたは変動性の属性があるパーツを外してください。\n\nステータスペナルティまたは変動性の属性があるパーツがあるため、DIMは一部の項目に対して {{stats}} の基準値を決定できません。 これは、Bungie.netから提供される情報の制限によって発生する事象です。",
+    "StatIncorrectWarning": "To get accurate results, please remove any mods with negative or conditional stat effects.\n\nDue to mods with negative stat modifiers or conditional stat effects, DIM cannot determine the base value of {{stats}} on some included items. This is a limitation of the information sent by Bungie.net.",
     "StatTierIgnoreOption": "無視",
     "StatTierIncludeOption": "含む",
     "TierNumber": "T{{tier}}",
@@ -696,7 +696,6 @@
     "PowerCap": "",
     "PullItem": "{{bucket}} から{{store}} に引き出します",
     "ReadLore": "イシュタルコレクティブで伝承を読む",
-    "ReadLoreLink": "Read lore",
     "ReviewsTab": "レビュー",
     "Rewards": "報酬:",
     "Split": "分割",
@@ -811,7 +810,6 @@
     "ColumnSize": "{{num}} 個",
     "ColumnSizeAuto": "オート",
     "CsvImport": "CSVからタグ/メモをインポートする",
-    "CustomStatDesc": "アーマーのスタッツを選択して、アイテムポップアップ、オーガナイザー、比較に表示するカスタムの合計値を作成します。stat:custom:>=30で検索します。",
     "Data": "スプレッドシート",
     "DefaultItemSizeNote": "50pxのアイテムサイズは、アイテムの画像やテキストをぼかすことなく、最もシャープに見えます。",
     "DontForgetDupes": "重複アイテムをすばやく検索するには、 「is:dupe」で検索し、比較ツールを使用して関連アイテムを評価することができます。",
@@ -848,8 +846,6 @@
     "SortName": "名前"
   },
   "Stats": {
-    "Custom": "カスタム合計",
-    "CustomDesc": "設定で変更したカスタム合計値。この数値は基本的なステータス値を加算しただけで、Modやマスターワークの値は無視されています。",
     "Discipline": "鍛錬",
     "Intellect": "知性",
     "MaxGearPower": "装備可能なギアの最大出力",
@@ -870,7 +866,7 @@
     "TierProgress": "T{{tier}} {{statName}} ({{progress}}/60 の T{{nextTier}})\n",
     "TierProgress_Max": "T{{tier}} {{statName}} ({{progress}}/300)\n",
     "Total": "合計",
-    "TotalIncorrectWarning": "ステータスペナルティまたは変動性の属性があるパーツがあるため、DIMは一部の項目に対して {{stats}} の基準値を決定できません。 これは、Bungie.netから提供される情報の制限によって発生する事象です。"
+    "TotalIncorrectWarning": "Due to mods with negative or conditional stat effects, DIM cannot determine the base value of {{stats}}. This is a limitation of the information sent by Bungie.net."
   },
   "Storage": {
     "ApiPermissionPrompt": {
diff --git a/src/locale/ko/dim.json b/src/locale/ko/dim.json
index 5fb387675..eafa93fd2 100644
--- a/src/locale/ko/dim.json
+++ b/src/locale/ko/dim.json
@@ -379,7 +379,7 @@
     "FilterMatchCount": "아이템 1개",
     "FilterMatchCount_plural": "아이템 {{count}}개",
     "Filters": "필터",
-    "InstallDIM": "앱으로 설치",
+    "InstallDIM": "DIM 설치",
     "Inventory": "소지품",
     "IosPwaPrompt": "모바일 사파리에서는 공유 아이콘(하단 중앙 버튼)을 누르고 \"홈 화면에 추가\"를 선택하세요.",
     "Menu": "메뉴",
@@ -696,7 +696,6 @@
     "PowerCap": "",
     "PullItem": "{{bucket}}에서 {{store}}(으)로 이동",
     "ReadLore": "Ishtar Collective에서 지식 읽기",
-    "ReadLoreLink": "지식 읽기",
     "ReviewsTab": "평가",
     "Rewards": "보상:",
     "Split": "분할",
@@ -811,7 +810,6 @@
     "ColumnSize": "아이템 {{num}}개",
     "ColumnSizeAuto": "자동",
     "CsvImport": "CSV에서 태그와 메모 가져오기",
-    "CustomStatDesc": "방어구 스텟을 선택하면 아이템 팝업, 정리 도구, 비교에서 표시되는 사용자 정의 총합을 만들 수 있습니다. stat:custom:>=30로 검색하세요.",
     "Data": "스프레드시트",
     "DefaultItemSizeNote": "아이템 크기를 50px로 하면 아이템 그림이나 텍스트를 흐리게 하지 않고 가장 선명하게 보이게 할 것입니다.",
     "DontForgetDupes": "중복된 아이템을 빠르게 찾기 위해 is:dupe를 검색할 수 있으며, 비교 도구를 사용하여 유사한 아이템을 비교 평가할 수 있습니다.",
@@ -848,8 +846,6 @@
     "SortName": "이름"
   },
   "Stats": {
-    "Custom": "사용자 정의 총합",
-    "CustomDesc": "사용자 정의 총합은 설정에 정의되어 있습니다. 기본 스텟만 계산되며, 개조 부품이나 걸작은 무시됩니다.",
     "Discipline": "규율",
     "Intellect": "지능",
     "MaxGearPower": "장착 가능한 최대 전투력",
diff --git a/src/locale/pl/dim.json b/src/locale/pl/dim.json
index 5a8eb6e1d..dbe0b6796 100644
--- a/src/locale/pl/dim.json
+++ b/src/locale/pl/dim.json
@@ -379,7 +379,7 @@
     "FilterMatchCount": "1 przedmiot",
     "FilterMatchCount_plural": "{{count}} przedmiotów",
     "Filters": "Filtry",
-    "InstallDIM": "Zainstaluj jako aplikację",
+    "InstallDIM": "Zainstaluj DIM",
     "Inventory": "Ekwipunek",
     "IosPwaPrompt": "W Mobilnym Safari, kliknij ikonę udostępniania (środkowy przycisk u dołu) i wybierz \"Dodaj do Ekranu Domowego\".",
     "Menu": "Menu",
@@ -696,7 +696,6 @@
     "PowerCap": "",
     "PullItem": "Wyciągnij z {{bucket}} do {{store}}",
     "ReadLore": "Czytaj lore na Ishtar Collective",
-    "ReadLoreLink": "Czytaj fabułę",
     "ReviewsTab": "Recenzje",
     "Rewards": "Nagrody:",
     "Split": "Podziel",
@@ -811,7 +810,6 @@
     "ColumnSize": "{{num}} przedmiotów",
     "ColumnSizeAuto": "Auto",
     "CsvImport": "Importuj tagi/notatki z pliku CSV",
-    "CustomStatDesc": "Wybierz statystyki pancerza, aby utworzyć niestandardową całkowitą sumę statystyk, która pokaże się w wyskakującym oknie przedmiotu, organizatorze i porównaniu. Szukaj z stat:custom:>=30.",
     "Data": "Arkusze kalkulacyjne",
     "DefaultItemSizeNote": "Rozmiar przedmiotu 50px będzie wyglądał najostrzej, bez rozmycia obrazu przedmiotu lub tekstu.",
     "DontForgetDupes": "Nie zapomnij, że możesz wyszukać is:dupe aby szybko znaleźć zduplikowane przedmioty, oraz możesz użyć narzędzia porównującego do oceny podobnych przedmiotów.",
@@ -848,8 +846,6 @@
     "SortName": "Nazwa"
   },
   "Stats": {
-    "Custom": "Niestandardowa suma",
-    "CustomDesc": "Niestandardowa suma skonfigurowana w ustawieniach. Dodaje tylko podstawowe statystyki, ignorując modyfikacje lub mistrzostwo.",
     "Discipline": "Dyscyplina",
     "Intellect": "Intelekt",
     "MaxGearPower": "Maksymalna Moc dostępnego wyposażenia",
diff --git a/src/locale/pt-br/dim.json b/src/locale/pt-br/dim.json
index 503467d80..e382b2462 100644
--- a/src/locale/pt-br/dim.json
+++ b/src/locale/pt-br/dim.json
@@ -1,6 +1,6 @@
 {
   "AWA": {
-    "ConfirmDescription": "Por favor, utilize o app Destiny Companion para dar permissão ao DIM modificar seus itens.",
+    "ConfirmDescription": "Por favor, utilize o app Destiny Companion para dar permissão ao DIM de modificar seus itens.",
     "ConfirmTitle": "Confirmar Ação"
   },
   "Accounts": {
@@ -249,7 +249,7 @@
     "NamedStat": "Mostra armaduras que tem pontos no atributo nomeado.",
     "Negate": "Para negar uma busca, inclua um sinal de menos como prefixo do termo buscado: {{notexample}} achará todos os itens não-engramas.",
     "NewItems": "Mostra novos itens.",
-    "Notes": "Procura por itens que você marcou com notas personalizadas",
+    "Notes": "Procura por itens que você marcou com notas customizadas",
     "Ornament": "Mostra itens com ornamentos e filtros pra seus estados.",
     "PartialMatch": "Exibe itens onde seus nomes/perks possuem uma correspondência parcial com o texto do filtro. Busque por frases inteiras usando aspas. O filtro 'perk:' buscará somente perks.",
     "Postmaster": "Itens que estão no Chefe dos Correios atualmente.",
@@ -379,7 +379,7 @@
     "FilterMatchCount": "1 item",
     "FilterMatchCount_plural": "{{count}} itens",
     "Filters": "Filtros",
-    "InstallDIM": "Instalar como um App",
+    "InstallDIM": "Instalar DIM",
     "Inventory": "Inventário",
     "IosPwaPrompt": "No Safari para celular, clique no ícone compartilhar (botão do meio na parte inferior) e selecione \"Adicionar à Tela Inicial\".",
     "Menu": "Menu",
@@ -696,7 +696,6 @@
     "PowerCap": "",
     "PullItem": "Mover de {{bucket}} para {{store}}",
     "ReadLore": "Leia a história no Ishtar Collective (somente em inglês)",
-    "ReadLoreLink": "Ler sobre",
     "ReviewsTab": "Análises",
     "Rewards": "Recompensas:",
     "Split": "Dividir",
@@ -729,7 +728,7 @@
       "Archetype": "Arquétipo",
       "BaseStats": "Atributos base",
       "Breaker": "Quebra-guarda",
-      "CustomTotal": "Total Personalizado",
+      "CustomTotal": "Total customizado",
       "Damage": "Dano",
       "Element": "Elemento",
       "Energy": "Energia",
@@ -811,7 +810,6 @@
     "ColumnSize": "{{num}} itens",
     "ColumnSizeAuto": "Auto",
     "CsvImport": "Importar marcas/anotações do CSV",
-    "CustomStatDesc": "Indique atributos de armaduras para criar um valor total personalizado que será exibido no popup do item, no Organizador e no Comparador. Pesquise por stat:custom>=30.",
     "Data": "Planilhas",
     "DefaultItemSizeNote": "Um item de tamanho 50px vai parecer mais nítido, sem borrar a imagem ou o texto do item.",
     "DontForgetDupes": "Não esqueça que você pode pesquisar is:dupe para encontrar facilmente itens duplicados, e então usar a ferramenta de comparação para avaliar itens relacionados.",
@@ -848,8 +846,6 @@
     "SortName": "Nome"
   },
   "Stats": {
-    "Custom": "Total Personalizado",
-    "CustomDesc": "Total personalizado indicado nas configurações. Inclui apenas atributos base, ignorando mods e obras-primas.",
     "Discipline": "Disciplina",
     "Intellect": "Intelecto",
     "MaxGearPower": "Poder máximo de itens equipáveis",
diff --git a/src/locale/ru/dim.json b/src/locale/ru/dim.json
index 25591aeed..a7669b1db 100644
--- a/src/locale/ru/dim.json
+++ b/src/locale/ru/dim.json
@@ -379,7 +379,7 @@
     "FilterMatchCount": "1 предмет",
     "FilterMatchCount_plural": "{{count}} предметов",
     "Filters": "Фильтры",
-    "InstallDIM": "Install as an App",
+    "InstallDIM": "Установить DIM",
     "Inventory": "Инвентарь",
     "IosPwaPrompt": "В мобильном браузере Safari, нажмите на кнопку «Поделиться» (находится внизу экрана посередине) и выберите «Добавить на экран \"Домой\"».",
     "Menu": "Меню",
@@ -696,7 +696,6 @@
     "PowerCap": "",
     "PullItem": "Перемещаем из {{bucket}} в {{store}}:",
     "ReadLore": "Читать лор на портале Ishtar Collective",
-    "ReadLoreLink": "Read lore",
     "ReviewsTab": "Отзывы",
     "Rewards": "Награды:",
     "Split": "Делить",
@@ -811,7 +810,6 @@
     "ColumnSize": "{{num}} предметов",
     "ColumnSizeAuto": "Авто",
     "CsvImport": "Импорт тегов/заметок из файла CSV",
-    "CustomStatDesc": "Choose armor stats to make a custom total stat that will show on the item popup, Organizer, and Compare. Search with stat:custom:>=30.",
     "Data": "Таблицы",
     "DefaultItemSizeNote": "При 50px предметы выглядят максимально четко. Ни картинка, ни текст не будут искажаться или размываться.",
     "DontForgetDupes": "Не забывайте, что вы набрать в поисковой строке is:dupe, чтобы быстро найти дубликаты предметов и использовать инструмент сравнения для оценки взаимосвязанных предметов.",
@@ -848,8 +846,6 @@
     "SortName": "Имя"
   },
   "Stats": {
-    "Custom": "Custom Total",
-    "CustomDesc": "Custom total configured in settings. This only adds up base stats, ignoring mods or masterworking.",
     "Discipline": "Дисциплина",
     "Intellect": "Интеллект",
     "MaxGearPower": "Максимальный уровень Силы экипированного снаряжения",
diff --git a/src/locale/zh-chs/dim.json b/src/locale/zh-chs/dim.json
index fc4bc6c99..194f36390 100644
--- a/src/locale/zh-chs/dim.json
+++ b/src/locale/zh-chs/dim.json
@@ -379,7 +379,7 @@
     "FilterMatchCount": "1个物品",
     "FilterMatchCount_plural": "{{count}} 个物品",
     "Filters": "过滤器",
-    "InstallDIM": "Install as an App",
+    "InstallDIM": "安装 DIM",
     "Inventory": "背包",
     "IosPwaPrompt": "在使用移动端Safari浏览器时，请点击屏幕底部中间的分享按钮并选择“添加到主屏幕”。",
     "Menu": "菜单",
@@ -696,7 +696,6 @@
     "PowerCap": "",
     "PullItem": "从{{bucket}} 移到{{store}}",
     "ReadLore": "在Ishtar Collective阅读背景故事",
-    "ReadLoreLink": "Read lore",
     "ReviewsTab": "评测",
     "Rewards": "奖励：",
     "Split": "拆分",
@@ -811,7 +810,6 @@
     "ColumnSize": "{{num}} 个物品",
     "ColumnSizeAuto": "自动",
     "CsvImport": "从CSV导入标签/注释",
-    "CustomStatDesc": "Choose armor stats to make a custom total stat that will show on the item popup, Organizer, and Compare. Search with stat:custom:>=30.",
     "Data": "试算表",
     "DefaultItemSizeNote": "50 px 的物品大小看起来最清晰, 而不会模糊物品图片或文本。",
     "DontForgetDupes": "别忘了，你可以搜索 is:dupe 来快速找到重复的物品，也可以用对比工具来比较相关物品。",
@@ -848,8 +846,6 @@
     "SortName": "名称"
   },
   "Stats": {
-    "Custom": "Custom Total",
-    "CustomDesc": "Custom total configured in settings. This only adds up base stats, ignoring mods or masterworking.",
     "Discipline": "训练",
     "Intellect": "智力",
     "MaxGearPower": "当前装备最大光等",
diff --git a/src/locale/zh-cht/dim.json b/src/locale/zh-cht/dim.json
index ff667ebb0..005c1231c 100644
--- a/src/locale/zh-cht/dim.json
+++ b/src/locale/zh-cht/dim.json
@@ -379,7 +379,7 @@
     "FilterMatchCount": "1個物品",
     "FilterMatchCount_plural": "{{count}} 個物品",
     "Filters": "篩檢程式",
-    "InstallDIM": "Install as an App",
+    "InstallDIM": "安裝 DIM",
     "Inventory": "背包",
     "IosPwaPrompt": "在使用移動端Safari流覽器時，請點擊螢幕底部中間的分享按鈕並選擇“添加到主螢幕”。",
     "Menu": "菜單",
@@ -696,7 +696,6 @@
     "PowerCap": "",
     "PullItem": "從{{bucket}} 移到{{store}}",
     "ReadLore": "在Ishtar Collective閱讀背景故事",
-    "ReadLoreLink": "Read lore",
     "ReviewsTab": "評測",
     "Rewards": "獎勵：",
     "Split": "拆分",
@@ -811,7 +810,6 @@
     "ColumnSize": "{{num}} 個物品",
     "ColumnSizeAuto": "自動",
     "CsvImport": "從CSV導入標籤/注釋",
-    "CustomStatDesc": "Choose armor stats to make a custom total stat that will show on the item popup, Organizer, and Compare. Search with stat:custom:>=30.",
     "Data": "試算表",
     "DefaultItemSizeNote": "50 px 的物品大小看起來最清晰, 而不會模糊物品圖片或文本。",
     "DontForgetDupes": "您可以在搜尋框使用 is:dupe 來快速查找重複物品, 也可以使用對比工具來對比相關物品.",
@@ -848,8 +846,6 @@
     "SortName": "名稱"
   },
   "Stats": {
-    "Custom": "Custom Total",
-    "CustomDesc": "Custom total configured in settings. This only adds up base stats, ignoring mods or masterworking.",
     "Discipline": "訓練",
     "Intellect": "智力",
     "MaxGearPower": "當前裝備最大光等",


Test patch:
diff --git a/src/app/dim-ui/CharacterSelect.test.tsx b/src/app/dim-ui/CharacterSelect.test.tsx
index bb725bb34..36d71d491 100644
--- a/src/app/dim-ui/CharacterSelect.test.tsx
+++ b/src/app/dim-ui/CharacterSelect.test.tsx
@@ -3,7 +3,7 @@ import TestRenderer from 'react-test-renderer';
 import CharacterSelect from './CharacterSelect';
 import { DimStore } from '../inventory/store-types';
 import { Frame, Track, ViewPager } from 'react-view-pager';
-import SimpleCharacterTile from 'app/inventory/SimpleCharacterTile';
+import CharacterTileButton from 'app/character-tile/CharacterTileButton';
 
 const stubDimStore: DimStore = {} as DimStore;
 const stubDimStoreArray: DimStore[] = [];
@@ -75,7 +75,7 @@ it('should render all non-vault stores in the phone interface', () => {
   );
   const testInstance = testRenderer.root;
   // There are two non-vault mocked DimStore objects. Both should render.
-  expect(testInstance.findAllByType(SimpleCharacterTile).length).toBe(2);
+  expect(testInstance.findAllByType(CharacterTileButton).length).toBe(2);
 });
 
 it('should render all non-vault stores in the non-phone interface', () => {
@@ -90,7 +90,7 @@ it('should render all non-vault stores in the non-phone interface', () => {
   );
   const testInstance = testRenderer.root;
   // There are two non-vault mocked DimStore objects. Both should render.
-  expect(testInstance.findAllByType(SimpleCharacterTile).length).toBe(2);
+  expect(testInstance.findAllByType(CharacterTileButton).length).toBe(2);
 });
 
 it('should call the onCharacterChanged function on click in the phone interface', () => {
@@ -105,7 +105,7 @@ it('should call the onCharacterChanged function on click in the phone interface'
   );
   const testInstance = testRenderer.root;
   // Simulate a click event
-  testInstance.findAllByType(SimpleCharacterTile)[0].props.onClick();
+  testInstance.findAllByType(CharacterTileButton)[0].props.onClick();
   expect(mockOnCharacterChanged).toHaveBeenCalledTimes(1);
   jest.clearAllMocks();
 });
@@ -122,7 +122,7 @@ it('should call the onCharacterChanged function on click in the non-phone interf
   );
   const testInstance = testRenderer.root;
   // Simulate a click event
-  testInstance.findAllByType(SimpleCharacterTile)[0].props.onClick();
+  testInstance.findAllByType(CharacterTileButton)[0].props.onClick();
   expect(mockOnCharacterChanged).toHaveBeenCalledTimes(1);
   jest.clearAllMocks();
 });