diff --git a/src/components/Discover/DiscoverBrowser.tsx b/src/components/Discover/DiscoverBrowser.tsx index b03a63d..6f4f2fe 100644 --- a/src/components/Discover/DiscoverBrowser.tsx +++ b/src/components/Discover/DiscoverBrowser.tsx @@ -33,7 +33,7 @@ import { AI_AREAS_DISCLOSURE, } from "./DiscoverFilters"; import { baseGameLabel, baseRoms } from "@/data/baseRoms"; -import { aiFilterLabel, aiPresetOf, matchesAiFilter, NO_AI_FILTER, type AiFilter } from "@/utils/aiDisclosure"; +import { aiFilterActive, aiFilterLabel, aiPresetOf, matchesAiFilter, NO_AI_FILTER, type AiFilter } from "@/utils/aiDisclosure"; const SORT_OPTIONS: SelectOption[] = [ { value: "trending", label: "Trending", icon: MdWhatshot }, @@ -354,7 +354,7 @@ export default function DiscoverBrowser({ catalog, generatedAt, initialState, ta const railRef = React.useRef(null); // The AI note under the results: phones open the sheet at "AI use", desktop points at it in the rail. const showAiFilter = () => (window.matchMedia("(min-width: 768px)").matches ? flashAiGroup(railRef.current) : openSheet(true)); - const aiNote = ai.hide.length > 0 && ( + const aiNote = aiFilterActive(ai) && ( )} {/* Clear keeps the saved AI filter, so offer it here when it may be what's hiding everything. */} - {ai.hide.length > 0 && ( + {aiFilterActive(ai) && ( diff --git a/src/components/Discover/DiscoverFilters.tsx b/src/components/Discover/DiscoverFilters.tsx index 630e635..d25a302 100644 --- a/src/components/Discover/DiscoverFilters.tsx +++ b/src/components/Discover/DiscoverFilters.tsx @@ -9,7 +9,7 @@ import { baseGameLabel, baseRoms, PLATFORM_NAMES, type Platform } from "@/data/b const RAIL_PLATFORMS: Platform[] = ["GBA", "GBC", "GB", "NDS"]; import { DISCOVER_COMPLETION_STATUSES } from "@/app/discover/search-params"; import type { DiscoverHack } from "@/types/discover"; -import { AI_AREAS, AI_PRESETS, aiPresetOf, matchesAiFilter, normalizeAiFilter, NO_AI_FILTER, type AiArea, type AiFilter } from "@/utils/aiDisclosure"; +import { AI_AREAS, AI_PRESETS, aiFilterActive, aiPresetOf, matchesAiFilter, normalizeAiFilter, NO_AI_FILTER, type AiArea, type AiFilter } from "@/utils/aiDisclosure"; import { useCloseOnDesktop, useDialog } from "@/hooks/useDialog"; /** The filterable part of the Discover URL state. The sheet edits a draft copy of this. */ @@ -61,7 +61,7 @@ export function countClearable(f: FilterState) { /** Everything narrowing the results, for the phone's Filters badge. */ export function countActive(f: FilterState) { - return countClearable(f) + (f.ai.hide.length > 0 ? 1 : 0); + return countClearable(f) + (aiFilterActive(f.ai) ? 1 : 0); } export function toggleValue(list: readonly T[], value: T) { @@ -122,8 +122,9 @@ export function FilterFields({ value, onChange, tagGroups, counts, readyCount, o const aiPreset = aiPresetOf(value.ai); const setAi = (ai: AiFilter) => onChange({ ...value, ai: normalizeAiFilter(ai) }); // Ticking code yourself allows small use by default; only the presets are strict about it. + // Ticking an area leaves Minor, since it's a limit across all areas rather than a pick of them. const toggleAiArea = (k: AiArea) => - setAi({ hide: toggleValue(value.ai.hide, k), smallCode: k === "code" ? !value.ai.hide.includes("code") : value.ai.smallCode }); + setAi({ hide: toggleValue(value.ai.hide, k), smallCode: k === "code" ? !value.ai.hide.includes("code") : value.ai.smallCode, minor: false }); const visibleGames = ROM_GAMES.map((g) => ({ ...g, dumps: g.dumps.filter((d) => !romQuery || g.label.toLowerCase().includes(romQuery) || d.label.toLowerCase().includes(romQuery)) })).filter((g) => g.dumps.length > 0); const visibleGroups = tagGroups @@ -240,13 +241,14 @@ export function FilterFields({ value, onChange, tagGroups, counts, readyCount, o 0 ? `${value.ai.hide.length} hidden` : ""} + detail={value.ai.minor ? "Limit on all areas" : value.ai.hide.length > 0 ? `${value.ai.hide.length} hidden` : ""} expanded={open.has(AI_AREAS_DISCLOSURE)} onToggle={() => onToggleOpen(AI_AREAS_DISCLOSURE)} tall={tall} > {/* Indented to line up with the "Choose areas" label, past its chevron. */}
+ {value.ai.minor &&

Minor AI usage sets a limit across all areas. Pick areas to build your own filter instead.

}

Hide hacks with AI in

{AI_AREAS.map((a) => ( diff --git a/src/components/Discover/savedAiFilter.ts b/src/components/Discover/savedAiFilter.ts index 78c7f21..35dfe4e 100644 --- a/src/components/Discover/savedAiFilter.ts +++ b/src/components/Discover/savedAiFilter.ts @@ -1,4 +1,4 @@ -import { aiFilterToken, NO_AI_FILTER, parseAiFilter, type AiFilter } from "@/utils/aiDisclosure"; +import { aiFilterActive, aiFilterToken, NO_AI_FILTER, parseAiFilter, type AiFilter } from "@/utils/aiDisclosure"; const KEY = "hackdex:discover-ai"; @@ -16,7 +16,7 @@ export function readSavedAiFilter(): AiFilter { /** "Any" clears it. */ export function saveAiFilter(filter: AiFilter) { try { - if (filter.hide.length > 0) localStorage.setItem(KEY, aiFilterToken(filter)); + if (aiFilterActive(filter)) localStorage.setItem(KEY, aiFilterToken(filter)); else localStorage.removeItem(KEY); } catch {} } diff --git a/src/utils/aiDisclosure.ts b/src/utils/aiDisclosure.ts index 942f3a4..fd68117 100644 --- a/src/utils/aiDisclosure.ts +++ b/src/utils/aiDisclosure.ts @@ -72,35 +72,51 @@ export function aiKind(levels: AiLevels): AiKind { /** * Discover's AI filter: hide hacks whose label shows AI in any of `hide`. With `smallCode`, code at - * "A little" (a bug fix or a few) still passes. Hacks without a label pass every filter; most use no - * AI, and reports catch the rest. + * "A little" (a bug fix or a few) still passes. `minor` is a separate mode that ignores `hide`: AI in + * at most MINOR_MAX_AREAS areas, none above Some. Hacks without a label pass every filter; most use + * no AI, and reports catch the rest. */ export interface AiFilter { hide: readonly AiArea[]; smallCode: boolean; + minor: boolean; } +const MINOR_MAX_AREAS = 2; + const CONTENT_AREAS = AI_AREAS.map((a) => a.key).filter((k) => k !== "code"); /** Named filters, shown as radios; any other pick of areas is "Custom". */ export const AI_PRESETS = [ - { value: "any", label: "Any", filter: { hide: [], smallCode: false } }, - { value: "no-content", label: "No AI content", filter: { hide: CONTENT_AREAS, smallCode: false } }, + { value: "any", label: "Any", filter: { hide: [], smallCode: false, minor: false } }, + { + value: "minor", + label: "Minor AI usage", + hint: `Up to ${MINOR_MAX_AREAS} areas, none above Some`, + filter: { hide: [], smallCode: false, minor: true }, + }, + { value: "no-content", label: "No AI content", filter: { hide: CONTENT_AREAS, smallCode: false, minor: false } }, { value: "none", label: "No direct AI usage", hint: "Nothing AI-generated was intentionally added by the creator", - filter: { hide: AI_AREAS.map((a) => a.key), smallCode: false }, + filter: { hide: AI_AREAS.map((a) => a.key), smallCode: false, minor: false }, }, ] as const satisfies readonly { value: string; label: string; hint?: string; filter: AiFilter }[]; export type AiPreset = (typeof AI_PRESETS)[number]["value"]; export const NO_AI_FILTER: AiFilter = AI_PRESETS[0].filter; -/** Areas in label order, and smallCode only while code is hidden, so equal filters serialize the same. */ +/** Areas in label order, smallCode only while code is hidden, and no areas with minor, so equal filters serialize the same. */ export function normalizeAiFilter(f: AiFilter): AiFilter { + if (f.minor) return { hide: [], smallCode: false, minor: true }; const hide = AI_AREAS.map((a) => a.key).filter((k) => f.hide.includes(k)); - return { hide, smallCode: f.smallCode && hide.includes("code") }; + return { hide, smallCode: f.smallCode && hide.includes("code"), minor: false }; +} + +/** Whether the filter hides anything; "Any" doesn't. */ +export function aiFilterActive(f: AiFilter) { + return f.minor || f.hide.length > 0; } /** Stored value (Discover saves the pick): a preset's name, or the areas joined by "." ("code-small" for code with small use allowed). */ @@ -112,7 +128,7 @@ export function aiFilterToken(f: AiFilter): string { } function sameAiFilter(a: AiFilter, b: AiFilter) { - return a.smallCode === b.smallCode && a.hide.length === b.hide.length && a.hide.every((k, i) => k === b.hide[i]); + return a.minor === b.minor && a.smallCode === b.smallCode && a.hide.length === b.hide.length && a.hide.every((k, i) => k === b.hide[i]); } export function parseAiFilter(token: string | undefined): AiFilter { @@ -120,7 +136,7 @@ export function parseAiFilter(token: string | undefined): AiFilter { if (preset || !token) return preset?.filter ?? NO_AI_FILTER; const parts = token.split("."); const hide = AI_AREAS.map((a) => a.key).filter((k) => parts.includes(k) || (k === "code" && parts.includes("code-small"))); - return normalizeAiFilter({ hide, smallCode: parts.includes("code-small") }); + return normalizeAiFilter({ hide, smallCode: parts.includes("code-small"), minor: false }); } /** The preset a filter matches, or "custom". */ @@ -137,6 +153,10 @@ export function aiFilterLabel(f: AiFilter): string { export function matchesAiFilter(levels: AiLevels | null, f: AiFilter) { if (!levels) return true; + if (f.minor) { + const used = AI_AREAS.filter((a) => levels[a.key] !== "none"); + return used.length <= MINOR_MAX_AREAS && used.every((a) => AI_LEVEL_STEP[levels[a.key]] <= AI_LEVEL_STEP.some); + } return f.hide.every((k) => levels[k] === "none" || (k === "code" && f.smallCode && levels.code === "little")); }