Phase 2: character portraits → token icons (local + realtime)

- character.portrait + mapToken.image (data URLs, Dexie v10, additive).
- src/lib/img/resize.ts: center-crop + downscale helper (160px tokens / 256px
  portraits) keeping IndexedDB + wire payloads small.
- Portrait uploader on the character sheet; token icon uploader in the map token
  modal (falls back to the linked character's portrait). Palette PC entries show
  the portrait thumbnail.
- Tokens render the image clipped to the circle (HP ring + condition badge on top).
- Realtime: generalized the image channel to many images (map bg + token/portrait
  icons), deduped by content and resent on reconnect. Player tokens/party carry an
  imageId resolved from the session image cache; party panel shows portraits.
- Also: fix the encounter-picker dropdown text being clipped (drop fixed height).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 14:42:06 +02:00
parent ca3769eb6b
commit 1aff63f29c
18 changed files with 239 additions and 42 deletions
+20
View File
@@ -88,6 +88,26 @@ test('maps: place a token from a planned encounter via the palette', async ({ pa
await expect(page.getByTestId('map-token')).toContainText('Bandit');
});
test('maps: a character portrait becomes the token icon', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Art');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Characters' }).click();
await createCharacter(page, 'Aria');
// Upload a portrait on the sheet; it renders in the header avatar.
await page.getByLabel('Portrait').setInputFiles('public/pwa-512x512.png');
await expect(page.locator('label img').first()).toBeVisible();
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Maps' }).click();
await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png');
// Placing the PC drops a token that shows the portrait image.
await page.getByRole('button', { name: /Aria/ }).click();
await expect(page.getByTestId('map-token').first().locator('img')).toBeVisible();
});
test('maps: place a party token from the palette', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Party');