Non-obvious choices made while executing the native Android migration plan
(docs/plan/), and why. Add an entry here whenever a phase requires a
judgment call the plan didn't already settle. One section per phase.
minSdkstays 24, not Hall of Memories' 26. The Capacitor build shippedminSdk 24and nothing in this app needsjava.time(the only reason Hall of Memories requires 26 without desugaring). Raising it would drop real API 24–25 devices for no benefit. If a later phase genuinely needsjava.time, the right fix is raisingminSdkthen, with a changelog note — not adding desugaring now against a need that doesn't exist yet.versionCodestarts at 2,versionNameat "2.0.0". The Capacitor build shippedversionCode 1/versionName "1.0"; the native app must be installable over it, soversionCodehas to increase. The major version bump (1.x→2.0.0) is honest: this release drops the web app entirely and does not carry user data forward (see the next entry).- No data migration from the Capacitor build — decided, not deferred.
The old app persisted
teamdex_userdatain@capacitor/preferences(native Android SharedPreferences under a Capacitor-specific key/format). Reading and converting that format into Room was considered and rejected in the planning session (seedocs/plan/native-spec.md, "Storage"): the user explicitly chose a clean break over the added complexity and risk of a one-shot migration path that only ever runs once per device and is otherwise dead code forever after. Consequence: anyone upgrading in place loses their saved teams and custom roster silently unless warned first. Phase 6's release notes must lead with this, in plain language, with the concrete mitigation (export each team to Showdown format from the old app before updating) — not buried under feature bullets. - The launcher icon is a verbatim copy of the Capacitor build's mipmap
set, not a re-laid-out vector. The phase file's literal instruction was
to "recover the foreground drawable and re-lay it out as
mipmap-anydpi-v26/ic_launcher.xml+ the density buckets" (implicitly assuming a vector source, as in Hall of Memories). Inspection showed the Capacitor asset is already a complete, correct Android launcher icon set — rasteric_launcher_foreground.png/ic_launcher_background.pngper density (mdpi through xxxhdpi, 108dp), a legacy rasteric_launcher.png/ic_launcher_round.pngfallback per density for pre-API-26 devices (needed here sinceminSdkis 24, unlike Hall of Memories' 26), andmipmap-anydpi-v26/ic_launcher{,_round}.xmlusing an inset foreground. Copying it unchanged is strictly more faithful to "the app's visual identity does not change in this rewrite" than reconstructing it as a hand-authored vector would have been, and it already handles the pre-26 fallback the vector approach doesn't../gradlew lintDebugconfirms it's a valid, complete adaptive icon (only a benign "missing monochrome tag" advisory, an Android-13+ nicety absent from the original Capacitor asset too — not a regression, not fixed here). - Settings' three-way language picker (System/Italian/English) has no
PWA equivalent.
legacy-web's i18next-based switcher (src/components/Settings/SettingsPage.tsx) exposes only two bare buttons,EN/IT, no spelled-out names, and no "follow the system locale" option — i18next has no concept of tracking the OS locale automatically the wayAppCompatDelegate.setApplicationLocales(empty)does. Since Rule 4 ("port the text, do not reinvent it") has no PWA wording to port for this native-only feature, the three option labels ("Predefinito sistema"/"System default", "Italiano"/"Italian", "Inglese"/"English") are ported from Hall of Memories' Settings screen instead, which already solved the identical UI problem. - The theme picker's three labels ARE ported from
legacy-web, unlike the language picker above —settings.systemDefault/light/darkalready exist inlegacy-web/src/i18n/locales/{it,en}.jsonfor exactly this picker (SettingsPage.tsx's theme<select>), so those exact strings were used rather than Hall of Memories' shorter "Sistema"/"Tema". - Theme colour roles are seeded from the brand purple
#5B21B6, not a new palette. That hex is the Capacitor adaptive-icon background (android:iconsnpm script's--iconBackgroundColor) and the PWA's own accent color — it already sits almost exactly at Material 3's conventional "tone 40" lightness for a light-scheme primary color (42% lightness, measured), so it was used directly asprimaryrather than algorithmically adjusted.primaryContainer/onPrimaryContainerand the dark-scheme roles were derived by holding hue and saturation constant and shifting only lightness (a plain HSL adjustment, not a full Material color-utilities HCT computation — no such dependency is in the pinned catalogue and Phase 0 doesn't add one). Material You dynamic colour (API 31+) overrides all of this anyway; the manual scheme only matters on older devices and when the user has dynamic colour off. Destination.TeamDetail's route exists in the sealed interface but has nocomposable<>registered in theNavHostyet. Nothing navigates to it until Phase 2 gives Teams real CRUD and TeamDetail a screen to show; registering a route with no reachable screen would mean inventing a placeholder Phase 0 doesn't need. The type's shape is what needs to be stable for Phase 2, not its wiring.- Verified locally with a temporary Android SDK, not committed. The
sandboxed session had no
ANDROID_HOMEby default (matchingdocs/plan/README.md's documented default), butdl.google.comwas reachable, socmdline-tools+platform-36+build-toolswere installed to/tmp/android-sdk(outside the repo,.gitignored either way vialocal.properties) specifically to runassembleDebug,lintDebugandtestDebugUnitTestfor real rather than only trustingandroid-ci.ymlto catch a problem. All three were green. This is not an assumption future sessions can rely on — check$ANDROID_HOMEandcommand -v sdkmanagerperdocs/plan/README.mdbefore assuming a local build is possible, and fall back to CI if it isn't.
isDefaultFormwas added toPokemonEntry, mid-phase, once Room's schema (§2) turned out to need it (poke_species.isDefaultForm) for search ranking but the domain model didn't carry it yet. Sourced frompokemon.csv's already-parsedis_defaultcolumn. Keeping it on the domain model rather than only on the entity is what CLAUDE.md's architecture already commits to ("Room is the single source of truth... Pure logic lives indomain/") — an entity should never carry data the domain layer has nowhere to read from.- No
Converters.kt. Every field on every Phase 1 entity is a native Room type (String/Int/Boolean/Double) — a converter class with nothing to convert would be dead ceremony. Same judgment as Phase 0 skipping an emptydi/NetworkModule.kt— except this phase'sNetworkModule.ktended up with real content onceDatasetSource(next entry) needed a@Binds. PokeDataClientsits behind aDatasetSourceinterface. No mocking library is in the pinned catalogue, andDatasetSyncManager's tests (a failing fetch leaves the database untouched; a successful one writes every table; a fresh cache never touches the network at all) need to control what a fetch returns or whether it throws. A hand-written fake implementing a one-method interface does that without a new dependency;PokeDataClientis the only real implementation, bound indi/NetworkModule.kt.- The repository interface dropped
suspend fun sync(): Result<Unit>from the plan's original one-paragraph sketch, in favour of Hall of Memories' fire-and-forgetstartSyncIfNeeded()/forceResync()(both non-suspend, observed viasyncState: StateFlow<SyncState>). The awaitable version has a real race:startIfNeeded()/forceResync()launch onto a detached application-scoped coroutine, so a caller immediately awaitingstate.first { terminal }can observe a stale terminal value left over from a previous run before the newly-launched attempt has had a chance to flip the state toRunning. Hall of Memories' simpler shape has no such race because nothing needs to await a specific run's own outcome. Rule 2 ("Copy Hall of Memories, don't invent") applied against my own draft, not just the plan's — worth naming explicitly since the plan's sketch was mine to begin with. PokedexRepositoryexposes bothcacheStatusandsyncState, not just the onecacheStatusthe plan's sketch listed.CacheStatusis a snapshot of the persisted meta row (usable/synced-at/counts/revision); it has no notion of "a sync is running right now with this stage and progress". The Teams screen's non-blocking banner and Settings' inline progress bar both need that live signal, sosyncState(a direct pass- through ofDatasetSyncManager.state) was added as a second observable rather than folding a "current stage" field intoCacheStatusitself, which would conflate "what's saved" with "what's happening right now".- Search ranking goes beyond the plan's one-line sketch. The DAO's
LIKEqueries rank a prefix match on the normalized name ahead of a contains-only match, and within a tie a species' default form ahead of its alternate forms (searching "zygarde" surfaceszygarde-50beforezygarde-mega) — both proven against real ranking SQL inPokedexDaoTest, not just asserted as intent. This mirrors Hall of Memories'mergeSearchResults(prefix-then-contains), done as a singleCASE-ordered SQL query instead of two separate DAO calls merged in the repository, because the plan's own interface returnsFlow, not a one-shot suspend list — a two-query app-level merge would need tocombine()two live Flows on every emission, which is more complexity for the same result. java.text.SimpleDateFormat, notjava.time, formats the "last synced" timestamp in Settings → Data.minSdkstays 24 (docs/plan/native-spec.md, "Identity");java.timeneeds API 26 without desugaring, which is exactly the trade CLAUDE.md already said not to make without a real need.PokeCacheMetaEntity.syncedAtEpochMillisandSyncState.Success.syncedAtEpochMillisare both plainLong, notInstant, for the same reason.TypeBadgerenders the Scarlet/Violet type-icon sprite, matchinglegacy-web/src/components/TypeBadge/TypeBadge.tsxexactly, rather than Hall of Memories' coloured-pill-text convention. CoverDex's own PWA already made this call for its own UI; porting the visual language is more faithful than porting Hall of Memories' unrelated one.- The move-count string is split into manual
_one/_otherresources rather than a single%dtemplate — lint'sPluralsCandidatecheck flags either shape identically (confirmed against Hall of Memories' already-shippedstrings.xml, which has 22 identical findings from the same_one/_otherconvention and still lints clean); this is expected noise from a heuristic that doesn't understand the manual split, not a real problem worth suppressing or working around further. DebugSeederexists but has no call site yet. With nothing to seed until Phase 2's team/roster tables exist, wiring a call intoCoverDexApplication.onCreate()now would mean exercising Hilt's Application-field-injection path — untested here, unverifiable without a device — for a function that does nothing. Phase 2 wires it up alongside the actual seeding logic, where there's something real to check.
cd legacy-web && npm ci && npm test — 175/175 green, unaffected by
anything in this phase.
Every number in docs/plan/reference-pokedata.md §3 was re-verified
against the pinned commit's live files, both by raw awk/csv counting
and — going one step further than the plan's own instruction — by actually
running assembleDataset() against the full pinned CSVs (not the small
test fixtures) in a temporary, throwaway test deleted immediately after
recording its output here:
species (forms) = 1351 (matches: 1351 pokemon.csv rows)
distinct species = 1025 (matches: 1025 pokemon_species.csv rows)
moves accepted into cache = 919 (see "18 shadow moves" below)
finalEvolutions, species-level = 568 (matches)
legendary, species-level = 71 (matches)
mythical, species-level = 23 (matches)
type_efficacy cells = 324 (matches: 18 x 18)
Two things worth writing down so a future reader doesn't trip over them the way this session briefly did:
- Species-level counts vs. form-level counts are not the same number,
and both are correct.
isFinalEvolution/isLegendary/isMythicalare species-level facts (frompokemon_species.csv) applied to every form that species has. Counting them over the 1351 assembledPokemonEntryforms (rather than grouping byspeciesIdfirst) gives 840/120/38 instead of 568/71/23 — not a bug, just multi-form legendaries (Mega/regional/etc. forms) each contributing one row per form. Group byspeciesIdbefore comparing againstreference-pokedata.md's numbers, which are all species-level. - 18 of 937 moves never reach the cache — see
reference-pokedata.md's new "Consequence, verified during Phase 1 implementation" bullet for the full explanation (Pokémon Colosseum/XD's "Shadow" move set,type_id = 10002, outside the 18-type model). 919 is the correct count forPokedexRepository's move cache from this point on; do not "fix" a future test that expects 937.
MigrationTestHelper's exported schema JSONs must be wired in as adebugsource-set asset, not atestone — confirmed by measurement, not by trusting the widely-repeated advice. The officially documented pattern (Room's own migration guide, countless blog posts and Stack Overflow answers) is:for instrumented tests, and by extension people carry the samesourceSets { getByName("androidTest").assets.srcDirs("$projectDir/schemas") }sourceSets["test"].assets.srcDirs(...)(orresources.srcDirs(...)) over to a Robolectric-basedMigrationTestHelpertest undertestOptions.unitTests.isIncludeAndroidResources = true. Neither worked here (Migration1To2Test, added this phase, kept failing withFileNotFoundException: ... Missing file: com.marcogn.coverdex.data.local.CoverDexDatabase/1.json), so this was run to ground with real build artifacts rather than assumed to be a typo:MigrationTestHelper.loadSchema()(decompiled fromroom-testing-2.6.1-runtime.jar, since the class name obfuscation in the error message hides which API it actually calls) genuinely callsinstrumentation.context.assets.open("$assetsFolder/$version.json")— a realandroid.content.res.AssetManagerread, not a JVM classpath resource lookup, confirming Room's own (correct, if easy to misread) error wording.- Under Robolectric, that
AssetManageris a shadow backed by whatever directory AGP's generatedtest_config.propertiesnames asandroid_merged_assets— inspected directly atapp/build/intermediates/.../test_config.properties, which readsandroid_merged_assets=build/intermediates/assets/debug/mergeDebugAssets. That is the realdebugvariant's merged assets output. There is nodebugUnitTest-specific assets merge task in this AGP version (confirmed:./gradlew :app:help --task mergeDebugUnitTestAssetsreports the task does not exist) — so atest-source-set asset has nowhere to be merged into before Robolectric looks for it. This was verified empirically, not just reasoned about: pointingsourceSets["test"].assetsat the schemas directory leftmergeDebugAssets's output untouched, and the test still failed identically; adding the same directory tosourceSets["debug"].assetsinstead made the files appear atapp/build/intermediates/assets/debug/mergeDebugAssets/..., and the test passed. - The Hall of Memories sibling app was checked first, as the
architectural reference — it has no precedent to copy here. It has
never written a second schema version (
app/schemas/holds only1.json), so it has never neededMigrationTestHelperat all. - Net effect:
app/build.gradle.kts'sandroid { sourceSets { ... } }wiresgetByName("debug").assets.srcDirs("$projectDir/schemas"), accepting that the exported schema JSONs (a few KB, currently two files) ship inside debug-only APK builds. Release builds are unaffected — nothing here touches thereleasesource set.
MigrationTestHelper's two-argument, string-based constructor (MigrationTestHelper(instrumentation, assetsFolder: String, factory)) is deprecated as of Room 2.6.1 in favour of the class-based overload (MigrationTestHelper(instrumentation, databaseClass: KClass<T>, specs, factory)), which is required anyway once a future phase introduces an@AutoMigration.Migration1To2Testuses the class-based constructor from the start rather than deferring the fix.- A new custom move defaults to
type = NORMAL,damageClass = PHYSICAL, notdamageClass = STATUSasphase-2-teams-and-roster.md's own prose says ("becomesisCustom = true,damageClass = STATUS,power = null"). That line does not matchlegacy-web's actual, executable behaviour:MoveSlot.tsx's custom-moveonChangebuilds{ type: move?.type ?? 'normal', power: move?.power ?? null, damageClass: move?.damageClass ?? 'physical', isCustom: true }— for a brand-new custom move,moveisnull, so every one of those??fallbacks fires:'normal',null,'physical'. Perdocs/plan/README.md's own rule ("when the Kotlin and the TypeScript disagree ... the TypeScript is right unless this plan explicitly says otherwise" — and this is a paraphrase slip, not a stated intentional deviation), the code wins. Onlypower = nullfrom the plan's prose survives;typeanddamageClassfollowlegacy-webinstead. There is nowhere in the domain model to encode "no move yet" versus "a move with defaults" separately fromPokemonMoveitself, so this only matters at the moment a slot's move-picker text field goes from empty to non-empty with no cached match — precisely the caseMoveSlot.tsxhandles this way. - Team and roster entity mappers (
TeamMappers.kt) fall back to a default value on an unrecognizedtype/damageClassstring instead of returningnulland dropping the row, unlikePokedexRepositoryImpl's cache mappers (Mappers.kt), which do drop a bad cache row. The cache is re-downloadable — a corrupt row is just re-synced away — but a team or a roster entry is the user's own irreplaceable data, written exclusively by this app's own code (never parsed from an external, occasionally-bad source like PokéAPI's CSVs).parseType/parseDamageClassshould never actually hit their fallback branch in practice; the fallback exists so a future bug in the write path degrades a slot's display rather than silently deleting the user's team. Asserted directly inTeamMappersTest. TeamDao/CustomPokemonDaoget a real column-listUPDATEfor rename (TeamDao.renameTeam) and edit (CustomPokemonDao.updateFields), instead of reusing the generic@Update-on-the-whole-entity thatupsertTeam/upsertalready had. BothTeamEntity.position(list order) andCustomPokemonEntity.createdAtEpochMillis(roster sort order) must survive an edit unchanged, and the repository has no reason to read the existing row first just to copy those two columns forward — a column-listUPDATEthat never mentions them is simpler and cannot regress by a future caller forgetting to carry them over. Asserted directly:CustomPokemonDaoTest's "editing an entry's name does not reset its creation time".- The Teams screen's create/rename/delete wording has no
legacy-websource to port for three of its four strings.legacy-web/src/i18n/locales/hasteams.newTeam,teams.createEmptyandteams.delete, but: renaming is an inline double-click-to-edit text field in the PWA with no dialog and no "Rename" string anywhere (teams_rename_team_title,teams_action_rename,teams_team_name_labelare new keys, phrased to match the existingteams_delete_title/settings_data_clear_confirm_titlestyle);DeleteTeamModal.tsx's confirmation copy is hardcoded English, never run throught()— a real localization gap in the PWA, not something to carry forward — soteams_delete_messageis phrased fresh (matchingteams.confirmDelete's intent, plus an explicit "cannot be recovered" clause the waysettings_data_clear_confirm_messagealready does elsewhere in this app) rather than left English-only. This is the same class of gap asSearchableDropdown's "No matches"/"Clear selection" (see above). - The slot editor's species picker never offers the custom roster as a
search source —
legacy-web'sPokemonSlot.tsxhas an "Include saved custom Pokémon in search" checkbox that mergescustomsinto the dropdown's options;phase-2-teams-and-roster.md's own "Species picker" bullet describes onlyPokedexRepository.searchSpecies-backed search, with no mention of the roster at all. Treated as a deliberate scope cut by the plan (the bullet is fully specified, not an ambiguous paraphrase), not a bug to port — it also sidesteps a real modeling cost: the picker's option values would otherwise need to bePokemonEntry | TeamMember(as they literally are in the TypeScript), forcing a sealed wrapper type throughDropdownOption<T>. "Save as custom" (writing a slot into the roster) is unaffected and fully implemented; only the reverse direction (picking from the roster into a slot) is deferred. - The ability field is
EditableComboBox(ported from Hall of Memories), notSearchableDropdown, despitenative-spec.md's "Searchable dropdowns" section listing "ability" alongside species/move/generator-anchor as if all four shared one contract.legacy-web's actual ability picker,AbilityDropdown.tsx, is a different component fromSearchableDropdown.tsxentirely: it commits whatever is typed on every keystroke (onChange(val)) and only offers cached abilities as clickable shortcuts — never rejecting free text, matching this app's own product decision (abilities accept free text, for ROM-hack compatibility). That is exactly Hall of Memories'EditableComboBoxcontract, so it was copied from there (docs/plan/README.md, "Copy Hall of Memories, don't invent") rather than force-fitting the strict, pick-only-from-a-listSearchableDropdownonto a field the behavioural reference treats differently. PokemonType.displayName()andDamageClass.displayName()'s wording was verified, not assumed.legacy-webhas no full localized type names anywhere (itstypes.*i18n keys are three-letter abbreviations used for compact badges elsewhere;PokemonSlot.tsx's own type<select>renders the raw English slug unlocalized) and no localized damage-category names at all (<option value="physical">physical</option>, literal). All 18 Italian type names and the three damage-category names were looked up individually against Bulbapedia (an international, English-language wiki, cross-referenced to its Italian-wiki interlanguage links) rather than recalled from memory and asserted — see thePokemonType.displayName()/DamageClassDropdowncommits for the full list. "Fisica"/"Speciale"/"Stato" (rather than "Fisico") is a minor grammatical judgment call, not a sourced fact: the feminine adjective form agreeing with "mossa" ("move"), matching Bulbapedia's own "Mossa speciale" phrasing, kept consistent across all three labels.- The slot editor's own copy (species placeholder, "Empty slot", "Type
1"/"Type 2"/"None", "Save as custom"/"Clear slot", the move picker's
placeholders) has no
legacy-webi18n coverage at all —PokemonSlot.tsxandMoveSlot.tsxhardcode every one of these in English with not()call. Onlyslot.ability("Abilità"/"Ability") andteamDetail.pokemonTab/analysisTab/enableMoveshad a real source to port; everything else underslot_*/team_detail_*is freshly phrased to match this app's existing wording style, the same treatment already given toSearchableDropdownand the Teams screen's dialogs above. - Creating a team asks for a name up front, unlike
legacy-web'screateEmptyTeam, which auto-names itTeam ${teams.length + 1}with no prompt at all. This is not a paraphrase disagreement (contrast theMoveSlotdamageClassentry above) —phase-2-teams-and-roster.md's own "Screens" section explicitly specifies "Create (a name dialog)" as a native-only UX decision, so the plan's text is the authority here, notlegacy-web's code. Creating a team still immediately opens it afterward, matchingcreateEmptyTeam's own behaviour.
offensiveCoverageForMemberkeeps its explicituseMoves: Booleanparameter, even thoughphase-3-analysis.md§1's own pseudocode lists it asfun offensiveCoverageForMember(chart: TypeChart, m: TeamMember): Set<PokemonType>— nouseMovesargument at all. The realcoverageEngine.tssignature (offensiveCoverageForMember(chart, member, useMoves)) has one, every one of its own tests exercises bothtrueandfalseexplicitly, and Phase 4's suggestion engine depends on being able to forceuseMoves = falsefor a candidate regardless of what moves it happens to carry ("Candidates are evaluated by types only, always" — phase-4's §1 rule 7, andcoverageEngineTest.ts's own "custom Pokémon type-only evaluation" group, ported verbatim asCoverageEngineTest's last case). Dropping the parameter to match the plan's abbreviated pseudocode would silently break that. Treated as a pseudocode omission, not a signature change to follow.- The Pokémon tab's "Analyze team" button and "include custom Pokémon"
checkbox are deferred to Phase 4, not built alongside the Analysis
tab's seven sections. Both exist in
TeamDetailPage.tsx(next to the tab switcher, not insideCoverageGrid.tsx), but the button only switches to the Analysis tab — already possible by tapping the tab itself — and the checkbox togglesincludeCustomsAnalysis, a flag Phase 3's ownAnalysisUiStatecarries (perphase-3-analysis.md§2's explicit instruction to combine it now) but that affects nothing until Phase 4's suggestion computation exists. Shipping a checkbox with no observable effect would be actively confusing;AnalysisViewModelalready exposessetIncludeCustomsAnalysis/setGenerationFilterso Phase 4'sSuggestionFilters.ktonly needs to add UI, not ViewModel plumbing. MainDispatcherRuleis new — the first Robolectric test in this codebase (or Hall of Memories) to construct a realViewModeland collect itsStateFlowdirectly, rather than testing only the DAO/repository layer underneath it.viewModelScopedispatches ontoDispatchers.Main, which no JVM test provides by default; withoutDispatchers.setMain(...)(and passing that sameTestDispatchertorunTest),AnalysisViewModelTesthung indefinitely — confirmed by reproducing the "did not run to completion" failure before adding the rule, not assumed from the symptom.MainDispatcherRulelives at the test-source root (app/src/test/java/.../MainDispatcherRule.kt, no package suffix) so any futureViewModeltest can reuse it.analysis_pokemon_column_headerandanalysis_suggestions_placeholderhave nolegacy-websource.CoverageGrid.tsx's "Pokémon" column header is a hardcoded literal (<th>Pokémon</th>, not()); the Suggestions-section placeholder text has no equivalent at all, sincelegacy-webnever had a partial state where suggestions don't exist yet — the phase split is native-only. Same treatment as the other wording gaps recorded above. (analysis_suggestions_placeholderitself is gone as of Phase 4 — the real Suggestions section replaced it.)
- The generation filter is a real generation number (
Int?,null= "all"), not a portedGenerationFilterstring key. This is the plan's one intentional deviation (phase-4-suggestions-and-generator.md§2):suggestionEngine.ts's hardcodedGEN_RANGESid buckets put every alternate form with id > 10000 into "Generation 9" regardless of its real species.domain/suggestion/SuggestionEngine.ktfilters onPokemonEntry.generationIntroduceddirectly instead, soGEN_RANGEShas no Kotlin equivalent at all — there was nothing left to port once the id-bucket approach was dropped.TestFixtures.kt'smockPokemonList()proves the fix rather than just asserting it: "Spectraform" (id 9301, modelled on a >10000 alt-form id) is deliberately givengenerationIntroduced = 1, andSuggestionEngineTest'sgeneration filter uses the real generationIntroduced, not an id rangeasserts it now appears under the Generation I filter, where the old id-range scheme would have excluded it. computeSuggestionsitself never caps its result, matchingsuggestionEngine.tsexactly (confirmed by portinguseSuggestions.test.ts's own "top 10 candidates returned (not 5)" case, which documents that the engine dropped its cap; onlySuggestionPanel.tsxslices to 10 for display). This app's own UI spec (phase-4-suggestions-and-generator.md§4: "Up to five cards") caps at 5 instead of the web panel's 10 —AnalysisScreen.ktdoes the slicing (state.suggestions.take(5)), the same "engine returns everything, the screen decides how much to show" split as the TypeScript, just a different display count because this phase's own UI spec says so.Suggestion/TeamMemberdrop the TypeScript'sspriteUrlfield entirely, for bothmemberFromEntry's candidates andSuggestionitself. Every prior phase'sTeamMemberalready carriespokedexIdinstead of a stored sprite URL — sprite URLs are derived, never stored, from(speciesId, ...)viaSpriteUrlResolver/PokemonSprite(CLAUDE.md, "Product decisions already made");SuggestionCard.ktresolves a suggestion's sprite fromsuggestion.candidate.pokedexIdthe same way every other screen does, not from a field that would have had to be plumbed through and kept in sync for no reason.teamGenerator.ts'scustoms: TeamMember[]parameter is dropped frombuildEligiblePool,generateTeamandregenerateSlot, andGeneratorConstraints.customSlotsis kept as a ported struct field but is likewise never read by generator logic. ReadingteamGenerator.tsend to end showscustomsis never referenced in any of the three function bodies, andcustomSlotsnever gates anything either — an unfinished feature in the original (the UI still shows a "Custom Pokémon" counter when the roster is non-empty, but it does nothing). Dropping the dead parameter (not the struct field, which is still part of the portedGeneratorConstraintsshape the UI binds counters to) is a straightforward cleanup of something confirmed unused, not a behavioural change — seedocs/plan/README.md's "when the Kotlin and the TypeScript disagree ... the TypeScript is right" rule, which governs behaviour, not literal parameter-for-parameter shape.- The generator takes an injectable
kotlin.random.Random(defaultRandom.Default), perphase-4-suggestions-and-generator.md§3.TeamGeneratorTest's portedteamGenerator.test.ts"anchor composite score validation" case is the clearest payoff: the TypeScript runsgenerateTeamfive times with realMath.random()and accepts 4-of-5 passes because it has no way to control randomness. The Kotlin version asserts the same "no more than one additional Water type" property for every one of ten fixed seeds (Random(0)throughRandom(9)), a strictly stronger test of the same claim, run and confirmed green locally rather than assumed to hold. AnalysisViewModel.applySuggestionwrites through the same default- ability lookup the slot editor uses (pokedexRepository.speciesById(id) ?.defaultAbility,SlotEditorScreen.kt's own pattern for a freshly picked species) rather than leaving a newly added/replaced member with no ability at all.memberFromEntry(the engine's own candidate constructor, shared with the generator) deliberately leavesability = null— candidates are evaluated by types only, and an ability belongs to the applied member, not the abstract candidate — so this lookup happens once, at the point a suggestion is actually accepted into a team slot.- Surprise Me is one scrollable screen, not
SurpriseMeModal.tsx's three-step (seed/constraints/result) wizard. The phase's own UI section asks only for "an optional anchor picker ..., the constraint controls, a Generate button, per-slot regenerate, and Keep ... styled to match the rest of this app's Material 3 screens" and explicitly notes the web modal was never Android-restyled, so there was no native pattern to preserve continuity with. Every other destination in this app is a full screen, not a dialog/wizard; collapsing the three steps into one scrollable column (anchors → constraints → Generate → result) keeps that consistent rather than introducing the only multi-step wizard in the app. - "Keep" always creates a brand-new team, via
TeamRepository.createTeamfollowed by sixsaveMembercalls, rather than offering to overwrite an existing one — matchinguseAppShell.ts'shandleSurpriseCreate(the handlerApp.tsxwires toSurpriseMeModal'sonCreate), which also always appends a brand-newTeamtostate.teams; there is no "overwrite an existing team" path in the behavioural reference either. One real difference:handleSurpriseCreateauto-names the team (`Team ${state.teams.length + 1}`) with no prompt, while Keep here reusesTeamNameDialog(the same name-entry dialog Teams' own "create" flow uses) to ask first — consistent with Phase 2's own "creating a team asks for a name up front" decision (see above), which already treatsphase-2-teams-and-roster.md's explicit "a name dialog" instruction as the native-only authority overlegacy-web's auto-naming.
- The Showdown parser's resolver callbacks take the full
PokemonEntry/MoveEntry, not just a species' types or a move's fields, unlikeshowdownParser.ts'sresolveTypes: (name) => [PokemonType, PokemonType | null] | null. This app resolves a sprite fromTeamMember.pokedexId, never a stored URL (every prior phase'sTeamMemberalready carries that instead ofspriteUrl) — the id has to come back from resolution somehow, and threading it through a parallel second lookup (asImportExport.tsx's own caller does, via a secondpokemon.find()after parsing) would just be more code for the same result.PokedexRepositorygainedallMoves()(mirroringallSpecies()) instead of a per-movemoveByNamespecifically so the import screen can resolve every line in a pasted team from one bulk fetch, not a suspend call per move. - Import always creates a brand-new team, prompting for its name up
front — the same shape as Surprise Me's Keep (see above), and for the
same reason:
phase-2-teams-and-roster.md's "a name dialog" instruction is the native-only authority here, anduseAppShell.ts's ownhandleImportTeam(the live, reachable import path — wired fromApp.tsx) also always appends a newTeam, never overwrites one.legacy-web/src/components/ImportExport/ImportExport.tsx, which does write into a specific existing team's slots, is dead code — grepping the repo shows nothing renders it;useAppShell.ts'shandleImportTeamis the actual behavioural reference, not that component. - Export lives only on a team's overflow menu; Import lives only in
Settings — not both places for both actions, despite
phase-5-import-export-and-settings.md§2's "both in Settings and both reachable from a team's overflow menu" phrasing. Export needs a specific team to render (there is nothing to export from Settings without first picking one, and Teams' own overflow menu already exists for per-team actions); Import always produces a new team per the decision above, so it has no natural per-team entry point — attaching an "Import" action to an existing team's menu would misleadingly suggest it imports into that team. One entry point per action, at the place each one's own target (an existing team vs. a new one) actually lives. ThemePreferencesis renamed toSettingsPreferences. It now holdsincludeMegaDynamax/excludeLegendaries/includeCustomsAnalysisalongside the Phase 0/3 theme andshowMoveskeys, in the samesettings_prefsDataStore file as always (phase-5-import-export-and- settings.md§3: "one file, not five") — the old name stopped describing what the class actually holds partway through Phase 3 already.excludeLegendariesandincludeMegaDynamaxmoved out ofAnalysisViewModel's per-screen state intoSettingsPreferences, removing the "exclude legendaries" switchSuggestionFilters.ktshipped with in Phase 4. RereadingTeamDetailPage.tsx's props for this phase showed neither has anonChangecallback there — both are read-only, app-wide preferences flowing down fromAppSettings, unlikeincludeCustomsAnalysis, which does have one (see the next entry). The Phase 4 per-screen toggle was a reasonable interim shape beforeSettingsPreferencesexisted to back it; once it did, keeping a duplicate, ephemeral copy of a setting the reference implementation treats as global would just invite the two from drifting apart.includeCustomsAnalysisstays a toggle on the Analysis screen, but is now backed bySettingsPreferencestoo — reconciling two phase docs that describe it differently.phase-3-analysis.mdcalls it one of "the local toggles"AnalysisViewModelcombines;phase-5-import- export-and-settings.md§3's table lists it asSettingsPreferences.includeCustomsAnalysisunder the same "one DataStore file" rule as the other settings.TeamDetailPage.tsxitself splits the difference: it receivesincludeCustomsAnalysisas a prop with anonIncludeCustomsChangecallback, unlike the two read-only ones above — genuinely page-adjustable in the reference implementation, just not (pertypes/index.ts'sAppSettingsinterface, which has no such field at all) actually persisted there. Keeping the on-screen toggle satisfies Phase 3's description; persisting it satisfies Phase 5's; doing both is a strict improvement over the TypeScript, which forgets the choice the moment you leave the page.- The local backup has no
images/entries, unlike Hall of Memories' ownBackupArchive.kt, which this file otherwise copies wholesale (phase-5-import-export-and-settings.md§4: "Copy Hall of Memories'data/backup/wholesale"). CoverDex has nothing analogous to a screenshot or box art — every field on aTeamMember/Teamis already plain data — so theImageStorage-driven half of the reference implementation (BackupArchiveBuilder's referenced-file-name filtering,importPayload'sresolveImage) has nothing to port; the zip holds onlydata.json. - A restored custom roster entry's
createdAtEpochMillisis synthesized from its position in the backup, not carried over from the original row.CustomPokemonRepository.roster: Flow<List<TeamMember>>already discards that column when mappingCustomPokemonWithMovesto the domainTeamMember— it exists purely asobserveRoster()'sORDER BYkey, never a value any screen displays — soBackupPayload(built from the domain model, like Hall of Memories' own DTOs) never had it to preserve in the first place.BackupRepositoryImpl.importPayloadassigns each entryrestoreTimeBase + indexinstead, which reproduces the one thing the real timestamp was ever for: the roster's display order survives a restore intact, even though the literal historical creation instant does not — the actual invariant behind "timestamps preserved" (phase-5-import-export-and-settings.md§4), not a narrower literal reading of it that this app's own architecture can't satisfy for this one field. - Timestamps in the backup format are epoch-millis
Longs, not ISO strings (BackupPayload.exportedAtEpochMillis), matching every other timestamp already in this app (Team.createdAtEpochMillis,CacheStatus.syncedAtEpochMillis) rather than Hall of Memories'java.time.Instant.toString()—minSdkstays 24 (docs/plan/native-spec.md, "Identity") andjava.timeneeds API 26 without desugaring, the same reasonPokedexRepositoryImplalready usesjava.text.SimpleDateFormatinstead.
- The release keystore was not generated by this agent, and never will
be.
docs/release-signing.mddocuments thekeytool -genkeypaircommand and the five GitHub secrets the workflows need (RELEASE_KEYSTORE_BASE64,RELEASE_KEYSTORE_PASSWORD,RELEASE_KEY_ALIAS,RELEASE_KEY_PASSWORD,RELEASE_PUSH_TOKEN), but generating the actual key material and setting repository secrets on someone else's behalf is outside what an agent session can safely do — it has no durable, private place to hold a signing key between sessions, and no business writing secrets into a human's repository without them typing the values in themselves.app/build.gradle.kts'ssigningConfigs["release"](wired in Phase 0) already falls back to an unsigned build when the four keystore env vars are absent, so this is a deliberate, permanent stopping point for the agent's part of the work, not a missed step. build-apk.ymlandrelease.ymlwere copied from Hall of Memories and adapted, not designed from scratch — same secret names, same base64-decode-and-verify-integrity step, samesigningReportsanity check.release.ymladditionally cutsCHANGELOG.md's[Unreleased]section into a dated release section and bumpsversionCode/versionNameinapp/build.gradle.kts, pushing that bump back tomainwith aRELEASE_PUSH_TOKENPAT so the commit isn't blocked by branch protection on the workflow's own defaultGITHUB_TOKEN. Both files were validated withpython3 -c "import yaml; yaml.safe_load(...)"before being committed — this repo has no GitHub Actions runner available in-session to actually execute them.legacy-web/was deleted only after checking every item its ownCLAUDE.mdbullet named as needing a native equivalent: the coverage engine (coverageEngine.ts→domain/coverage/CoverageEngine.kt, 36 ported test cases), ability effects (abilityEffects.ts→domain/ability/AbilityEffects.kt, checked line-for-line — same eleven immunities, the same twothick-fat/fluffymultipliers, the samewonder-guardbadge note), the suggestion engine and its composite-score weights (suggestionEngine.ts→domain/suggestion/, the0.5/1.0weights confirmed byte-identical in both files), the team generator andSTARTER_FINALS(teamGenerator.ts→domain/generator/TeamGenerator.kt), the Showdown format (showdownParser.ts→domain/showdown/ ShowdownFormat.kt, 19 ported tests), and both locale files (i18n/locales/{it,en}.json, 142 flat keys each) againstres/values{,-en}/strings.xml(173 keys each, a superset — the extra keys are native-only screens with no PWA equivalent, e.g. the navigation drawer).docs/plan/itself was kept, deliberately, as the historical record of how the rewrite was built and reasoned about — nothing inCLAUDE.mdornative-spec.mdever asked for that to be deleted, onlylegacy-web/.README.md,ROADMAP.mdand.github/CONTRIBUTING.mdwere rewritten rather than patched. All three were still written for the PWA-and- native dual-release era of the project (npm commands, GitHub Pages, Capacitor); patching individual sentences would have left the overall framing wrong even with every literal inaccuracy fixed.CHANGELOG.md's[Unreleased]section was cut into a real## [2.0.0]release entry with its bullets reordered so the data-loss warning (saved teams do not carry over from the Capacitor build) appears third, immediately after the two purely positive "it's native now" bullets — stated plainly rather than softened, perdocs/plan/native-spec.md's own instruction that "Phase 6's release notes must lead with that warning."
Findings from docs/post-migration-review.md, a code-level audit run
after Phase 6 closed. Each finding below records the decision made when
fixing it; the review document itself has the full analysis, including
findings not yet acted on.
- Finding 1 (crash) — fixed as a pure bug fix, no decision needed.
regenerateSlot's ranking recomputed its composite score (including random tie-breaking noise) inside the sort comparator instead of once per candidate, which could throwIllegalArgumentExceptiononce the eligible pool was large. The production pool is in the high hundreds; every existing test pool was under 20 entries, which is why nothing caught it. Fixed by scoring once per candidate before sorting. - Finding 5 (Surprise Me's "Custom slots" stepper did nothing) —
implemented rather than removed.
teamGenerator.tsaccepted acustoms: TeamMember[]parameter it never referenced, and Phase 4's port dropped it as genuinely dead — but keptGeneratorConstraints.customSlotsas a ported struct field, so the Surprise Me screen shipped a stepper that consumed the six-slot budget and placed nothing. Decided to implement rather than delete the stepper: it is the one generator feature that serves this app's stated ROM-hack/draft-building audience (CLAUDE.md, "What this project is"), andSurpriseMeViewModelalready loaded the custom roster into its UI state without using it.generateTeamandregenerateSlotnow take acustoms: List<TeamMember> = emptyList()parameter (default keeps every pre-existing call site, including every test, unchanged) and treatcustomSlotsas a reserved category exactly like starter/ legendary-mythical/Mega/Dynamax — with one asymmetry, stated here because nothing forced it either way: a custom is never chosen opportunistically in an unconstrained "free" slot the way a catalogue Pokémon can be once its own quota is met, because customs live outsidebuildEligiblePool's catalogue-only pool. A custom appears only while its own reserved budget still has room. The alternative (customs competing for every free slot too) was rejected as surprising: a user who setscustomSlots = 0should never see a custom Pokémon appear. - Finding 2 (both engines ran on the main thread) — no injected test
dispatcher.
AnalysisViewModel'scombine()chain now ends in.flowOn(Dispatchers.Default).stateIn(...);SurpriseMeViewModel. generate()/regenerateSlot()now doisGenerating.value = truesynchronously, thenviewModelScope.launch(Dispatchers.Default) { ... }for the whole computation and everyresult/warning/isGeneratingwrite — deliberately notwithContext(Dispatchers. Default) { compute() }followed by writes back on the launch's original (Main) context, which would need a second hop back throughDispatchers.Mainafter the background work finishes. Considered adding an injectable@DefaultDispatcherqualifier toCoroutinesModule.kt(the established pattern for cross-cutting coroutine concerns here, see@ApplicationScope) so tests could substitute a deterministicTestDispatcher; decided against it for now;Dispatchers.Defaultis hardcoded, matching this codebase's existing convention of callingDispatchers.IOdirectly indata/pokeapi/data/backuprather than injecting a dispatcher there either.SurpriseMeViewModel's synchronousisGenerating.value = truebefore the launch is why this is safe to test without one: a test can assert onvm.uiState.value.isGeneratingimmediately after callinggenerate(), with no suspension needed, and the eventualfalseis observed via a realStateFlowupdate from a real background thread — not virtual time — the same category of wait every Room-/DataStore- backed test in this codebase already relies on. - Finding 3 (per-candidate rework in
computeCompositeScore) —TeamScoringContextextracted, no new test.computeCompositeScoretookotherMembers: List<TeamMember>and rebuilt the offensive coverage union and weakness map from it on every call, even though neither depends on the candidate — only on the team. Extracted that half intoteamScoringContext(chart, otherMembers): TeamScoringContext, built once per distinct team (once in addition mode, once per replacement candidate — six total, notcandidates × 6) and passed intocomputeCompositeScorein place of the raw member list.TeamGenerator.computeScoreadditionally reused the context'sbaseCoverageas its owncurrentTeamCoverageargument, deleting a second, independent redundant computation of the exact same set that existed only in that one call site (the generator never excludes a member from the comparison the way the suggestion engine's replacement mode does, so building the context from the exact team scored against makes the two values identical — this does not hold for the suggestion engine, wherecurrentTeamCoverageisanalyseTeam's potentially moves-awareunionCovered, deliberately different from the context's always-types-onlybaseCoverage). No new test: this is behaviour-preserving by construction (same inputs, same output shape, work reordered not changed), and every call site funnels throughcomputeSuggestions/generateTeam/regenerateSlot, all three already covered bySuggestionEngineTest/TeamGeneratorTest's existing exact- score and exact-ranking assertions. - Finding 4 (Suggestions panel "missing" PWA features) — the original
finding was wrong, corrected before implementing it. As first
written, finding 4 claimed the Suggestions panel was missing a
type-filter chip row, a "Best coverage"/"Random" mode toggle, and
showed 5 cards where the PWA showed 10 — based on a diff against
SuggestionPanel.android.tsx/SuggestionFilters.android.tsxalone.docs/plan/native-spec.md's "Suggestion engine" section states "Return the top 5 bygain" for both addition and replacement mode, and mentions neither the type filter nor the mode toggle;SuggestionFilters.kt's own doc comment already said as much ("Deliberately smaller thanlegacy-web's ownSuggestionFilters.tsx[...]: neither is in this app's UI spec") — a comment the original review should have read before writing that table and didn't. Five cards is the spec for this rewrite, not a shortfall against the PWA; implementing the retracted items would have reversed a documented Phase 4 decision. What survived the correction: asolidCoveragemessage (independent of the retracted items — shown when every displayed suggestion has zero gain, regardless of how many are shown or how they got filtered) and deleting one genuinely orphaned string resource (suggestions_exclude_legendaries). See the corrected finding 4 indocs/post-migration-review.mdfor the full record, including the quoted spec text. - Finding 6 (abilities ignored by suggestion/generator scoring) —
weaknesses()gained anabilityparameter, a real spec change.weaknesses(chart, types)never took an ability, unlikesharedWeaknessCounts/defensiveProfileinCoverageEngine.kt, which always honoured it — so a team's Analysis screen could show a member as immune to a type (via the coverage grid) while the Suggestions section, on the same screen, still penalized a candidate for "aggravating" that exact weakness. Addedability: String? = nulltoweaknesses(), threadedcandidate.abilityand eachotherMembersmember's.abilitythroughcomputeCompositeScore/teamScoringContext. Also fixedmemberFromEntry(dropped from Phase 4 asability = nullfor every catalogue-derived candidate) to carryPokemonEntry.defaultAbility, so a candidate is scored with the ability it will actually have once applied, not always with none — this letTeamGenerator.Candidatedrop its own separateabilityfield entirely (both construction paths already produce amemberwhose ownabilityis correct, so the "pick the ability to apply" step this field existed for is a no-op now). This is a spec change: composite scores now differ from the ported TypeScript baseline for any candidate or team member with a scoring-relevant ability (the 14 entries inAbilityEffects.kt'sABILITY_EFFECTS). Every existing fixture inTestFixtures.kt/SuggestionEngineTest.kt/TeamGeneratorTest.kthasdefaultAbility = nulland builds everyTeamMemberwith no explicitabilityeither, so this change is invisible to every existing exact-score/exact-ranking assertion — a newScoringTest.ktexercises the new behavior directly (Levitate removing a candidate's own Ground weakness; a teammate's Levitate changing a shared candidate weakness from "aggravated" to merely "new").
- BST is the suggestion ranking's tie-break only, never a term in the
composite score. Discussed explicitly with the repository owner
before implementation: making a candidate's raw strength part of the
score itself would change what "the best pick" means for a team that
is not yet solid (a real coverage gain could be outranked by a
bigger Pokémon with no gain at all), and would touch the
0.5/1.0weightsScoring.kt's own doc comment calls "load-bearing and shared with the generator". The comparator instead adds exactly one new step —bestScore, then isFinal, then baseStatTotal descending, then catalogue id ascending— so it only ever decides among candidates that already tied on real coverage/weakness math. - Held items are the defensive subset only, and
items.csvis never downloaded. Also an explicit decision with the repository owner: modelling every item (offensive boosts, berries with non-type effects, weather items) would need the full item catalogue (a further ~60 KB) for a coverage app that only cares about type effectiveness. The 18 modelled items (Air Balloon, Iron Ball, Ring Target, one resist berry per type) are a hardcoded table, the same shape asABILITY_EFFECTS. The plan document's own §4.1 table has a gap — it lists 16 resist berries plus Chilan and omits Coba Berry (Flying) — caught during implementation by cross-checking "one berry per type except Normal" againstPokemonType.entries;ItemEffects.kt'sITEM_EFFECTSmap is the correct, complete 18-item version and a dedicated test (ITEM_EFFECTS has one resist berry per type except Normal, plus Chilan for Normal) asserts the full set going forward. - The item field has no canonical-per-species picker, unlike ability.
AbilityPicker's canonical/custom split exists because a species' abilities are a small, fixed, PokéAPI-known set; any Pokémon can hold any item, so there is no equivalent "canonical list" to offer —ItemPickeris free text with suggestions from the modelled subset only, the same shape as the ability field's own custom mode. AbilityPicker's free-text suggestion list does not show the "has an effect" marker that the canonical dropdown does.EditableComboBoxcommits whatever suggestion string the user taps verbatim as the field's value — appending a marker (e.g. "Overgrow ●") to a suggestion would corrupt the stored ability with that marker attached. The canonical dropdown avoids this because itsonClickcommitsability.displayNameindependently of theTextcontent shown in the row; the free-text list has no such separation available without changingEditableComboBox's own contract, which several other screens already depend on. Scope decision: the badge only appears on canonical picks.KNOWN_ABILITIES_WITH_EFFECTSis regenerated fromABILITY_EFFECTSinstead of hand-maintained, and its exact strings changed as a result. Before Phase 7 it was a hand-tweaked list with one inconsistency of its own ("well-baked body", replacing only the second hyphen inwell-baked-bodywith a space, keeping the first) — a leftover, unused artifact from thelegacy-webport that nothing in the app actually reads (confirmed by grep before touching it). It's nowABILITY_EFFECTS.keys.map { it.replace('-', ' ') }, sowell-baked-bodybecomes "well baked body" (both hyphens replaced) rather than the old mixed convention. Harmless: nothing consumes this constant, and its own test was rewritten to assert the regenerated shape, not the old one.- Wonder Guard was promoted from
AbilityEffect.BadgeOnlyto a real effect (OnlySuperEffective), not left as a display note. The pre-Phase-7 table treated it as UI-only, sodefensiveProfile/defensiveMultiplierwere actively wrong for the one Pokémon (Shedinja) the ability applies to: a resisted or neutral hit should deal zero damage, but the engine reported the real chart multiplier instead. This is a genuine behavior change (a Wonder Guard holder's non-neutral matchups now show up indefensiveProfile.immunities, not scattered across weaknesses/resistances), found by auditingABILITY_EFFECTSagainst PokéAPI's ownshort_effecttext rather than assumed. - Gen I's canonical BST is the sum of five stats, not six — no
Special Attack/Special Defense split existed before Generation II, so
mirroring the single "Special" stat into both halves (making a false
six-stat total) would inflate every Gen-I Pokémon's total and change
relative ordering among special-heavy species.
bstResolverFor/assembleDataset'sbstAtboth apply this rule, and it is the reason a Gen-I total must never be compared numerically against a later generation's — documented onPastBstitself since it is the one way this feature can go quietly wrong. SuggestionEngine.EntryLookup's displayName-first precedence can, in a contrived case, disagree with the pre-Phase-7 single-passpool.find { it.displayName == x || it.name == x.lowercase() }— if one entry's raw identifier lowercased happens to equal another entry's exact display name, the old code (which evaluates both conditions per pool element, first match wins by pool position) could return a different entry than the new two-map lookup (which always checks every displayName match before falling back to any name match, regardless of position). This divergence needs a contrived, unrealistic fixture to observe — realdisplayName/namepairs never collide this way — and the plan's own §5.4 explicitly frames "displayName match first" as the behavior to preserve, so the two-map version is the intended, not merely tolerated, semantics.
parseShowdownBlockmistook any unrecognized line for a new species line, not just the block's first one. Verified against the real client's own grammar (sim/teams.tsin smogon/pokemon-showdown,exportSet/parseExportedTeamLine): a genuine Showdown export commonly includesLevel:,Shiny: Yes,Tera Type:,Happiness:,Pokeball:,Hidden Power:,Dynamax Level:andGigantamax: Yeslines, none of which this app tracks — but the oldwhenblock's final branch (!line.startsWith("#")) treated every line that wasn't a move, anAbility:line, or an EVs/IVs/Nature line as a fresh species line, silently overwriting whatever had already been parsed. Pasting almost any real-world set with aLevel:or (very common in Gen 9)Tera Type:line therefore corruptedspeciesNameto that line's text and broke species resolution. Fixed by tracking each line's position (forEachIndexed) and only ever treating index 0 as the species line — the sameisFirstLinesplit the real client's own parser uses — with every other unrecognized line now silently ignored instead of overwriting anything, and an explicit ignore-list for the ten field prefixes above.- The species line now also strips a nickname wrapper and a trailing
gender marker, e.g.
Volt Turtle (Pikachu) @ Light BallorPikachu (F) @ Light Ball— both common in real pastes (nicknamed Pokémon, VGC sets with explicit gender) and previously left whole, which meantresolveSpecieswas called with a string that could never match a real species name. Same order of operations as the real client'sparseExportedTeamLine: split the item off first, then strip" (M)"/" (F)", then unwrap"Name (Species)". Trait: <ability>is now accepted as a legacy alias forAbility: <ability>— the real client still parses it (pre-Gen-6 sets, and some third-party tools, still emit it) and previously it fell into the same species-line bug above.- The exporter no longer writes blank
Ability:/EVs:/Natureplaceholder lines, or a bare trailing@with no item. These were only ever safe to re-import because CoverDex's own parser explicitly ignored them and the real client's line-matching happens to fail too once each line is.trim()-med ("EVs: "trimmed no longer starts with the 5-character prefix"EVs: ") — accidentally harmless, not correct. Real Showdown's ownexportSetomits a field's line entirely when it's unset (if (set.ability) …,if (stats.length) …), so the exporter now does the same:Ability:is only written whenTeamMember.abilityis non-null, and EVs/IVs/Nature are omitted outright since they are still untracked (out of scope — seedocs/plan/native-spec.md, "Explicitly out of scope", "EV/IV tracking"). Existing tests asserting the old blank-line shape were updated to assert the lines are absent instead. - The
# Types: fire/flyingcomment line is intentionally left as CoverDex's own extension. Confirmed against the real parser that any line not matching a known prefix (species/Ability:/Trait:/EVs:/IVs:`/nature/moves/the eight ignored-detail prefixes) is silently skipped, never erroring or corrupting a later field — so this round-trips through the real client untouched, and through CoverDex's own parser as the type-override signal it always was.
The launcher icon carried over from the Capacitor build (see "The launcher icon is a verbatim copy..." above) was replaced with new, purpose-made artwork — a red Pokédex-style handheld next to a notepad and pencil, supplied as a single flattened 1254×1254 raster (art + solid blue background baked into one image, not separate foreground/background layers).
The sibling repo HackDex-Tracker shipped this exact kind of asset once
before and got it wrong: it treated a full-bleed raster as automatically
exempt from adaptive-icon masking just because the foreground layer was
transparent, and it wasn't — every launcher clips an <adaptive-icon>'s
composite to its own mask shape (circle, squircle, rounded square, ...)
regardless of which layer the visible art sits in, and real devices cut
straight through that badge's artwork before it was fixed (see that
repo's docs/implementation-decisions.md, "Adaptive icon safe zone").
Rather than repeat that mistake, this asset was scaled and padded
before ever reaching a device:
- Sample the source image's background color from its border pixels
(a uniform blue,
rgb(25, 129, 238)give or take JPEG-ish noise). - Find the farthest pixel from center that differs meaningfully from that background color — the true visual extent of the artwork, not just its bounding box.
- Binary-search for the largest scale factor that still leaves zero pixels outside a simulated launcher mask differing from the background color, checked against the three mask shapes real Android launchers actually use: a full circle inscribed in the icon (the stock/Pixel launcher), a squircle, and a rounded square.
- Resize the entire source image (art and background together) by that factor and center it on a same-size canvas filled with the same background color — since the fill color matches the source's own background exactly, there is no visible seam.
- Verify by simulating all three mask shapes over the result and confirming zero pixels outside each one differ from the background color.
The first pass of this fix (still visible in this file's git history) solved step 3 against Android's documented theoretical safe zone instead — a circle just 66dp in diameter, centered in the 108dp adaptive-icon canvas (https://developer.android.com/develop/ui/views/launch/icon_design_adaptive), which Google states is guaranteed safe under any mask shape a launcher could conceivably use. That produced a icon scaled to only ~61%, visibly smaller than it needed to be: this specific artwork's content already survives a full inscribed circle (the actual worst common shape) at essentially its original, unscaled proportions (the source image's farthest content pixel sits only ~0.2% past a full circle's radius). The huge gap between "provably safe against every mask Android could ever throw at it" and "safe against the masks real launchers actually ship" is exactly why the icon looked too small — recalculated against the three real shapes above, scale ~98% survives all of them with zero clipped pixels.
ic_launcher_background.png is this composited, verified image at every
density (mdpi 108px through xxxhdpi 432px); ic_launcher.xml/
ic_launcher_round.xml set the foreground to @android:color/transparent
and no longer reference an ic_launcher_foreground.png (deleted — there
is no separate foreground art to inset). The legacy pre-API-26
ic_launcher.png is the same composited image at the legacy sizes;
ic_launcher_round.png is the same again masked to a circle inscribed in
the square, matching how the previous icon's round variant was built.
Verified with the same circular/squircle/rounded-square mask simulation
technique as HackDex-Tracker's fix, confirming the artwork survives every
shape uncropped before this ever reached a real device.