From df9db0c257a040ffd97390416b6f79614434a569 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sun, 27 Sep 2026 15:25:26 +0530 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20search=20the=20Pok=C3=A9dex=20with?= =?UTF-8?q?=20filters=20like=20spe>100=20and=20weak:ground?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search box now reads filters for base stats, type, matchups, generation, ability, height, weight and flags such as is:legendary or is:mega, and any filter can be negated with a leading minus. Words that are not filters still search names. The browse index gains abilities, height and weight so the filters run in the browser. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/dex/filters.test.ts | 149 +++++++++++++++ src/lib/dex/filters.ts | 349 ++++++++++++++++++++++++++++++++++++ src/lib/dex/list.test.ts | 3 + src/lib/dex/list.ts | 33 +++- 4 files changed, 526 insertions(+), 8 deletions(-) create mode 100644 src/lib/dex/filters.test.ts create mode 100644 src/lib/dex/filters.ts diff --git a/src/lib/dex/filters.test.ts b/src/lib/dex/filters.test.ts new file mode 100644 index 0000000..2437203 --- /dev/null +++ b/src/lib/dex/filters.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vite-plus/test"; +import { describeFilter, matchesFilters, parseSearch, removeToken, wantsForms } from "./filters.js"; +import { DEFAULT_QUERY, queryDex, type DexListEntry } from "./list.js"; + +function entry(partial: Partial & Pick): DexListEntry { + return { + slug: partial.name.toLowerCase(), + speciesId: partial.id, + isDefault: true, + types: ["normal"], + stats: [50, 50, 50, 50, 50, 50], + generation: 1, + height: 10, + weight: 100, + abilities: [], + legendary: false, + mythical: false, + baby: false, + final: true, + ...partial, + }; +} + +const garchomp = entry({ + id: 445, + name: "Garchomp", + types: ["dragon", "ground"], + stats: [108, 130, 95, 80, 85, 102], + generation: 4, + height: 19, + weight: 950, + abilities: ["sand-veil", "rough-skin"], +}); +const gengar = entry({ + id: 94, + name: "Gengar", + types: ["ghost", "poison"], + stats: [60, 65, 60, 130, 75, 110], + abilities: ["cursed-body"], +}); +const megaGengar = entry({ + id: 10038, + speciesId: 94, + name: "Mega Gengar", + form: "Mega", + isDefault: false, + types: ["ghost", "poison"], + stats: [60, 65, 80, 170, 95, 130], + abilities: ["shadow-tag"], +}); +const alolanRaichu = entry({ + id: 10100, + speciesId: 26, + name: "Alolan Raichu", + form: "Alolan", + isDefault: false, + types: ["electric", "psychic"], +}); +const dex = [gengar, megaGengar, alolanRaichu, garchomp]; + +const pick = (q: string) => dex.filter((e) => matchesFilters(e, parseSearch(q).filters)); + +describe("parseSearch", () => { + it("separates filters from name text", () => { + const search = parseSearch("spe>100 gar type:ghost"); + expect(search.text).toBe("gar"); + expect(search.filters.map((f) => f.kind)).toEqual(["measure", "type"]); + expect(search.invalid).toEqual([]); + }); + + it("reports filters it cannot read", () => { + expect(parseSearch("type:cake spe:fast is:shiny").invalid).toEqual([ + "type:cake", + "spe:fast", + "is:shiny", + ]); + }); + + it("keeps Type: Null searchable", () => { + expect(parseSearch("type: null").text).toBe("type: null"); + }); + + it("reads quoted ability names", () => { + const [filter] = parseSearch('ability:"rough skin"').filters; + expect(filter).toMatchObject({ kind: "ability", slug: "rough-skin" }); + }); +}); + +describe("matchesFilters", () => { + it("compares base stats and totals", () => { + expect(pick("spe>100")).toEqual([gengar, megaGengar, garchomp]); + expect(pick("spe>=110 spa<150")).toEqual([gengar]); + expect(pick("bst>=600")).toEqual([megaGengar, garchomp]); + }); + + it("compares height in metres and weight in kilograms", () => { + expect(pick("height>1.5 weight>=95")).toEqual([garchomp]); + }); + + it("filters by type matchups", () => { + expect(pick("weak:ice")).toEqual([garchomp]); + expect(pick("immune:normal")).toEqual([gengar, megaGengar]); + expect(pick("resist:fighting")).toEqual([alolanRaichu]); + }); + + it("filters by generation, flags and abilities", () => { + expect(pick("gen:sinnoh")).toEqual([garchomp]); + expect(pick("gen:1-3 is:regional")).toEqual([alolanRaichu]); + expect(pick("is:mega")).toEqual([megaGengar]); + expect(pick("ability:roughskin")).toEqual([garchomp]); + }); + + it("negates filters with a leading minus", () => { + expect(pick("-type:ghost")).toEqual([alolanRaichu, garchomp]); + expect(pick("!is:form")).toEqual([gengar, garchomp]); + }); +}); + +describe("queryDex with filters", () => { + it("hides forms for plain filters and shows them for form filters", () => { + const names = (q: string) => queryDex(dex, { ...DEFAULT_QUERY, q }).map((e) => e.name); + expect(names("type:ghost")).toEqual(["Gengar"]); + expect(names("is:mega")).toEqual(["Mega Gengar"]); + expect(names("gen type:ghost")).toEqual(["Gengar", "Mega Gengar"]); + expect(wantsForms(parseSearch("-is:mega").filters)).toBe(false); + }); +}); + +describe("describeFilter", () => { + it("writes filters in plain language", () => { + const describe = (q: string) => parseSearch(q).filters.map(describeFilter); + expect(describe("spe>100 -type:flying weak:ice")).toEqual([ + "Speed above 100", + "Not Flying type", + "Weak to Ice", + ]); + expect(describe("gen:1-3 is:final weight<=10")).toEqual([ + "Generations I–III", + "Fully evolved", + "Weight at most 10 kg", + ]); + }); +}); + +describe("removeToken", () => { + it("drops one filter and keeps the rest", () => { + expect(removeToken("char spe>100 type:fire", "spe>100")).toBe("char type:fire"); + }); +}); diff --git a/src/lib/dex/filters.ts b/src/lib/dex/filters.ts new file mode 100644 index 0000000..ebd35d1 --- /dev/null +++ b/src/lib/dex/filters.ts @@ -0,0 +1,349 @@ +import { effectiveness } from "$lib/pokemon/matchups.js"; +import { GENERATIONS } from "$lib/pokemon/generations.js"; +import { titleCase } from "$lib/pokemon/format.js"; +import { + STAT_KEYS, + STAT_LABELS, + isTypeName, + type StatKey, + type TypeName, +} from "$lib/pokemon/types.js"; +import type { DexListEntry } from "./list.js"; +import { normalize } from "./search.js"; + +export type Comparison = ">" | ">=" | "<" | "<=" | "="; + +export const IS_FLAGS = [ + "legendary", + "mythical", + "baby", + "final", + "mega", + "gmax", + "regional", + "form", + "mono", + "dual", +] as const; +export type IsFlag = (typeof IS_FLAGS)[number]; + +type Measure = StatKey | "total" | "height" | "weight"; + +type FilterBody = + | { kind: "measure"; measure: Measure; op: Comparison; value: number } + | { kind: "type"; type: TypeName } + | { kind: "weak" | "resist" | "immune"; type: TypeName } + | { kind: "gen"; from: number; to: number } + | { kind: "is"; flag: IsFlag } + | { kind: "ability"; slug: string }; + +/** A filter read from a search, with the token it came from. */ +export type Filter = FilterBody & { raw: string; negate: boolean }; + +export interface ParsedSearch { + /** Words that are not filters, matched against names and dex numbers. */ + text: string; + filters: Filter[]; + /** Tokens that look like filters but could not be read. */ + invalid: string[]; +} + +const MEASURES: Record = { + hp: "hp", + atk: "attack", + attack: "attack", + def: "defense", + defense: "defense", + spa: "special-attack", + spatk: "special-attack", + satk: "special-attack", + spd: "special-defense", + spdef: "special-defense", + sdef: "special-defense", + spe: "speed", + speed: "speed", + bst: "total", + total: "total", + height: "height", + ht: "height", + weight: "weight", + wt: "weight", +}; + +const KEYS: Record = { + type: "type", + t: "type", + weak: "weak", + w: "weak", + resist: "resist", + resists: "resist", + r: "resist", + immune: "immune", + gen: "gen", + g: "gen", + is: "is", + ability: "ability", + a: "ability", +}; + +const IS_ALIASES: Record = { + legendary: "legendary", + legend: "legendary", + mythical: "mythical", + mythic: "mythical", + baby: "baby", + final: "final", + fe: "final", + fullyevolved: "final", + mega: "mega", + gmax: "gmax", + gigantamax: "gmax", + regional: "regional", + form: "form", + mono: "mono", + single: "mono", + dual: "dual", +}; + +const MEASURE_PATTERN = /^([a-z]+):?(>=|<=|>|<|=)?(\d+(?:\.\d+)?)$/; + +/** Split on spaces, keeping quoted values such as ability:"water absorb" together. */ +function tokenize(q: string): string[] { + return q.match(/[^\s"]*"[^"]*"?[^\s"]*|\S+/g) ?? []; +} + +function parseGen(value: string): { from: number; to: number } | null { + const one = (v: string) => { + if (/^\d$/.test(v)) return Number(v); + const gen = GENERATIONS.find( + (g) => g.numeral.toLowerCase() === v || g.region.toLowerCase() === v, + ); + return gen?.id ?? NaN; + }; + const [a, b = a] = value.split("-"); + const from = one(a); + const to = one(b); + if (!(from >= 1 && to <= 9 && from <= to)) return null; + return { from, to }; +} + +function parseToken(token: string): FilterBody | null | undefined { + const lower = token.toLowerCase(); + + const measure = MEASURE_PATTERN.exec(lower); + if (measure && MEASURES[measure[1]]) { + return { + kind: "measure", + measure: MEASURES[measure[1]], + op: (measure[2] as Comparison | undefined) ?? "=", + value: Number(measure[3]), + }; + } + + const colon = lower.indexOf(":"); + if (colon < 1) return undefined; + const key = KEYS[lower.slice(0, colon)]; + const value = lower + .slice(colon + 1) + .replace(/"/g, "") + .trim(); + if (!key) return MEASURES[lower.slice(0, colon)] ? null : undefined; + // "Type: Null" is a Pokémon, so a key with nothing after it is plain text. + if (!value) return undefined; + + switch (key) { + case "type": + case "weak": + case "resist": + case "immune": + return isTypeName(value) ? { kind: key, type: value } : null; + case "gen": { + const range = parseGen(value); + return range && { kind: "gen", ...range }; + } + case "is": { + const flag = IS_ALIASES[normalize(value)]; + return flag ? { kind: "is", flag } : null; + } + case "ability": + return { kind: "ability", slug: value.replace(/[\s_]+/g, "-") }; + } +} + +/** + * Read filters out of a search, e.g. `spe>100 type:fire -is:legendary char`. + * Anything that is not a filter stays in `text` for the name search. + */ +export function parseSearch(q: string): ParsedSearch { + const words: string[] = []; + const filters: Filter[] = []; + const invalid: string[] = []; + for (const raw of tokenize(q)) { + const negate = /^[-!]./.test(raw); + const token = negate ? raw.slice(1) : raw; + const parsed = parseToken(token); + if (parsed === undefined) words.push(raw); + else if (parsed === null) invalid.push(raw); + else filters.push({ ...parsed, raw, negate }); + } + return { text: words.join(" "), filters, invalid }; +} + +function measureOf(entry: DexListEntry, measure: Measure): number { + if (measure === "total") return entry.stats.reduce((sum, s) => sum + s, 0); + // Height is stored in decimetres and weight in hectograms; filters use metres and kilograms. + if (measure === "height") return entry.height / 10; + if (measure === "weight") return entry.weight / 10; + return entry.stats[STAT_KEYS.indexOf(measure)]; +} + +function compare(a: number, op: Comparison, b: number): boolean { + if (op === ">") return a > b; + if (op === ">=") return a >= b; + if (op === "<") return a < b; + if (op === "<=") return a <= b; + return a === b; +} + +const REGIONAL = /^(Alolan|Galarian|Hisuian|Paldean)\b/; + +function hasFlag(entry: DexListEntry, flag: IsFlag): boolean { + switch (flag) { + case "legendary": + case "mythical": + case "baby": + case "final": + return entry[flag]; + case "mega": + return /^(Mega|Primal)\b/.test(entry.form ?? ""); + case "gmax": + return entry.form === "Gigantamax"; + case "regional": + return REGIONAL.test(entry.form ?? ""); + case "form": + return !entry.isDefault; + case "mono": + return entry.types.length === 1; + case "dual": + return entry.types.length === 2; + } +} + +function test(entry: DexListEntry, filter: Filter): boolean { + switch (filter.kind) { + case "measure": + return compare(measureOf(entry, filter.measure), filter.op, filter.value); + case "type": + return entry.types.includes(filter.type); + case "weak": + return effectiveness(filter.type, entry.types) > 1; + case "resist": { + const m = effectiveness(filter.type, entry.types); + return m > 0 && m < 1; + } + case "immune": + return effectiveness(filter.type, entry.types) === 0; + case "gen": + return entry.generation >= filter.from && entry.generation <= filter.to; + case "is": + return hasFlag(entry, filter.flag); + case "ability": + return entry.abilities.some((slug) => normalize(slug) === normalize(filter.slug)); + } +} + +export function matchesFilters(entry: DexListEntry, filters: readonly Filter[]): boolean { + return filters.every((filter) => test(entry, filter) !== filter.negate); +} + +/** Filters that only alternate forms can satisfy, so forms must be searched. */ +export function wantsForms(filters: readonly Filter[]): boolean { + return filters.some( + (f) => f.kind === "is" && !f.negate && ["mega", "gmax", "regional", "form"].includes(f.flag), + ); +} + +const OP_WORDS: Record = { + ">": "above", + ">=": "at least", + "<": "below", + "<=": "at most", + "=": "exactly", +}; + +const FLAG_LABELS: Record = { + legendary: "Legendary", + mythical: "Mythical", + baby: "Baby", + final: "Fully evolved", + mega: "Mega Evolution", + gmax: "Gigantamax", + regional: "Regional form", + form: "Alternate form", + mono: "Single type", + dual: "Dual type", +}; + +function measureLabel(measure: Measure): string { + if (measure === "total") return "Base stat total"; + if (measure === "height") return "Height"; + if (measure === "weight") return "Weight"; + return STAT_LABELS[measure].long; +} + +function numeral(gen: number): string { + return GENERATIONS[gen - 1]?.numeral ?? String(gen); +} + +/** Plain-language label for a filter chip, e.g. "Speed above 100". */ +export function describeFilter(filter: Filter): string { + const not = filter.negate; + switch (filter.kind) { + case "measure": { + const unit = filter.measure === "height" ? " m" : filter.measure === "weight" ? " kg" : ""; + const label = `${measureLabel(filter.measure)} ${OP_WORDS[filter.op]} ${filter.value}${unit}`; + return not ? `Not ${label[0].toLowerCase()}${label.slice(1)}` : label; + } + case "type": + return `${not ? "Not " : ""}${titleCase(filter.type)} type`; + case "weak": + return `${not ? "Not weak" : "Weak"} to ${titleCase(filter.type)}`; + case "resist": + return `${not ? "Doesn’t resist" : "Resists"} ${titleCase(filter.type)}`; + case "immune": + return `${not ? "Not immune" : "Immune"} to ${titleCase(filter.type)}`; + case "gen": { + const range = + filter.from === filter.to + ? `Generation ${numeral(filter.from)}` + : `Generations ${numeral(filter.from)}–${numeral(filter.to)}`; + return not ? `Not ${range.replace("G", "g")}` : range; + } + case "is": + return not ? `Not ${FLAG_LABELS[filter.flag].toLowerCase()}` : FLAG_LABELS[filter.flag]; + case "ability": + return `${not ? "Without" : "Has"} ${titleCase(filter.slug)}`; + } +} + +/** Remove one token from a search string, leaving the rest as typed. */ +export function removeToken(q: string, raw: string): string { + const tokens = tokenize(q); + const index = tokens.indexOf(raw); + if (index >= 0) tokens.splice(index, 1); + return tokens.join(" "); +} + +export const SEARCH_EXAMPLES = [ + { query: "spe>100", description: "Base stats: hp, atk, def, spa, spd, spe and bst" }, + { query: "type:fire", description: "Type; add a second for dual types" }, + { query: "weak:ground", description: "Takes super-effective damage; also resists: and immune:" }, + { query: "gen:1-3", description: "Generations by number, numeral or region" }, + { query: "ability:levitate", description: "Has an ability, hidden abilities included" }, + { + query: "is:legendary", + description: `Also ${IS_FLAGS.filter((f) => f !== "legendary").join(", ")}`, + }, + { query: "height>2", description: "Height in metres or weight in kilograms" }, + { query: "-type:flying", description: "Put - before any filter to exclude it" }, +] as const; diff --git a/src/lib/dex/list.test.ts b/src/lib/dex/list.test.ts index 77522a4..b20bc69 100644 --- a/src/lib/dex/list.test.ts +++ b/src/lib/dex/list.test.ts @@ -11,6 +11,9 @@ function entry(partial: Partial & Pick; +> & { + /** Ability slugs, hidden ability included. */ + abilities: string[]; +}; export function toListEntry(entry: DexEntry): DexListEntry { const { id, slug, name, speciesId, form, isDefault, types, stats, generation } = entry; - const { legendary, mythical, baby, final } = entry; + const { height, weight, legendary, mythical, baby, final } = entry; return { id, slug, @@ -33,6 +39,9 @@ export function toListEntry(entry: DexEntry): DexListEntry { types, stats, generation, + height, + weight, + abilities: entry.abilities.map((a) => a.slug), legendary, mythical, baby, @@ -128,24 +137,32 @@ function compare(a: DexListEntry, b: DexListEntry, sort: SortKey, desc: boolean) return a.speciesId - b.speciesId || a.id - b.id; } -/** Filter, search and sort entries. Search relevance wins over the chosen sort. */ +/** + * Filter, search and sort entries. The search text may carry filters such as `spe>100`; + * name relevance wins over the chosen sort. + */ export function queryDex(entries: readonly DexListEntry[], query: DexQuery): DexListEntry[] { - const dexNumber = parseDexNumber(query.q); + const search = parseSearch(query.q); + const text = search.text.trim(); + const dexNumber = parseDexNumber(text); + // Alternate forms surface for name searches and form filters even when forms are hidden. + const forms = query.forms || Boolean(text) || wantsForms(search.filters); const scored: { entry: DexListEntry; score: number }[] = []; for (const entry of entries) { - if (!query.forms && !entry.isDefault && !query.q) continue; + if (!forms && !entry.isDefault) continue; if (query.types.some((t) => !entry.types.includes(t))) continue; if (query.gens.length && !query.gens.includes(entry.generation)) continue; if (query.tags.length && !query.tags.every((tag) => entry[tag])) continue; + if (!matchesFilters(entry, search.filters)) continue; let score = 0; if (dexNumber !== null) { if (entry.speciesId !== dexNumber) continue; - } else if (query.q) { - const match = matchScore(query.q, entry.name); + } else if (text) { + const match = matchScore(text, entry.name); if (match === null) continue; - // Alternate forms only surface for searches when forms are hidden, and rank after defaults. + // With forms hidden, forms found by name rank after default forms. score = match * 2 + (entry.isDefault ? 0 : 1); } scored.push({ entry, score }); From 8097bbc7e171b017d96ffc07899a8bb4ed2bd167 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sun, 27 Sep 2026 15:25:26 +0530 Subject: [PATCH 2/3] feat: show search filters as removable chips with search tips Each filter reads back in plain language ("Speed above 100") so it is clear what the search understood, and tokens it can't read are flagged. Search tips list the syntax; picking an example adds it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/components/dex/search-filters.svelte | 39 ++++++++++++++++++++ src/lib/components/dex/search-tips.svelte | 39 ++++++++++++++++++++ src/routes/+page.svelte | 12 +++++- 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/dex/search-filters.svelte create mode 100644 src/lib/components/dex/search-tips.svelte diff --git a/src/lib/components/dex/search-filters.svelte b/src/lib/components/dex/search-filters.svelte new file mode 100644 index 0000000..66c29bf --- /dev/null +++ b/src/lib/components/dex/search-filters.svelte @@ -0,0 +1,39 @@ + + +{#if search.filters.length || search.invalid.length} +
    + {#each search.filters as filter (filter.raw)} +
  • + {describeFilter(filter)} + +
  • + {/each} + {#each search.invalid as raw (raw)} +
  • + Can’t read {raw} + +
  • + {/each} +
+{/if} diff --git a/src/lib/components/dex/search-tips.svelte b/src/lib/components/dex/search-tips.svelte new file mode 100644 index 0000000..5d43733 --- /dev/null +++ b/src/lib/components/dex/search-tips.svelte @@ -0,0 +1,39 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + +

+ Mix filters with a name. Every filter must match. Pick one to add it. +

+
    + {#each SEARCH_EXAMPLES as example (example.query)} +
  • + +
  • + {/each} +
+
+
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f0caa2d..47ecc68 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -10,6 +10,8 @@ import DexTile from "$lib/components/dex/dex-tile.svelte"; import DexWall from "$lib/components/dex/dex-wall.svelte"; import FilterControls from "$lib/components/dex/filter-controls.svelte"; + import SearchFilters from "$lib/components/dex/search-filters.svelte"; + import SearchTips from "$lib/components/dex/search-tips.svelte"; import TypeFilter from "$lib/components/dex/type-filter.svelte"; import { Button } from "$lib/components/ui/button/index.js"; import * as Empty from "$lib/components/ui/empty/index.js"; @@ -122,7 +124,7 @@ type="search" value={query.q} oninput={(event) => update({ q: event.currentTarget.value })} - placeholder="Search by name or number" + placeholder="Search by name, number or filters like spe>100 type:fire" aria-label="Search Pokémon" autocomplete="off" spellcheck={false} @@ -139,8 +141,14 @@ {/if} +
+
+ update({ q })} /> +
+ update({ q: `${query.q.trim()} ${example}`.trim() })} /> +
-
+

{#if matches} From 179beaa87ac50706084a59f1dc09e63bc2249fdc Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sun, 27 Sep 2026 15:25:26 +0530 Subject: [PATCH 3/3] feat: jump from the command palette to every filter match Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/components/site/command-palette.svelte | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/lib/components/site/command-palette.svelte b/src/lib/components/site/command-palette.svelte index 0c31f94..121829d 100644 --- a/src/lib/components/site/command-palette.svelte +++ b/src/lib/components/site/command-palette.svelte @@ -1,10 +1,12 @@