diff --git a/docs/get-started/usage/game-client/install-manage-mods.md b/docs/get-started/usage/game-client/install-manage-mods.md
index f627093c..966c32c1 100644
--- a/docs/get-started/usage/game-client/install-manage-mods.md
+++ b/docs/get-started/usage/game-client/install-manage-mods.md
@@ -19,6 +19,18 @@ Installing mods is sooo easy. Just go to the **Mods** page, click on the mod you
This will install the **Mod** on the **Installation** you've selected on the left menu.
+### Suggested Mods
+
+The **Mods** page can offer a small row of compatible Mods above the normal catalog. The first time
+you use it, RiftLauncher asks separately for permission to check the ModDB catalog; it does not
+turn on just because you answered another ModDB question. Each suggestion explains why it was
+shown, such as being enabled in another compatible Installation, matching categories, trending,
+popular, or recently updated.
+
+Use **Refresh suggestions** to check again, or **Dismiss suggestion** to hide a listing. **Add all
+suggestions** opens the same confirmation table used by the regular multi-select install flow;
+nothing is downloaded until you confirm the table.
+
Check out this little guide explaining how to do it:
{% embed url="https://www.youtube.com/watch?v=aKqQLtS2WF0" %}
diff --git a/src/config/configManager.ts b/src/config/configManager.ts
index b238705e..ed6ba7d8 100644
--- a/src/config/configManager.ts
+++ b/src/config/configManager.ts
@@ -13,6 +13,7 @@ import { normalizeModDbVisibility } from "@domain/moddbVisibility"
import { normalizeReceiveBetaUpdates } from "@domain/appUpdate/betaUpdates"
import { DEFAULT_COMPRESSION_LEVEL, DEFAULT_CONFIG_BASE } from "@domain/config/defaults"
import { normalizeServerBookmarks } from "@domain/servers/bookmarks"
+import { MAX_DISMISSED_MOD_SUGGESTIONS } from "@domain/mods/suggestions"
const defaultConfig: ConfigType = {
...DEFAULT_CONFIG_BASE,
@@ -425,6 +426,15 @@ function normalizeAccounts(value: unknown, atStartup: boolean): AccountPublicTyp
.slice(0, MAX_STORED_ACCOUNTS)
}
+function normalizeModSuggestionsConsent(value: unknown): boolean | null {
+ return value === true || value === false ? value : null
+}
+
+function normalizeDismissedModSuggestions(value: unknown): number[] {
+ const ids = Array.isArray(value) ? value.filter((listingId): listingId is number => typeof listingId === "number" && Number.isSafeInteger(listingId) && listingId > 0) : []
+ return [...new Set(ids)].slice(0, MAX_DISMISSED_MOD_SUGGESTIONS)
+}
+
/**
* `atStartup` is set on the one read that opens a stored document this process has not written:
* `getConfig`'s file read. Everything else (every `saveConfig`, every re-normalization of the
@@ -491,6 +501,8 @@ export function normalizeConfig(config: unknown, { atStartup = false }: { atStar
// `moddbVisibilityAnswer` is the #219 field this replaced, read under its old name so an
// install that answered back then migrates rather than being asked as though it never had.
moddbVisibility: normalizeModDbVisibility(rawConfig.moddbVisibility ?? (rawConfig as Record)["moddbVisibilityAnswer"], app.getVersion()),
+ modSuggestionsConsent: normalizeModSuggestionsConsent(rawConfig.modSuggestionsConsent),
+ dismissedModSuggestions: normalizeDismissedModSuggestions(rawConfig.dismissedModSuggestions),
// Null for anything that is not an explicit yes or no, which is what every config written
// before the toggle existed says, and leaves the running version deciding as it always did.
receiveBetaUpdates: normalizeReceiveBetaUpdates(rawConfig.receiveBetaUpdates),
diff --git a/src/domain/config/defaults.ts b/src/domain/config/defaults.ts
index d68fc444..ae1e76e0 100644
--- a/src/domain/config/defaults.ts
+++ b/src/domain/config/defaults.ts
@@ -31,6 +31,8 @@ export const DEFAULT_CONFIG_BASE: Omit typeof listingId === "number" && Number.isSafeInteger(listingId) && listingId > 0)
+ : []
+ }
+ }
+}
+
/** Every migration the launcher knows, lowest schema first. */
-export const CONFIG_MIGRATIONS: readonly ConfigMigration[] = [floatMarkerToIntegerSchema, stampLinkedOnExternalVersions, singleAccountToAccountList, addGameVersionIdentity]
+export const CONFIG_MIGRATIONS: readonly ConfigMigration[] = [
+ floatMarkerToIntegerSchema,
+ stampLinkedOnExternalVersions,
+ singleAccountToAccountList,
+ addGameVersionIdentity,
+ addModSuggestionsPreferences
+]
function byFromSchema(migrations: readonly ConfigMigration[]): Map {
return new Map(migrations.map((migration) => [migration.fromSchema, migration]))
diff --git a/src/domain/mods/suggestions.ts b/src/domain/mods/suggestions.ts
new file mode 100644
index 00000000..5622f073
--- /dev/null
+++ b/src/domain/mods/suggestions.ts
@@ -0,0 +1,215 @@
+import { evaluateModCompatibility, newestCompatibleRelease, type ModCompatibilityVerdict } from "./compatibility"
+import { listingDeclaresModid, installedCopiesOf } from "./installedFilters"
+import { readModSide } from "./modinfo"
+
+/** The number of cards shown in the suggestions row. */
+export const MAX_SUGGESTIONS = 6
+
+/** The maximum number of detail pages one refresh may ask the ModDB for. */
+export const MAX_SUGGESTION_DETAIL_LOOKUPS = 20
+
+/** Dismissed listing ids are history, not an unbounded event log. */
+export const MAX_DISMISSED_MOD_SUGGESTIONS = 1_000
+
+/** Named weights keep the heuristic small enough to audit and change deliberately. */
+export const SUGGESTION_SCORE_WEIGHTS = {
+ categoryOverlap: 32,
+ trending: 24,
+ // Popularity is useful context, but a catalog counter must not drown out a direct category match.
+ popularity: 8,
+ recency: 16
+} as const
+
+const DOWNLOAD_LOG_CEILING = 1_000_000
+const FOLLOW_LOG_CEILING = 100_000
+const TRENDING_LOG_CEILING = 1_000
+const RECENCY_HALF_LIFE_DAYS = 30
+const DAY_MS = 24 * 60 * 60 * 1_000
+
+/** The local data needed to rank one Installation without reading from disk. */
+export interface SuggestionInstallation {
+ readonly id: string
+ readonly version: string
+ readonly mods: readonly InstalledModType[]
+}
+
+/** The signal values are exposed so the explanation can be checked against the score. */
+export interface SuggestionSignals {
+ readonly categoryOverlap: number
+ readonly trending: number
+ readonly popularity: number
+ readonly recency: number
+ readonly otherInstallation: boolean
+}
+
+export type SuggestionReason =
+ | { readonly kind: "other-installation" }
+ | { readonly kind: "matching-tags"; readonly tags: readonly string[] }
+ | { readonly kind: "trending" }
+ | { readonly kind: "popular" }
+ | { readonly kind: "recent" }
+ | { readonly kind: "catalog" }
+
+/** A catalog listing with its local, explainable ranking result. */
+export interface RankedSuggestion {
+ readonly mod: DownloadableModOnListType
+ readonly score: number
+ readonly signals: SuggestionSignals
+ readonly reason: SuggestionReason
+}
+
+/** A ranked listing whose detail proved compatible with the target game version. */
+export interface ResolvedSuggestion extends RankedSuggestion {
+ readonly detail: DownloadableModType
+ readonly compatibility: ModCompatibilityVerdict
+}
+
+export interface RankSuggestionsInput {
+ readonly catalog: readonly DownloadableModOnListType[]
+ readonly installation: SuggestionInstallation
+ readonly otherInstallations: readonly SuggestionInstallation[]
+ readonly targetGameVersion: string
+ readonly dismissedListingIds: readonly number[]
+ /** Epoch milliseconds. Defaults to the wall clock for production; tests pass a fixed value. */
+ readonly now?: number
+}
+
+function positiveNumber(value: unknown): number {
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0
+}
+
+/** Maps an untrusted catalog counter onto 0..1 without letting it flatten every other signal. */
+function boundedLog(value: unknown, ceiling: number): number {
+ const positive = positiveNumber(value)
+ return Math.min(1, Math.log1p(positive) / Math.log1p(ceiling))
+}
+
+function recency(lastReleased: unknown, now: number): number {
+ if (typeof lastReleased !== "string") return 0
+ const released = Date.parse(lastReleased)
+ if (!Number.isFinite(released)) return 0
+
+ const ageDays = Math.max(0, now - released) / DAY_MS
+ return 1 / (1 + ageDays / RECENCY_HALF_LIFE_DAYS)
+}
+
+function compatibleGameVersion(version: string, target: string): boolean {
+ return evaluateModCompatibility([version], target) !== "undeclared"
+}
+
+function categoryTagsForInstallation(catalog: readonly DownloadableModOnListType[], mods: readonly InstalledModType[]): Set {
+ const tags = new Set()
+ for (const listing of catalog) {
+ if (!mods.some((mod) => listingDeclaresModid(listing.modidstrs, mod.modid))) continue
+ for (const tag of listing.tags) tags.add(tag.toLowerCase())
+ }
+ return tags
+}
+
+function otherInstallationMatch(mod: DownloadableModOnListType, input: RankSuggestionsInput): boolean {
+ return input.otherInstallations.some(
+ (other) => compatibleGameVersion(other.version, input.targetGameVersion) && other.mods.some((installed) => installed.enabled && listingDeclaresModid(mod.modidstrs, installed.modid))
+ )
+}
+
+function reasonFor(signals: SuggestionSignals, matchingTags: readonly string[]): SuggestionReason {
+ if (signals.otherInstallation) return { kind: "other-installation" }
+
+ const weighted: Array<{ value: number; reason: SuggestionReason }> = [
+ { value: signals.categoryOverlap * SUGGESTION_SCORE_WEIGHTS.categoryOverlap, reason: { kind: "matching-tags", tags: matchingTags } },
+ { value: signals.trending * SUGGESTION_SCORE_WEIGHTS.trending, reason: { kind: "trending" } },
+ { value: signals.popularity * SUGGESTION_SCORE_WEIGHTS.popularity, reason: { kind: "popular" } },
+ { value: signals.recency * SUGGESTION_SCORE_WEIGHTS.recency, reason: { kind: "recent" } }
+ ]
+ const winning = weighted.reduce((best, signal) => (signal.value > best.value ? signal : best), { value: 0, reason: { kind: "catalog" } })
+ return winning.reason
+}
+
+/**
+ * Ranks a complete ModDB catalog locally.
+ *
+ * This is intentionally a sum of bounded signals, with one separate tier for a compatible enabled
+ * copy from another Installation. It never reads a file or makes a request, so every suggestion can
+ * be explained from the data the caller already owns.
+ */
+export function rankSuggestions(input: RankSuggestionsInput): RankedSuggestion[] {
+ const dismissed = new Set(input.dismissedListingIds)
+ const installedHere = input.installation.mods
+ const installedTags = categoryTagsForInstallation(input.catalog, installedHere)
+ const now = input.now ?? Date.now()
+
+ return input.catalog
+ .filter((mod) => typeof mod.type === "string" && mod.type.toLowerCase() === "mod")
+ .filter((mod) => {
+ const side = readModSide(typeof mod.side === "string" ? mod.side : undefined)
+ return side === "client" || side === "both"
+ })
+ .filter((mod) => !dismissed.has(mod.modid))
+ .filter((mod) => installedCopiesOf(mod.modidstrs, installedHere).length === 0)
+ .map((mod) => {
+ const matchingTags = mod.tags.filter((tag) => installedTags.has(tag.toLowerCase()))
+ const categoryOverlap = Math.min(matchingTags.length, 3) / 3
+ const trending = boundedLog(mod.trendingpoints, TRENDING_LOG_CEILING)
+ const popularity = boundedLog(mod.downloads, DOWNLOAD_LOG_CEILING) * 0.6 + boundedLog(mod.follows, FOLLOW_LOG_CEILING) * 0.4
+ const recent = recency(mod.lastreleased, now)
+ const otherInstallation = otherInstallationMatch(mod, input)
+ const signals: SuggestionSignals = { categoryOverlap, trending, popularity, recency: recent, otherInstallation }
+ const score =
+ categoryOverlap * SUGGESTION_SCORE_WEIGHTS.categoryOverlap +
+ trending * SUGGESTION_SCORE_WEIGHTS.trending +
+ popularity * SUGGESTION_SCORE_WEIGHTS.popularity +
+ recent * SUGGESTION_SCORE_WEIGHTS.recency
+
+ return { mod, score, signals, reason: reasonFor(signals, matchingTags) }
+ })
+ .sort((one, other) => {
+ if (one.signals.otherInstallation !== other.signals.otherInstallation) return one.signals.otherInstallation ? -1 : 1
+ if (one.score !== other.score) return other.score - one.score
+ return one.mod.modid - other.mod.modid
+ })
+}
+
+export interface ResolveSuggestionsInput {
+ readonly candidates: readonly RankedSuggestion[]
+ readonly targetGameVersion: string
+ readonly getDetail: (listingId: number) => Promise
+ readonly signal?: AbortSignal
+ /** Maximum number of compatible details to retain. Defaults to the visible row size. */
+ readonly maxSuggestions?: number
+}
+
+function isCancelled(signal: AbortSignal | undefined): boolean {
+ return signal?.aborted === true
+}
+
+/**
+ * Confirms the ranked head one detail at a time, preserving rank order and respecting both budgets.
+ * A cancellation returns no partial row because a stale refresh must never repaint over a newer one.
+ */
+export async function resolveSuggestions(input: ResolveSuggestionsInput): Promise {
+ const accepted: ResolvedSuggestion[] = []
+ const candidates = input.candidates.slice(0, MAX_SUGGESTION_DETAIL_LOOKUPS)
+ const maxSuggestions = input.maxSuggestions ?? MAX_SUGGESTIONS
+
+ for (const candidate of candidates) {
+ if (isCancelled(input.signal)) return []
+
+ let detail: DownloadableModType | undefined
+ try {
+ detail = await input.getDetail(candidate.mod.modid)
+ } catch {
+ detail = undefined
+ }
+
+ if (isCancelled(input.signal)) return []
+ if (!detail) continue
+
+ const release = newestCompatibleRelease(detail.releases, input.targetGameVersion)
+ if (!release) continue
+
+ accepted.push({ ...candidate, detail, compatibility: evaluateModCompatibility(release.tags, input.targetGameVersion) })
+ if (accepted.length >= maxSuggestions) break
+ }
+
+ return accepted
+}
diff --git a/src/global.d.ts b/src/global.d.ts
index 7bdb1c67..8b7c749a 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -43,6 +43,10 @@ declare global {
answeredVersion: string
countedVersions: string[]
}
+ /** Whether the player has independently opted into ModDB suggestions for the Mods browser. */
+ modSuggestionsConsent: boolean | null
+ /** Listing ids dismissed from the suggestions row, bounded and retained once recorded. */
+ dismissedModSuggestions: number[]
/**
* Whether update checks offer prerelease builds: `true` for yes, `false` for no, and `null`
* while nobody has said, which leaves the running version deciding the way electron-updater
diff --git a/src/renderer/src/features/config/contexts/ConfigContext.tsx b/src/renderer/src/features/config/contexts/ConfigContext.tsx
index 76d28ac6..7fd67325 100644
--- a/src/renderer/src/features/config/contexts/ConfigContext.tsx
+++ b/src/renderer/src/features/config/contexts/ConfigContext.tsx
@@ -28,6 +28,10 @@ export interface ConfigSettingsType {
accentColor: string
/** The stored answer to the ModDB listing question, the version it was given under, and what has been counted. See src/domain/moddbVisibility.ts. */
moddbVisibility: ConfigType["moddbVisibility"]
+ /** Whether the player has opted into the separate ModDB suggestions row, or null while unanswered. */
+ modSuggestionsConsent: boolean | null
+ /** Listing ids dismissed from the suggestions row. */
+ dismissedModSuggestions: number[]
/** Whether update checks may offer betas, or null while nobody has said. See src/domain/appUpdate/betaUpdates.ts. */
receiveBetaUpdates: boolean | null
/** Whether the launcher measures the game process while it runs. See src/domain/sessions/sampling.ts. */
@@ -175,6 +179,8 @@ const ConfigProvider = ({ children }: { children: React.ReactNode }): JSX.Elemen
backgroundRevision: config._backgroundRevision ?? 0,
accentColor: config.accentColor,
moddbVisibility: config.moddbVisibility,
+ modSuggestionsConsent: config.modSuggestionsConsent,
+ dismissedModSuggestions: config.dismissedModSuggestions,
receiveBetaUpdates: config.receiveBetaUpdates,
measurePlaySessions: config.measurePlaySessions,
allowBasicSessionStore: config.allowBasicSessionStore,
@@ -191,6 +197,8 @@ const ConfigProvider = ({ children }: { children: React.ReactNode }): JSX.Elemen
config._backgroundRevision,
config.accentColor,
config.moddbVisibility,
+ config.modSuggestionsConsent,
+ config.dismissedModSuggestions,
config.receiveBetaUpdates,
config.measurePlaySessions,
config.allowBasicSessionStore,
diff --git a/src/renderer/src/features/config/contexts/configReducer.ts b/src/renderer/src/features/config/contexts/configReducer.ts
index 92dd71b4..5fbe881c 100644
--- a/src/renderer/src/features/config/contexts/configReducer.ts
+++ b/src/renderer/src/features/config/contexts/configReducer.ts
@@ -1,5 +1,6 @@
import { DEFAULT_CONFIG_BASE } from "@domain/config/defaults"
import { type ModDbVisibilityState } from "@domain/moddbVisibility"
+import { MAX_DISMISSED_MOD_SUGGESTIONS } from "@domain/mods/suggestions"
export enum CONFIG_ACTIONS {
SET_CONFIG = "SET_CONFIG",
@@ -14,6 +15,8 @@ export enum CONFIG_ACTIONS {
SET_BACKGROUND = "SET_BACKGROUND",
SET_ACCENT_COLOR = "SET_ACCENT_COLOR",
SET_MODDB_VISIBILITY = "SET_MODDB_VISIBILITY",
+ SET_MOD_SUGGESTIONS_CONSENT = "SET_MOD_SUGGESTIONS_CONSENT",
+ ADD_DISMISSED_MOD_SUGGESTION = "ADD_DISMISSED_MOD_SUGGESTION",
SET_RECEIVE_BETA_UPDATES = "SET_RECEIVE_BETA_UPDATES",
SET_MEASURE_PLAY_SESSIONS = "SET_MEASURE_PLAY_SESSIONS",
SET_ALLOW_BASIC_SESSION_STORE = "SET_ALLOW_BASIC_SESSION_STORE",
@@ -127,6 +130,18 @@ export interface SetModDbVisibility {
payload: ModDbVisibilityState
}
+/** Records the separate answer for the opt-in ModDB suggestions row. */
+export interface SetModSuggestionsConsent {
+ type: CONFIG_ACTIONS.SET_MOD_SUGGESTIONS_CONSENT
+ payload: boolean | null
+}
+
+/** Remembers one dismissed listing while keeping dismissal history bounded. */
+export interface AddDismissedModSuggestion {
+ type: CONFIG_ACTIONS.ADD_DISMISSED_MOD_SUGGESTION
+ payload: { listingId: number }
+}
+
/**
* Answers, once and for good, whether update checks may offer beta builds.
*
@@ -323,6 +338,8 @@ export type ConfigAction =
| SetBackground
| SetAccentColor
| SetModDbVisibility
+ | SetModSuggestionsConsent
+ | AddDismissedModSuggestion
| SetReceiveBetaUpdates
| SetMeasurePlaySessions
| SetAllowBasicSessionStore
@@ -384,6 +401,12 @@ export const configReducer = (config: ConfigType, action: ConfigAction): ConfigT
return { ...config, accentColor: action.payload }
case CONFIG_ACTIONS.SET_MODDB_VISIBILITY:
return { ...config, moddbVisibility: action.payload }
+ case CONFIG_ACTIONS.SET_MOD_SUGGESTIONS_CONSENT:
+ return { ...config, modSuggestionsConsent: action.payload }
+ case CONFIG_ACTIONS.ADD_DISMISSED_MOD_SUGGESTION: {
+ if (config.dismissedModSuggestions.includes(action.payload.listingId) || config.dismissedModSuggestions.length >= MAX_DISMISSED_MOD_SUGGESTIONS) return config
+ return { ...config, dismissedModSuggestions: [...config.dismissedModSuggestions, action.payload.listingId] }
+ }
case CONFIG_ACTIONS.SET_RECEIVE_BETA_UPDATES:
return { ...config, receiveBetaUpdates: action.payload }
case CONFIG_ACTIONS.SET_MEASURE_PLAY_SESSIONS:
diff --git a/src/renderer/src/features/mods/components/ModListCard.tsx b/src/renderer/src/features/mods/components/ModListCard.tsx
index d827f817..c4a4d665 100644
--- a/src/renderer/src/features/mods/components/ModListCard.tsx
+++ b/src/renderer/src/features/mods/components/ModListCard.tsx
@@ -1,4 +1,4 @@
-import { memo, useLayoutEffect, useRef } from "react"
+import { memo, useLayoutEffect, useRef, type ReactNode } from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import {
@@ -66,6 +66,7 @@ function ModListCard({
busy = false,
updateTo,
onAction,
+ footer,
picked,
pickDisabled = false
}: Readonly<{
@@ -86,6 +87,8 @@ function ModListCard({
/** A newer release tagged for the Installation's game version, once the ModDB details are in. */
updateTo?: string
onAction?: (mod: DownloadableModOnListType, action: ModCardAction) => void | Promise
+ /** Optional content below the card body, used by the suggestions row for its reason and dismiss action. */
+ footer?: ReactNode
/** Set only in selection mode: whether this Mod is picked. */
picked?: boolean
/** The selection is full and this Mod is not in it, so it cannot be picked. */
@@ -171,6 +174,8 @@ function ModListCard({
+ {footer}
+
{/*
* The favorite hue goes on the icon: a colour on the ghost FormButton loses the cascade
diff --git a/src/renderer/src/features/mods/components/ModSuggestions.tsx b/src/renderer/src/features/mods/components/ModSuggestions.tsx
new file mode 100644
index 00000000..e66df4d7
--- /dev/null
+++ b/src/renderer/src/features/mods/components/ModSuggestions.tsx
@@ -0,0 +1,143 @@
+import { FiLoader } from "react-icons/fi"
+import { PiArrowClockwise, PiX } from "react-icons/pi"
+import { useTranslation } from "react-i18next"
+
+import type { ResolvedSuggestion } from "@domain/mods/suggestions"
+import { FormButton } from "@renderer/components/ui/FormComponents"
+import { GridGroup, GridWrapper } from "@renderer/components/ui/Grid"
+import ModListCard, { type ModCardAction } from "@renderer/features/mods/components/ModListCard"
+import { quickInstallKey } from "@renderer/features/mods/hooks/useInstalledModActions"
+
+function reasonText(suggestion: ResolvedSuggestion, t: (key: string, options?: Record
) => string): string {
+ switch (suggestion.reason.kind) {
+ case "other-installation":
+ return t("features.mods.suggestionsReasonOtherInstallation")
+ case "matching-tags":
+ return t("features.mods.suggestionsReasonMatchingTags", { count: suggestion.reason.tags.length })
+ case "trending":
+ return t("features.mods.suggestionsReasonTrending")
+ case "popular":
+ return t("features.mods.suggestionsReasonPopular")
+ case "recent":
+ return t("features.mods.suggestionsReasonRecent")
+ case "catalog":
+ return t("features.mods.suggestionsReasonCatalog")
+ }
+}
+
+/** The opt-in and compact suggestion row above the ordinary ModDB grid. */
+function ModSuggestions({
+ consent,
+ installation,
+ suggestions,
+ loading,
+ selecting,
+ pickedIds,
+ isModFav,
+ isBusy,
+ onEnable,
+ onRefresh,
+ onDismiss,
+ onAddAll,
+ onSelect,
+ onToggleFav,
+ onOpenModDb,
+ onAction
+}: Readonly<{
+ consent: boolean | null
+ installation: InstallationType | undefined
+ suggestions: readonly ResolvedSuggestion[]
+ loading: boolean
+ selecting: boolean
+ pickedIds?: ReadonlySet
+ isModFav: (mod: DownloadableModOnListType) => boolean
+ isBusy: (key: string) => boolean
+ onEnable: () => void
+ onRefresh: () => void
+ onDismiss: (listingId: number) => void
+ onAddAll: (mods: readonly DownloadableModOnListType[]) => void | Promise
+ onSelect: (mod: DownloadableModOnListType) => void
+ onToggleFav: (mod: DownloadableModOnListType) => void
+ onOpenModDb: (mod: DownloadableModOnListType) => void
+ onAction: (mod: DownloadableModOnListType, action: ModCardAction) => void | Promise
+}>): JSX.Element | null {
+ const { t } = useTranslation()
+
+ if (!installation || consent === false) return null
+
+ if (consent === null) {
+ return (
+
+ {t("features.mods.suggestionsOptInTitle")}
+ {t("features.mods.suggestionsOptInBody")}
+
+ {t("features.mods.suggestionsOptInButton")}
+
+
+ )
+ }
+
+ if (!loading && suggestions.length === 0) return null
+
+ const headingId = "mod-suggestions-heading"
+ return (
+
+
+
+
+
+ {t("features.mods.suggestionsTitle", { installation: installation.name })}
+
+
{t("features.mods.suggestionsFooter")}
+
+
+
+
+
+
onAddAll(suggestions.map(({ mod }) => mod))} disabled={loading || suggestions.length === 0}>
+ {t("features.mods.suggestionsAddAll")}
+
+
+
+
+
+ {loading && }
+ {suggestions.map((suggestion) => (
+
+ {reasonText(suggestion, t)}
+ {
+ event.stopPropagation()
+ onDismiss(suggestion.mod.modid)
+ }}
+ >
+
+
+
+ }
+ />
+ ))}
+
+
+
+ )
+}
+
+export default ModSuggestions
diff --git a/src/renderer/src/features/mods/hooks/useModSuggestions.ts b/src/renderer/src/features/mods/hooks/useModSuggestions.ts
new file mode 100644
index 00000000..68cf731e
--- /dev/null
+++ b/src/renderer/src/features/mods/hooks/useModSuggestions.ts
@@ -0,0 +1,142 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+
+import { parseModListResponse } from "@domain/mods/moddb"
+import { MAX_SUGGESTION_DETAIL_LOOKUPS, MAX_SUGGESTIONS, rankSuggestions, resolveSuggestions, type ResolvedSuggestion, type SuggestionInstallation } from "@domain/mods/suggestions"
+import { queryModDb } from "@renderer/features/moddb/adapters/moddb"
+import type { QueryModOutcome } from "@renderer/features/mods/hooks/useQueryMod"
+import { logMods } from "@renderer/features/moddb/adapters/log"
+
+export interface ModSuggestionsState {
+ readonly suggestions: readonly ResolvedSuggestion[]
+ readonly loading: boolean
+ readonly refresh: () => void
+}
+
+/**
+ * Loads the optional suggestions row in two deliberately separate phases: one bare catalog
+ * snapshot, then at most twenty detail checks in rank order. The effect owns an AbortController so
+ * a refresh, Installation switch, or unmount cannot paint the result of an older request.
+ */
+export function useModSuggestions({
+ consent,
+ installation,
+ installations,
+ installedMods,
+ dismissedListingIds,
+ getInstalledMods,
+ queryMod
+}: Readonly<{
+ consent: boolean | null
+ installation: InstallationType | undefined
+ installations: readonly InstallationType[]
+ installedMods: readonly InstalledModType[] | undefined
+ dismissedListingIds: readonly number[]
+ getInstalledMods: ({ path }: { path: string }) => Promise<{ mods: InstalledModType[]; errors: ErrorInstalledModType[] }>
+ queryMod: ({ modid }: { modid: number | string }) => Promise
+}>): ModSuggestionsState {
+ const [suggestions, setSuggestions] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [refreshNumber, setRefreshNumber] = useState(0)
+ const installationId = installation?.id
+ const installationPath = installation?.path
+ const installationVersion = installation?.version
+ const dismissedListingIdsRef = useRef(dismissedListingIds)
+ dismissedListingIdsRef.current = dismissedListingIds
+
+ const installationsRef = useRef(installations)
+ installationsRef.current = installations
+ const installedModsRef = useRef(installedMods)
+ installedModsRef.current = installedMods
+
+ // Stable key for other installations: id, path, version. Does not change when _modsCount or unrelated fields mutate.
+ const otherInstallationsKey = useMemo(
+ () =>
+ installations
+ .filter((item) => item.id !== installationId)
+ .map((item) => `${item.id}:${item.path}:${item.version}`)
+ .join(";"),
+ [installations, installationId]
+ )
+
+ // Stable key for current installed mods: modid, version, enabled. Does not change on array recreation with identical mod list.
+ const installedModsKey = useMemo(() => (installedMods ? installedMods.map((mod) => `${mod.modid}:${mod.version}:${mod.enabled}`).join(";") : null), [installedMods])
+
+ const refresh = useCallback(() => setRefreshNumber((number) => number + 1), [])
+
+ useEffect(() => {
+ if (consent !== true || !installationId || !installationPath || !installationVersion || installedModsKey === null) {
+ setSuggestions([])
+ setLoading(false)
+ return
+ }
+
+ const controller = new AbortController()
+ setLoading(true)
+
+ void (async (): Promise => {
+ try {
+ // This path is intentionally unfiltered. The browse grid's current search/order is not a
+ // suggestions input, and this is the one request that gives the pure ranker a complete view.
+ const catalogResponse = await queryModDb("/mods")
+ if (controller.signal.aborted) return
+
+ const catalog = parseModListResponse(catalogResponse)
+ if (!catalog.ok) {
+ logMods("error", `[front] [mods] [features/mods/hooks/useModSuggestions.ts] Catalog snapshot failed: ${catalog.reason}.`)
+ setSuggestions([])
+ return
+ }
+
+ const otherInstallations: SuggestionInstallation[] = []
+ for (const other of installationsRef.current) {
+ if (other.id === installationId) continue
+ if (controller.signal.aborted) return
+
+ const scanned = await getInstalledMods({ path: other.path })
+ if (controller.signal.aborted) return
+ otherInstallations.push({ id: other.id, version: other.version, mods: scanned.mods })
+ }
+
+ const current: SuggestionInstallation = { id: installationId, version: installationVersion, mods: installedModsRef.current ?? [] }
+ const ranked = rankSuggestions({
+ catalog: catalog.payload as unknown as DownloadableModOnListType[],
+ installation: current,
+ otherInstallations,
+ targetGameVersion: installationVersion,
+ dismissedListingIds: dismissedListingIdsRef.current,
+ now: Date.now()
+ })
+
+ const resolved = await resolveSuggestions({
+ candidates: ranked,
+ targetGameVersion: installationVersion,
+ maxSuggestions: MAX_SUGGESTION_DETAIL_LOOKUPS,
+ signal: controller.signal,
+ getDetail: async (listingId) => {
+ if (controller.signal.aborted) return undefined
+ const outcome = await queryMod({ modid: listingId })
+ return outcome.status === "found" ? outcome.mod : undefined
+ }
+ })
+
+ if (!controller.signal.aborted) setSuggestions(resolved)
+ } catch (error) {
+ if (!controller.signal.aborted) {
+ logMods("error", `[front] [mods] [features/mods/hooks/useModSuggestions.ts] Suggestions failed: ${error}.`)
+ setSuggestions([])
+ }
+ } finally {
+ if (!controller.signal.aborted) setLoading(false)
+ }
+ })()
+
+ return (): void => controller.abort()
+ }, [consent, installationId, installationPath, installationVersion, otherInstallationsKey, installedModsKey, getInstalledMods, queryMod, refreshNumber])
+
+ const visibleSuggestions = useMemo(() => {
+ const dismissed = new Set(dismissedListingIds)
+ return suggestions.filter(({ mod }) => !dismissed.has(mod.modid)).slice(0, MAX_SUGGESTIONS)
+ }, [dismissedListingIds, suggestions])
+
+ return { suggestions: visibleSuggestions, loading, refresh }
+}
diff --git a/src/renderer/src/features/mods/pages/ListMods.tsx b/src/renderer/src/features/mods/pages/ListMods.tsx
index a251c55f..9e4f2285 100644
--- a/src/renderer/src/features/mods/pages/ListMods.tsx
+++ b/src/renderer/src/features/mods/pages/ListMods.tsx
@@ -12,6 +12,7 @@ import { useGetInstalledMods } from "@renderer/features/mods/hooks/useGetInstall
import { installedModLookups } from "@renderer/features/mods/hooks/useGetCompleteInstalledMods"
import { useInstalledModActions } from "@renderer/features/mods/hooks/useInstalledModActions"
import { useQueryMod } from "@renderer/features/mods/hooks/useQueryMod"
+import { useModSuggestions } from "@renderer/features/mods/hooks/useModSuggestions"
import { useSyncModsCount } from "@renderer/features/mods/hooks/useSyncModsCount"
import { logMods } from "@renderer/features/moddb/adapters/log"
import { useExternalLinks } from "@renderer/features/mods/hooks/useExternalLinks"
@@ -24,6 +25,7 @@ import ModsGrid from "@renderer/features/mods/components/ModsGrid"
import DeleteModDialog from "@renderer/features/mods/components/DeleteModDialog"
import ImportModpackPopup from "@renderer/features/mods/components/ImportModpackPopup"
import ModSelectionBar from "@renderer/features/mods/components/ModSelectionBar"
+import ModSuggestions from "@renderer/features/mods/components/ModSuggestions"
import type { ModCardAction } from "@renderer/features/mods/components/ModListCard"
import { FormButton } from "@renderer/components/ui/FormComponents"
import { DEFAULT_LOADED_MODS, getModsBrowseState, updateModsBrowseState, type ModsBrowseState, type ModsFilters } from "@renderer/features/mods/modsBrowseState"
@@ -56,7 +58,7 @@ function ListMods(): JSX.Element {
const installations = useInstallations()
const favMods = useFavMods()
const suspendedModUpdates = useSuspendedModUpdates()
- const { lastUsedInstallation } = useSettingsConfig()
+ const { lastUsedInstallation, modSuggestionsConsent, dismissedModSuggestions } = useSettingsConfig()
const configDispatch = useConfigDispatch()
const { addNotification } = useNotificationsContext()
@@ -83,6 +85,20 @@ function ListMods(): JSX.Element {
const [installationInstalledMods, setInstallationInstalledMods] = useState(undefined)
const installationModsLoadedRef = useRef(false)
+ const {
+ suggestions,
+ loading: suggestionsLoading,
+ refresh: refreshSuggestions
+ } = useModSuggestions({
+ consent: modSuggestionsConsent,
+ installation,
+ installations,
+ installedMods: installationInstalledMods,
+ dismissedListingIds: dismissedModSuggestions,
+ getInstalledMods,
+ queryMod
+ })
+
// The fast scan is this page's refresh: a folder read, with none of the ModDB lookups the
// Manage Mods scan makes for every installed Mod.
const actions = useInstalledModActions(installation, triggerGetInstalledMods)
@@ -373,13 +389,13 @@ function ListMods(): JSX.Element {
}
// Returns its promise so Install selected stays busy, and refuses another press, while the folder is read.
- async function installPicks(): Promise {
+ async function installPicks(selectedPicks: readonly ModPick[] = picks): Promise {
if (!installation) return
const { mods } = await getInstalledMods({ path: installation.path })
// The sidebar stays live during the read. A switch drops the run rather than opening it on the
// other Installation; the picks stay, and the next press reads the new folder.
if (selectedInstallationId.current !== installation.id) return
- const { entries, leftOut } = modSelectionEntries(picks, mods)
+ const { entries, leftOut } = modSelectionEntries(selectedPicks, mods)
setPickRun({ installationId: installation.id, request: { name: "", gameVersion: installation.version, mods: entries }, installedMods: mods, leftOut })
}
@@ -439,6 +455,18 @@ function ListMods(): JSX.Element {
[installNewest, updateMod, toggleEnabled, toggleSuspended, requestDelete, navigate]
)
+ function enableSuggestions(): void {
+ configDispatch({ type: CONFIG_ACTIONS.SET_MOD_SUGGESTIONS_CONSENT, payload: true })
+ }
+
+ function dismissSuggestion(listingId: number): void {
+ configDispatch({ type: CONFIG_ACTIONS.ADD_DISMISSED_MOD_SUGGESTION, payload: { listingId } })
+ }
+
+ function addAllSuggestions(mods: readonly DownloadableModOnListType[]): Promise {
+ return installPicks(addPicks(picks, mods.map(toModPick)))
+ }
+
function clearFilters(): void {
setFilter("textFilter", "")
setFilter("authorFilter", { userid: "", name: "" })
@@ -488,7 +516,7 @@ function ListMods(): JSX.Element {
canInstall={installation !== undefined}
onPickVisible={() => setPicks(addPicks(picks, modsList.slice(0, visibleMods).map(toModPick)))}
onClear={() => setPicks([])}
- onInstall={installPicks}
+ onInstall={() => installPicks()}
/>
)}
@@ -513,6 +541,25 @@ function ListMods(): JSX.Element {
)}
+ favMods.includes(mod.modid)}
+ isBusy={actions.isBusy}
+ onEnable={enableSuggestions}
+ onRefresh={refreshSuggestions}
+ onDismiss={dismissSuggestion}
+ onAddAll={addAllSuggestions}
+ onSelect={selecting ? onTogglePick : onSelectMod}
+ onToggleFav={onToggleFavMod}
+ onOpenModDb={onOpenModDb}
+ onAction={onModAction}
+ />
+
{
})
})
+describe("addModSuggestionsPreferences", () => {
+ it("migrates a config written before suggestions existed without inventing consent", () => {
+ const before = { schemaVersion: 5, favMods: [12] }
+ const after = addModSuggestionsPreferences.migrate(before) as Record
+
+ assert.deepEqual(after, { schemaVersion: 5, favMods: [12], modSuggestionsConsent: null, dismissedModSuggestions: [] })
+ assert.deepEqual(before, { schemaVersion: 5, favMods: [12] })
+ })
+
+ it("keeps valid consent and dismissal history while the migration fills nothing missing", () => {
+ const after = addModSuggestionsPreferences.migrate({ modSuggestionsConsent: true, dismissedModSuggestions: [9, 10] })
+
+ assert.deepEqual(after, { modSuggestionsConsent: true, dismissedModSuggestions: [9, 10] })
+ })
+})
+
describe("floatMarkerToIntegerSchema", () => {
it("steps from the float era to the first integer schema", () => {
assert.equal(floatMarkerToIntegerSchema.fromSchema, FLOAT_ERA_CONFIG_SCHEMA)
@@ -153,8 +170,8 @@ describe("migrateConfigDocument on real configs", () => {
const repeatedDoc = repeated.doc as { gameVersions: Array> }
assert.equal(result.outcome, "migrated")
- assert.equal(result.schema, 5)
- assert.deepEqual(result.applied.at(-1), { fromSchema: 4, toSchema: 5 })
+ assert.equal(result.schema, 6)
+ assert.deepEqual(result.applied.at(-1), { fromSchema: 5, toSchema: 6 })
assert.equal(doc.gameVersions[0]!.label, "1.22.7")
assert.equal(typeof doc.gameVersions[0]!.id, "string")
assert.equal(doc.gameVersions[0]!.id, repeatedDoc.gameVersions[0]!.id, "legacy ids are deterministic")
@@ -262,7 +279,8 @@ describe("migrateConfigDocument on real configs", () => {
{ fromSchema: 1, toSchema: 2 },
{ fromSchema: 2, toSchema: 3 },
{ fromSchema: 3, toSchema: 4 },
- { fromSchema: 4, toSchema: 5 }
+ { fromSchema: 4, toSchema: 5 },
+ { fromSchema: 5, toSchema: 6 }
])
const doc = result.doc as Record
@@ -328,7 +346,8 @@ describe("migrateConfigDocument on real configs", () => {
[FLOAT_ERA_CONFIG_SCHEMA, FIRST_INTEGER_CONFIG_SCHEMA],
[2, 3],
[3, 4],
- [4, 5]
+ [4, 5],
+ [5, 6]
]
)
assert.equal(CONFIG_MIGRATIONS[CONFIG_MIGRATIONS.length - 1]?.toSchema, CURRENT_CONFIG_SCHEMA)
diff --git a/tests/domain/mods/suggestions.test.ts b/tests/domain/mods/suggestions.test.ts
new file mode 100644
index 00000000..96fe3cfb
--- /dev/null
+++ b/tests/domain/mods/suggestions.test.ts
@@ -0,0 +1,389 @@
+import assert from "node:assert/strict"
+import { describe, it } from "vitest"
+
+import { MAX_SUGGESTION_DETAIL_LOOKUPS, MAX_SUGGESTIONS, rankSuggestions, resolveSuggestions, type SuggestionInstallation } from "../../../src/domain/mods/suggestions"
+
+const NOW = Date.parse("2026-09-15T12:00:00Z")
+
+function listing(modid: number, name: string, overrides: Partial = {}): DownloadableModOnListType {
+ return {
+ modid,
+ assetid: modid,
+ downloads: 0,
+ follows: 0,
+ trendingpoints: 0,
+ comments: 0,
+ name,
+ summary: "",
+ modidstrs: [name.toLowerCase().replaceAll(" ", "-")],
+ author: "Author",
+ urlalias: null,
+ side: "client",
+ type: "mod",
+ logo: "",
+ tags: [],
+ lastreleased: "2020-01-01",
+ ...overrides
+ }
+}
+
+function copy(modid: string, enabled = true): InstalledModType {
+ return { name: modid, modid, version: "1.0.0", path: `/Mods/${modid}.zip`, enabled }
+}
+
+function installation(id: string, version = "1.22.7", mods: InstalledModType[] = []): SuggestionInstallation {
+ return { id, version, mods }
+}
+
+function compatibleDetail(mod: DownloadableModOnListType): DownloadableModType {
+ return {
+ modid: mod.modid,
+ assetid: mod.assetid,
+ name: mod.name,
+ urlalias: null,
+ homepageurl: null,
+ sourcecodeurl: null,
+ trendingpoints: mod.trendingpoints,
+ comments: mod.comments,
+ createdat: "2026-01-01",
+ tags: mod.tags,
+ releases: [
+ {
+ releaseid: mod.modid,
+ mainfile: `https://example.test/${mod.modid}.zip`,
+ filename: `${mod.modid}.zip`,
+ fileid: mod.modid,
+ downloads: 0,
+ tags: ["1.22.7"],
+ modidstr: mod.modidstrs[0] ?? "",
+ modversion: "1.0.0",
+ created: "2026-01-01",
+ changelog: ""
+ }
+ ]
+ }
+}
+
+describe("rankSuggestions", () => {
+ it("requires mod listings on the client or both side and excludes local, dismissed, and duplicate matches", () => {
+ const candidates = rankSuggestions({
+ catalog: [
+ listing(1, "Local", { modidstrs: ["LOCAL"] }),
+ listing(2, "Dismissed"),
+ listing(3, "Several", { modidstrs: ["several"] }),
+ listing(4, "Server", { side: "server" }),
+ listing(5, "Pack", { type: "modpack" }),
+ listing(6, "Empty identifier", { modidstrs: [""] }),
+ listing(7, "Client"),
+ listing(8, "Both", { side: "both" })
+ ],
+ installation: installation("current", "1.22.7", [copy("local"), copy("several"), copy("several", false)]),
+ otherInstallations: [],
+ dismissedListingIds: [2],
+ targetGameVersion: "1.22.7",
+ now: NOW
+ })
+
+ assert.deepEqual(
+ candidates.map(({ mod }) => mod.modid),
+ [6, 7, 8]
+ )
+ })
+
+ it("puts an enabled compatible copy from another installation in its own top tier", () => {
+ const reused = listing(1, "Already elsewhere")
+ const disabled = listing(2, "Disabled elsewhere")
+ const old = listing(3, "Old game elsewhere")
+ const result = rankSuggestions({
+ catalog: [reused, disabled, old],
+ installation: installation("current"),
+ otherInstallations: [
+ installation("same-game", "1.22.6", [copy("already-elsewhere")]),
+ installation("disabled", "1.22.7", [copy("disabled-elsewhere", false)]),
+ installation("old-game", "1.21.9", [copy("old-game-elsewhere")])
+ ],
+ dismissedListingIds: [],
+ targetGameVersion: "1.22.7",
+ now: NOW
+ })
+
+ assert.equal(result[0]?.mod.modid, reused.modid)
+ assert.equal(result[0]?.reason.kind, "other-installation")
+ assert.notEqual(result.find((candidate) => candidate.mod.modid === disabled.modid)?.reason.kind, "other-installation")
+ assert.notEqual(result.find((candidate) => candidate.mod.modid === old.modid)?.reason.kind, "other-installation")
+ })
+
+ it("scores each discovery signal in isolation and explains the winning signal", () => {
+ const installedTag = listing(99, "Installed", { modidstrs: ["installed"], tags: ["Tweak"] })
+ const tagMatch = listing(1, "Tag match", { tags: ["Tweak"] })
+ const trending = listing(2, "Trending", { trendingpoints: 100 })
+ const popular = listing(3, "Popular", { downloads: 100, follows: 10 })
+ const recent = listing(4, "Recent", { lastreleased: "2026-09-14T12:00:00Z" })
+
+ const result = rankSuggestions({
+ catalog: [installedTag, tagMatch, trending, popular, recent],
+ installation: installation("current", "1.22.7", [copy("installed")]),
+ otherInstallations: [],
+ dismissedListingIds: [],
+ targetGameVersion: "1.22.7",
+ now: NOW
+ })
+
+ const byId = new Map(result.map((candidate) => [candidate.mod.modid, candidate]))
+ assert.equal(byId.get(tagMatch.modid)?.reason.kind, "matching-tags")
+ assert.equal(byId.get(trending.modid)?.reason.kind, "trending")
+ assert.equal(byId.get(popular.modid)?.reason.kind, "popular")
+ assert.equal(byId.get(recent.modid)?.reason.kind, "recent")
+
+ for (const candidate of result) {
+ const fired =
+ candidate.reason.kind === "matching-tags"
+ ? candidate.signals.categoryOverlap > 0
+ : candidate.reason.kind === "trending"
+ ? candidate.signals.trending > 0
+ : candidate.reason.kind === "popular"
+ ? candidate.signals.popularity > 0
+ : candidate.reason.kind === "recent"
+ ? candidate.signals.recency > 0
+ : candidate.reason.kind === "other-installation"
+ assert.equal(fired, true, `${candidate.mod.name} explanation must name a fired signal`)
+ }
+ })
+
+ it("uses bounded logarithmic popularity so one huge listing does not flatten the list", () => {
+ const huge = listing(1, "Huge", { downloads: 10_000_000_000, follows: 10_000_000_000 })
+ const tagMatch = listing(2, "Tag match", { tags: ["Tweak"] })
+ const installedTag = listing(99, "Installed", { modidstrs: ["installed"], tags: ["Tweak"] })
+ const result = rankSuggestions({
+ catalog: [huge, tagMatch, installedTag],
+ installation: installation("current", "1.22.7", [copy("installed")]),
+ otherInstallations: [],
+ dismissedListingIds: [],
+ targetGameVersion: "1.22.7",
+ now: NOW
+ })
+
+ assert.equal(result[0]?.mod.modid, tagMatch.modid)
+ assert.ok((result.find((candidate) => candidate.mod.modid === huge.modid)?.signals.popularity ?? 1) <= 1)
+ })
+
+ it("uses the injected clock and a deterministic listing-id tie break", () => {
+ const old = listing(1, "Old", { lastreleased: "2026-08-01" })
+ const fresh = listing(2, "Fresh", { lastreleased: "2026-09-14" })
+ const tiedHighId = listing(20, "High id")
+ const tiedLowId = listing(10, "Low id")
+
+ const byTime = rankSuggestions({
+ catalog: [old, fresh],
+ installation: installation("current"),
+ otherInstallations: [],
+ dismissedListingIds: [],
+ targetGameVersion: "1.22.7",
+ now: NOW
+ })
+ assert.equal(byTime[0]?.mod.modid, fresh.modid)
+
+ const tied = rankSuggestions({
+ catalog: [tiedHighId, tiedLowId],
+ installation: installation("current"),
+ otherInstallations: [],
+ dismissedListingIds: [],
+ targetGameVersion: "1.22.7",
+ now: NOW
+ })
+ assert.deepEqual(
+ tied.map(({ mod }) => mod.modid),
+ [10, 20]
+ )
+ })
+
+ it("selects the explanation from the largest weighted contribution, not the raw signal", () => {
+ // categoryOverlap with 1 tag is 1/3 (raw ~0.333), weighted at 32 = 10.67.
+ // trending with trendingpoints 10 gives log10(11)/log10(1000) ~0.347 (raw ~0.347 > 0.333),
+ // but weighted at 24 = 8.33 (< 10.67).
+ // The weighted calculation must choose matching-tags, not trending.
+ const installedListing = listing(99, "Installed Mod", { modidstrs: ["installed-mod"], tags: ["qol"] })
+ const candidate = listing(1, "Candidate", { tags: ["qol"], trendingpoints: 10 })
+ const [ranked] = rankSuggestions({
+ catalog: [installedListing, candidate],
+ installation: installation("current", "1.22.7", [copy("installed-mod")]),
+ otherInstallations: [],
+ dismissedListingIds: [],
+ targetGameVersion: "1.22.7",
+ now: NOW
+ })
+ assert.ok(ranked)
+ assert.deepEqual(ranked.reason, { kind: "matching-tags", tags: ["qol"] })
+ })
+})
+
+describe("resolveSuggestions", () => {
+ it("stops after the detail budget when every ranked candidate is incompatible", async () => {
+ const catalog = Array.from({ length: MAX_SUGGESTION_DETAIL_LOOKUPS + 3 }, (_, index) => listing(index + 1, `Mod ${index + 1}`))
+ const ranked = rankSuggestions({
+ catalog,
+ installation: installation("current"),
+ otherInstallations: [],
+ dismissedListingIds: [],
+ targetGameVersion: "1.22.7",
+ now: NOW
+ })
+ const requested: number[] = []
+ const result = await resolveSuggestions({
+ candidates: ranked,
+ targetGameVersion: "1.22.7",
+ getDetail: async (listingId) => {
+ requested.push(listingId)
+ return { ...compatibleDetail(catalog[listingId - 1]!), releases: [] }
+ }
+ })
+
+ assert.deepEqual(result, [])
+ assert.equal(requested.length, MAX_SUGGESTION_DETAIL_LOOKUPS)
+ })
+
+ it("keeps accepted declared results in rank order and caps the row at six", async () => {
+ const catalog = Array.from({ length: MAX_SUGGESTIONS + 2 }, (_, index) => listing(index + 1, `Mod ${index + 1}`))
+ const ranked = rankSuggestions({
+ catalog,
+ installation: installation("current"),
+ otherInstallations: [],
+ dismissedListingIds: [],
+ targetGameVersion: "1.22.7",
+ now: NOW
+ })
+ const requested: number[] = []
+ const result = await resolveSuggestions({
+ candidates: ranked,
+ targetGameVersion: "1.22.7",
+ getDetail: async (listingId) => {
+ requested.push(listingId)
+ return compatibleDetail(catalog[listingId - 1]!)
+ }
+ })
+
+ assert.deepEqual(
+ result.map(({ mod }) => mod.modid),
+ catalog.slice(0, MAX_SUGGESTIONS).map(({ modid }) => modid)
+ )
+ assert.equal(result.length, MAX_SUGGESTIONS)
+ assert.deepEqual(
+ requested,
+ catalog.slice(0, MAX_SUGGESTIONS).map(({ modid }) => modid)
+ )
+ assert.ok(result.every(({ compatibility }) => compatibility === "declared" || compatibility === "same-minor"))
+ })
+
+ it("can resolve accepted candidates past the six-card display cap for local backfill", async () => {
+ const catalog = Array.from({ length: MAX_SUGGESTIONS + 1 }, (_, index) => listing(index + 1, `Mod ${index + 1}`))
+ const ranked = rankSuggestions({
+ catalog,
+ installation: installation("current"),
+ otherInstallations: [],
+ dismissedListingIds: [],
+ targetGameVersion: "1.22.7",
+ now: NOW
+ })
+
+ const result = await resolveSuggestions({
+ candidates: ranked,
+ targetGameVersion: "1.22.7",
+ maxSuggestions: MAX_SUGGESTIONS + 1,
+ getDetail: async (listingId) => compatibleDetail(catalog[listingId - 1]!)
+ })
+
+ assert.equal(result.length, MAX_SUGGESTIONS + 1)
+ assert.deepEqual(
+ result.map(({ mod }) => mod.modid),
+ catalog.map(({ modid }) => modid)
+ )
+ })
+
+ it("caps resolution at MAX_SUGGESTION_DETAIL_LOOKUPS even with more compatible candidates available", async () => {
+ const catalog = Array.from({ length: MAX_SUGGESTION_DETAIL_LOOKUPS + 10 }, (_, index) => listing(index + 1, `Mod ${index + 1}`))
+ const ranked = rankSuggestions({
+ catalog,
+ installation: installation("current"),
+ otherInstallations: [],
+ dismissedListingIds: [],
+ targetGameVersion: "1.22.7",
+ now: NOW
+ })
+ const requested: number[] = []
+ const result = await resolveSuggestions({
+ candidates: ranked,
+ targetGameVersion: "1.22.7",
+ maxSuggestions: MAX_SUGGESTION_DETAIL_LOOKUPS,
+ getDetail: async (listingId) => {
+ requested.push(listingId)
+ return compatibleDetail(catalog[listingId - 1]!)
+ }
+ })
+
+ assert.equal(result.length, MAX_SUGGESTION_DETAIL_LOOKUPS)
+ assert.equal(requested.length, MAX_SUGGESTION_DETAIL_LOOKUPS)
+ })
+
+ it("does not resolve a detail whose releases are undeclared for the target version", async () => {
+ const candidate = listing(1, "Undeclared")
+ const detail = compatibleDetail(candidate)
+ const ranked = rankSuggestions({
+ catalog: [candidate],
+ installation: installation("current"),
+ otherInstallations: [],
+ dismissedListingIds: [],
+ targetGameVersion: "1.22.7",
+ now: NOW
+ })
+
+ const result = await resolveSuggestions({
+ candidates: ranked,
+ targetGameVersion: "1.22.7",
+ getDetail: async () => ({ ...detail, releases: [{ ...detail.releases[0]!, tags: ["1.21.9"] }] })
+ })
+
+ assert.deepEqual(result, [])
+ })
+
+ it("does not publish work cancelled before a lookup or after a lookup completes", async () => {
+ const candidate = rankSuggestions({
+ catalog: [listing(1, "Mod")],
+ installation: installation("current"),
+ otherInstallations: [],
+ dismissedListingIds: [],
+ targetGameVersion: "1.22.7",
+ now: NOW
+ })
+ const before = new AbortController()
+ before.abort()
+ let beforeCalls = 0
+ assert.deepEqual(
+ await resolveSuggestions({
+ candidates: candidate,
+ targetGameVersion: "1.22.7",
+ signal: before.signal,
+ getDetail: async () => {
+ beforeCalls++
+ return undefined
+ }
+ }),
+ []
+ )
+ assert.equal(beforeCalls, 0)
+
+ const after = new AbortController()
+ let afterCalls = 0
+ const result = await resolveSuggestions({
+ candidates: candidate,
+ targetGameVersion: "1.22.7",
+ signal: after.signal,
+ getDetail: async () => {
+ afterCalls++
+ after.abort()
+ return compatibleDetail(listing(1, "Mod"))
+ }
+ })
+ assert.deepEqual(result, [])
+ assert.equal(afterCalls, 1)
+ })
+})
diff --git a/tests/ipc/configManager.test.ts b/tests/ipc/configManager.test.ts
index fd8517d9..67839500 100644
--- a/tests/ipc/configManager.test.ts
+++ b/tests/ipc/configManager.test.ts
@@ -91,6 +91,8 @@ function minimalConfig(overrides: Partial = {}): ConfigType {
background: DEFAULT_BACKGROUND_ID,
accentColor: DEFAULT_ACCENT_ID,
moddbVisibility: defaultModDbVisibility(),
+ modSuggestionsConsent: null,
+ dismissedModSuggestions: [],
receiveBetaUpdates: DEFAULT_RECEIVE_BETA_UPDATES,
measurePlaySessions: DEFAULT_MEASURE_PLAY_SESSIONS,
allowBasicSessionStore: DEFAULT_ALLOW_BASIC_SESSION_STORE,
@@ -182,6 +184,23 @@ describe("normalizeConfig: the document itself", () => {
const { normalizeConfig } = await freshConfigManager()
assert.deepEqual(normalizeConfig({}).suspendedModUpdates, [])
})
+
+ it("keeps Mod suggestions consent explicit and never regresses it to a missing default", async () => {
+ const { normalizeConfig } = await freshConfigManager()
+
+ assert.equal(normalizeConfig({}).modSuggestionsConsent, null)
+ assert.equal(normalizeConfig({ modSuggestionsConsent: true }).modSuggestionsConsent, true)
+ assert.equal(normalizeConfig({ modSuggestionsConsent: false }).modSuggestionsConsent, false)
+ for (const value of ["true", "yes", 1, {}, []]) assert.equal(normalizeConfig({ modSuggestionsConsent: value }).modSuggestionsConsent, null, String(value))
+ })
+
+ it("keeps valid dismissed Mod suggestions and never regresses them to a missing default", async () => {
+ const { normalizeConfig } = await freshConfigManager()
+
+ assert.deepEqual(normalizeConfig({}).dismissedModSuggestions, [])
+ assert.deepEqual(normalizeConfig({ dismissedModSuggestions: [4, 4, 2.5, "3", 0, -1, 7] }).dismissedModSuggestions, [4, 7])
+ assert.deepEqual(normalizeConfig({ dismissedModSuggestions: "not an array" }).dismissedModSuggestions, [])
+ })
})
describe("normalizeConfig: installations", () => {
diff --git a/tests/renderer-dom/helpers/windowApi.ts b/tests/renderer-dom/helpers/windowApi.ts
index 25b8baca..d96034ab 100644
--- a/tests/renderer-dom/helpers/windowApi.ts
+++ b/tests/renderer-dom/helpers/windowApi.ts
@@ -53,6 +53,8 @@ export function createMockConfig(overrides: MockConfigOverrides = {}): ConfigTyp
background: "default",
accentColor: "amber",
moddbVisibility: { policy: "ask", answeredVersion: "", countedVersions: [] },
+ modSuggestionsConsent: null,
+ dismissedModSuggestions: [],
receiveBetaUpdates: null,
measurePlaySessions: true,
allowBasicSessionStore: false,
diff --git a/tests/renderer-dom/modsSuggestions.test.tsx b/tests/renderer-dom/modsSuggestions.test.tsx
new file mode 100644
index 00000000..04b90f10
--- /dev/null
+++ b/tests/renderer-dom/modsSuggestions.test.tsx
@@ -0,0 +1,218 @@
+import { afterEach, describe, expect, it, vi } from "vitest"
+import { screen, waitFor, within } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { Route, Routes } from "react-router-dom"
+
+import ListMods from "@renderer/features/mods/pages/ListMods"
+import { TaskProvider } from "@renderer/contexts/TaskManagerContext"
+import { installMockWindowApi, createMockConfig, type MockedBridgeAPI } from "./helpers/windowApi"
+import { renderWithProviders } from "./helpers/render"
+import { resetModsBrowseState } from "@renderer/features/mods/modsBrowseState"
+import { clearQueryCache } from "@renderer/features/mods/hooks/useQueryMods"
+
+const INSTALLATION: InstallationType = {
+ id: "install-a",
+ name: "Install A",
+ icon: "",
+ path: "/games/a",
+ version: "1.22.7",
+ gameVersionId: null,
+ startParams: "",
+ backupsLimit: 3,
+ backupsAuto: false,
+ compressionLevel: 6,
+ backups: [],
+ lastTimePlayed: -1,
+ totalTimePlayed: 0,
+ mesaGlThread: false,
+ envVars: ""
+}
+
+const CANDIDATE: DownloadableModOnListType = {
+ modid: 123,
+ assetid: 123,
+ downloads: 100,
+ follows: 5,
+ trendingpoints: 10,
+ comments: 1,
+ name: "Suggestion Candidate",
+ summary: "A useful Mod.",
+ modidstrs: ["suggestioncandidate"],
+ author: "Author",
+ urlalias: null,
+ side: "client",
+ type: "mod",
+ logo: "",
+ tags: ["Tweak"],
+ lastreleased: "2026-09-14"
+}
+
+const BACKFILL_CANDIDATE: DownloadableModOnListType = {
+ ...CANDIDATE,
+ modid: 124,
+ assetid: 124,
+ name: "Backfill Candidate",
+ modidstrs: ["backfillcandidate"]
+}
+
+const DETAIL: DownloadableModType = {
+ modid: 123,
+ assetid: 123,
+ name: "Suggestion Candidate",
+ urlalias: null,
+ homepageurl: null,
+ sourcecodeurl: null,
+ trendingpoints: 10,
+ comments: 1,
+ createdat: "2026-01-01",
+ tags: ["Tweak"],
+ releases: [
+ {
+ releaseid: 1,
+ mainfile: "https://mods.example/suggestioncandidate-1.0.0.zip",
+ filename: "suggestioncandidate-1.0.0.zip",
+ fileid: 1,
+ downloads: 1,
+ tags: ["1.22.7"],
+ modidstr: "suggestioncandidate",
+ modversion: "1.0.0",
+ created: "2026-09-14",
+ changelog: ""
+ }
+ ]
+}
+
+function detailFor(candidate: DownloadableModOnListType): DownloadableModType {
+ return {
+ ...DETAIL,
+ modid: candidate.modid,
+ assetid: candidate.assetid,
+ name: candidate.name,
+ releases: [{ ...DETAIL.releases[0]!, modidstr: candidate.modidstrs[0]!, mainfile: `https://mods.example/${candidate.modidstrs[0]}-1.0.0.zip`, filename: `${candidate.modidstrs[0]}-1.0.0.zip` }]
+ }
+}
+
+const MANY_CANDIDATES = [
+ CANDIDATE,
+ BACKFILL_CANDIDATE,
+ ...Array.from({ length: 5 }, (_, index) => ({ ...CANDIDATE, modid: 125 + index, assetid: 125 + index, name: `Suggestion Candidate ${index + 3}`, modidstrs: [`suggestioncandidate${index + 3}`] }))
+]
+
+function mount(consent: boolean | null = null, suggestionCandidates: readonly DownloadableModOnListType[] = [CANDIDATE]): { api: MockedBridgeAPI; queryURL: ReturnType } {
+ const queryURL = vi.fn(async (url: string): Promise => {
+ if (url.endsWith("/api/mods") || url.includes("/api/mods?")) return JSON.stringify({ statuscode: "200", mods: suggestionCandidates })
+ const candidate = suggestionCandidates.find(({ modid }) => url.endsWith(`/api/mod/${modid}`))
+ if (candidate) return JSON.stringify({ statuscode: "200", mod: detailFor(candidate) })
+ return JSON.stringify({ statuscode: "200", authors: [], gameversions: [], tags: [] })
+ })
+ const api = installMockWindowApi({
+ configManager: {
+ getConfig: vi.fn(async () => createMockConfig({ lastUsedInstallation: INSTALLATION.id, installations: [INSTALLATION], modSuggestionsConsent: consent }))
+ },
+ modsManager: { getInstalledMods: vi.fn(async () => ({ mods: [], errors: [] })) },
+ netManager: { queryURL }
+ })
+
+ renderWithProviders(
+
+
+ } />
+
+ ,
+ { route: "/mods" }
+ )
+ return { api, queryURL }
+}
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ window.localStorage.clear()
+ resetModsBrowseState()
+ clearQueryCache()
+})
+
+describe("Mod suggestions", () => {
+ it("does no suggestion request or card work before the independent opt-in", async () => {
+ const { queryURL } = mount()
+
+ await screen.findByRole("button", { name: "Suggestion Candidate, Not installed" }, { timeout: 3000 })
+ expect(queryURL.mock.calls.filter(([url]) => url.endsWith("/api/mods"))).toHaveLength(0)
+ expect(screen.queryByRole("heading", { name: "Suggested for Install A" })).toBeNull()
+ expect(screen.getByRole("button", { name: "Turn on Mod suggestions" })).toBeTruthy()
+ })
+
+ it("opts in, renders the reason above the grid, refreshes, and persists dismissal", async () => {
+ const user = userEvent.setup()
+ const { api, queryURL } = mount()
+
+ await screen.findByRole("button", { name: "Turn on Mod suggestions" }, { timeout: 3000 })
+ await user.click(screen.getByRole("button", { name: "Turn on Mod suggestions" }))
+
+ const section = await screen.findByRole("region", { name: "Suggested for Install A" }, { timeout: 3000 })
+ await within(section).findByText(/Popular|Recently updated|You run this/i)
+ await waitFor(() => expect(queryURL.mock.calls.filter(([url]) => url.endsWith("/api/mods")).length).toBe(1))
+ expect(api.configManager.saveConfig).toHaveBeenCalledWith(expect.objectContaining({ modSuggestionsConsent: true }))
+
+ const beforeRefresh = queryURL.mock.calls.filter(([url]) => url.endsWith("/api/mods")).length
+ await user.click(within(section).getByRole("button", { name: "Refresh suggestions" }))
+ await waitFor(() => expect(queryURL.mock.calls.filter(([url]) => url.endsWith("/api/mods")).length).toBe(beforeRefresh + 1))
+
+ await user.click(within(section).getByRole("button", { name: "Dismiss suggestion" }))
+ await waitFor(() => expect(screen.queryByRole("region", { name: "Suggested for Install A" })).toBeNull())
+ expect(api.configManager.saveConfig).toHaveBeenCalledWith(expect.objectContaining({ dismissedModSuggestions: [123] }))
+ }, 15_000)
+
+ it("dismisses one card locally without rerunning the pipeline and backfills from the ranked pool", async () => {
+ const user = userEvent.setup()
+ const { queryURL } = mount(null, MANY_CANDIDATES)
+
+ await user.click(await screen.findByRole("button", { name: "Turn on Mod suggestions" }, { timeout: 3000 }))
+ const section = await screen.findByRole("region", { name: "Suggested for Install A" }, { timeout: 3000 })
+ await within(section).findByRole("button", { name: "Suggestion Candidate 6, Not installed" })
+ const catalogRequests = (): typeof queryURL.mock.calls => queryURL.mock.calls.filter(([url]) => url.endsWith("/api/mods"))
+ const detailRequests = (): typeof queryURL.mock.calls => queryURL.mock.calls.filter(([url]) => url.includes("/api/mod/"))
+ await waitFor(() => expect(catalogRequests()).toHaveLength(1))
+ const detailsBeforeDismiss = detailRequests().length
+
+ await user.click(within(section).getAllByRole("button", { name: "Dismiss suggestion" })[0]!)
+
+ await waitFor(() => expect(within(section).queryByRole("button", { name: "Suggestion Candidate, Not installed" })).toBeNull())
+ expect(within(section).getByRole("button", { name: "Suggestion Candidate 7, Not installed" })).toBeTruthy()
+ expect(catalogRequests()).toHaveLength(1)
+ expect(detailRequests()).toHaveLength(detailsBeforeDismiss)
+ }, 15_000)
+
+ it("Add all opens the existing install confirmation without downloading first", async () => {
+ const user = userEvent.setup()
+ const downloadOnPath = vi.fn(async () => "")
+ const { api } = mount(true)
+ Object.assign(api.pathsManager, { downloadOnPath })
+
+ const section = await screen.findByRole("region", { name: "Suggested for Install A" }, { timeout: 3000 })
+ await user.click(within(section).getByRole("button", { name: "Add all suggestions" }))
+
+ const dialog = await screen.findByRole("dialog", { name: "Install Selected Mods" }, { timeout: 3000 })
+ expect(within(dialog).getByText("Suggestion Candidate")).toBeTruthy()
+ expect(downloadOnPath).not.toHaveBeenCalled()
+ }, 15_000)
+
+ it("mounts with consent and 30 candidates runs the pipeline once and caps detail lookups at 20", async () => {
+ const candidates30 = Array.from({ length: 30 }, (_, index) => ({
+ ...CANDIDATE,
+ modid: 200 + index,
+ assetid: 200 + index,
+ name: `Candidate ${index + 1}`,
+ modidstrs: [`candidate${index + 1}`]
+ }))
+ const { queryURL } = mount(true, candidates30)
+
+ const section = await screen.findByRole("region", { name: "Suggested for Install A" }, { timeout: 3000 })
+ await within(section).findByRole("button", { name: "Candidate 1, Not installed" })
+
+ const catalogRequests = (): typeof queryURL.mock.calls => queryURL.mock.calls.filter(([url]) => url.endsWith("/api/mods"))
+ const detailRequests = (): typeof queryURL.mock.calls => queryURL.mock.calls.filter(([url]) => url.includes("/api/mod/"))
+
+ await waitFor(() => expect(catalogRequests()).toHaveLength(1))
+ expect(detailRequests()).toHaveLength(20)
+ }, 15_000)
+})
diff --git a/tests/renderer/configReducer.test.ts b/tests/renderer/configReducer.test.ts
index c6ed2afc..4b493d08 100644
--- a/tests/renderer/configReducer.test.ts
+++ b/tests/renderer/configReducer.test.ts
@@ -19,6 +19,7 @@ import { defaultModDbVisibility, MODDB_VISIBILITY_ALWAYS } from "@domain/moddbVi
import { DEFAULT_RECEIVE_BETA_UPDATES } from "@domain/appUpdate/betaUpdates"
import { DEFAULT_ALLOW_BASIC_SESSION_STORE } from "@domain/account/sessionStorage"
import { DEFAULT_MEASURE_PLAY_SESSIONS } from "@domain/sessions/sampling"
+import { MAX_DISMISSED_MOD_SUGGESTIONS } from "@domain/mods/suggestions"
import { CONFIG_ACTIONS, configReducer, initialState, type ConfigAction } from "../../src/renderer/src/features/config/contexts/configReducer"
@@ -39,6 +40,8 @@ function baseConfig(overrides: Partial = {}): ConfigType {
background: DEFAULT_BACKGROUND_ID,
accentColor: DEFAULT_ACCENT_ID,
moddbVisibility: defaultModDbVisibility(),
+ modSuggestionsConsent: null,
+ dismissedModSuggestions: [],
receiveBetaUpdates: DEFAULT_RECEIVE_BETA_UPDATES,
measurePlaySessions: DEFAULT_MEASURE_PLAY_SESSIONS,
allowBasicSessionStore: DEFAULT_ALLOW_BASIC_SESSION_STORE,
@@ -105,6 +108,33 @@ describe("configReducer: SET_CONFIG", () => {
})
})
+describe("configReducer: Mod suggestions", () => {
+ it("stores independent consent without touching the ModDB visibility answer", () => {
+ const config = baseConfig()
+ const result = configReducer(config, { type: CONFIG_ACTIONS.SET_MOD_SUGGESTIONS_CONSENT, payload: true })
+
+ assert.equal(result.modSuggestionsConsent, true)
+ assert.deepEqual(result.moddbVisibility, config.moddbVisibility)
+ })
+
+ it("deduplicates dismissed listing ids below the cap", () => {
+ const config = baseConfig({ dismissedModSuggestions: [12, 34] })
+ const duplicate = configReducer(config, { type: CONFIG_ACTIONS.ADD_DISMISSED_MOD_SUGGESTION, payload: { listingId: 12 } })
+ assert.deepEqual(duplicate.dismissedModSuggestions, [12, 34])
+ assert.equal(duplicate, config)
+ })
+
+ it("never exceeds the maximum dismissed listing ids cap and never evicts an older dismissal", () => {
+ const config = baseConfig({ dismissedModSuggestions: Array.from({ length: MAX_DISMISSED_MOD_SUGGESTIONS }, (_, index) => index + 1) })
+ const result = configReducer(config, { type: CONFIG_ACTIONS.ADD_DISMISSED_MOD_SUGGESTION, payload: { listingId: MAX_DISMISSED_MOD_SUGGESTIONS + 1 } })
+
+ assert.equal(result.dismissedModSuggestions.length, MAX_DISMISSED_MOD_SUGGESTIONS)
+ assert.equal(result.dismissedModSuggestions.includes(1), true)
+ assert.equal(result.dismissedModSuggestions.includes(MAX_DISMISSED_MOD_SUGGESTIONS + 1), false)
+ assert.equal(result, config)
+ })
+})
+
describe("configReducer: scalar setters", () => {
it("SET_LAST_USED_INSTALLATION accepts an id and null alike", () => {
const config = baseConfig()
diff --git a/tests/text-contrast.test.ts b/tests/text-contrast.test.ts
index e263076a..13bc84cd 100644
--- a/tests/text-contrast.test.ts
+++ b/tests/text-contrast.test.ts
@@ -383,6 +383,16 @@ describe("text over the player's background image", () => {
for (const notice of foregrounds("features/mods/components/ServerModsSection.tsx")) assertReadable("server Mods section notice", notice, PAGE, TEXT_FLOOR)
})
+ it("keeps the Mod suggestions heading and caption readable on the grid panel", () => {
+ const source = read("features/mods/components/ModSuggestions.tsx")
+ assert.ok(
+ source.indexOf("") < source.indexOf('className="relative mb-2 flex flex-wrap items-center justify-between gap-2 px-2"'),
+ "the suggestions heading should sit inside its grid panel and have relative positioning"
+ )
+ assertReadable("Mod suggestions heading", [ZINC["zinc-200"], 1], [shell, gridPanel], TEXT_FLOOR)
+ for (const text of foregrounds("features/mods/components/ModSuggestions.tsx")) assertReadable("Mod suggestions text", text, [shell, gridPanel], TEXT_FLOOR)
+ })
+
it("keeps a server group's text readable on the panel it does sit on", () => {
for (const text of foregrounds("features/mods/components/ServerModsGroup.tsx")) assertReadable("server Mods group text", text, LIST_PANEL, TEXT_FLOOR)
for (const text of foregrounds("features/mods/components/ServerModItem.tsx")) assertReadable("server Mod row text", text, LIST_PANEL, TEXT_FLOOR)