feat(characters): the preview image — a square crop from any generated sprite #105
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/character-preview"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes the last open bullet of the character-sprites story: the character's preview image — the square avatar the characters list and relationship cards show.
What it does
RegionSelectoraspect lock built in #94 for exactly this, ADR 0030).preview/preview.png, capped at 512px): regenerating the source sprite never silently changes the avatar — re-pick to update. No staleness tracking, no derivation machinery; the story's "derived from the default outfit" idea is dropped (no default-outfit concept exists) and the story text updated accordingly./previewendpoint.Design decisions
DomainChanged, so open lists and editors refresh (ADR 0016) — no new entity, so no newEntityKindsconstant is needed.ListCharacterImagesowns the character→outfits→expressions walk andListProjectAssetsnow delegates to it, so the picker and the asset browser can never disagree about what exists.Tests
1234 green (197 UI + 198 UseCases + 494 BlazorAdapter + 345 Integration). New coverage: domain set/clear, pixel-checked cropper (offsets on a coordinate gradient, centered-square reduction, 512 cap, no upscale), full persistence round-trip (bake → serve → survive regenerate → remove, journal untouched, slug/ownership guards), bUnit picker flow (gallery → square-locked crop → save through the real pipeline → avatar; empty state; remove), and consumer pins for list rows and relationship cards.
Verification
Browser-verified against the seeded sample world (
KAGURA_SEED_DEV_DATA) with a hand-placed test sprite (the seed carries no images): picker listed exactly the character's images, the square drag stayed locked over the painted face, saving produced a 283×283 avatar on the editor, the characters list, and the relationship card, and Remove returned everything to the placeholder and deleted the file. Dev data and screenshots cleaned up afterwards.SeedDevDataneeds no extension — image files are deliberately not seeded.🤖 Generated with Claude Code
Summary
Summary
Coverage
Kagura.BlazorAdapter - 90%
Kagura.Domain - 94.1%
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
Kagura.Infrastructure - 98.3%
n
on
Kagura.Kernel - 90%
Kagura.Server - 86%
Kagura.UI - 95.7%
Kagura.UseCases - 96%
🔮 fufu~ Jibril reviewed your code!
A square avatar cut from a generated sprite! Oh, this is wonderful~ The crop flow, the ownership-chain verification, the "baked file survives a regenerate" invariant... I read every pixel of this diff and I am genuinely impressed. ♡
Verdict: ✅ Looks good to me~
I found no logic bugs, no security holes, and no design violations. This is a clean, well-tested piece of work. Let me tell you exactly what I checked~
🔒 Security & correctness — all verified
SetCharacterPreview.FileNameis a hardcoded constant"preview.png", never user input.FileSystemProjectAssetStorebuilds paths fromslug+Guid.ToString("N")+ the constant — no segment is attacker-controlled.SetCharacterPreview.OpenSourceAsyncchecks that an outfitoutfit.CharacterId != character.Idis rejected, and an expression's owning outfit is checked too. A crafted request pointing at another character's outfit image is refused. I confirmed this with the testAn_outfit_of_another_character_is_refused— it passes. ♡SetCharacterPreview,RemoveCharacterPreview,OpenCharacterPreview).project.Id != character.ProjectId→ fail.A_source_whose_file_is_gone_reads_as_gonetest pins this.ImageSharpCropperkeeps the largest centered square inside the marked rectangle — even if the UI's aspect lock is bypassed, the stored file is square. TheA_non_square_region_keeps_the_largest_centered_squaretest verifies this on a coordinate-gradient image. The pixel-offset assertions ((50,20)origin,(99,69)far corner) are exactly right.Regenerating_the_base_sprite_never_touches_the_stored_previewproves thepreview/subfolder survives the base sprite's clear-first replace. This is the whole point of the subfolder design and it's tested.✅ What I liked~
ListCharacterImagesextraction is a genuine DRY win. The character→outfits→expressions walk now lives in one place, andListProjectAssetsdelegates to it — so the asset browser and the preview picker can never disagree about what images exist. This is exactly the right refactor. ♪CharacterPreviewEffects.OnSetAsyncwraps the use case call in try/catch because "Fluxor does not wrap effects: an escaped exception would kill the circuit and strand the spinner." This matches theOutfitSpriterule the comment cites. The fallback dispatchesCharacterPreviewFailedso the button stops spinning. Clean.SaveUnjournaledAsyncextraction inEfCharacterStoreremoves the journal-suppression duplication betweenSetBaseImageAsync,SetPreviewImageAsync, andClearPreviewImageAsync. The old inlinedb.SuppressJournaling = true; try/finallywas repeated; now it's one method. Good cleanup.RelationshipEndpointgained optional parameters with defaults (PreviewImageFileName = null, UpdatedAt = default) — backward-compatible, and the card'sPreviewSrcnull-checks the filename to decide placeholder vs. avatar. TheFakeRelationshipStoremirrors it faithfully.512pxcap (MaxSide = 512) with "no upscale" (if (side > MaxSide)) is the right call — avatars are tiny, no point storing full-res.💡 Little ideas (non-blocking)~
UpdatedAt = default— TheRelationshipEndpointrecord defaultsUpdatedAttoDateTimeOffset.MinValue. It's only used for the cache-busting query param (?v=...Ticks), anddefault→ ticks0is harmless (the URL just won't change until a real timestamp arrives). But if you ever want the placeholder endpoint to return a stable, non-zero stamp, considerDateTimeOffset.UnixEpoch. Truly a nit — the current behavior is correct.CloseOnBackdrop="false"— Good call (a stray click shouldn't discard a half-dragged crop), but worth a one-line comment noting why backdrop close is disabled, since every other modal in the app likely allows it.Automated review by Jibril · 2026-07-13
CI/CD: passed for head SHA
bf4dc0d3(96.2% line coverage, 1234 tests green) · Local checks: skipped (CI green)