android: shrink adaptive icon mark 25% + full-bleed legacy square + Erudit-only default for the profileless client #271

Merged
developer merged 3 commits from feature/native-icons-variant-defaults into development 2026-07-14 20:23:04 +00:00
37 changed files with 59 additions and 29 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+8 -2
View File
@@ -19,6 +19,11 @@ const uri = p => 'data:image/png;base64,' + readFileSync(p).toString('base64');
const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png')); const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png'));
const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png')); const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png'));
const MONO = uri(join(DIR, 'android-monochrome.png')); const MONO = uri(join(DIR, 'android-monochrome.png'));
// The full-bleed master (square tile + master mark) — the legacy SQUARE launcher (API < 26)
// uses it so old Android shows a large mark rather than the safe-zone foreground shrunk into
// the middle. The round legacy icon keeps the safe-zone composite (a round mask would clip
// the master's corner ✻).
const MASTER = uri(join(ROOT, 'ui', 'assets', 'icon.png'));
// density: [adaptive-layer px (108 dp), legacy launcher px (48 dp)] // density: [adaptive-layer px (108 dp), legacy launcher px (48 dp)]
const D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] }; const D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] };
@@ -38,8 +43,9 @@ for (const [d, [fg, lg]] of Object.entries(D)) {
writeFileSync(join(dir, 'ic_launcher_background.png'), await shot(im(BG, fg), fg, false)); writeFileSync(join(dir, 'ic_launcher_background.png'), await shot(im(BG, fg), fg, false));
writeFileSync(join(dir, 'ic_launcher_foreground.png'), await shot(im(FG, fg), fg, true)); writeFileSync(join(dir, 'ic_launcher_foreground.png'), await shot(im(FG, fg), fg, true));
writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true)); writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true));
const comp = `<div style="position:relative;width:${lg}px;height:${lg}px">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`; // Square launcher: the full-bleed master (large mark). Round launcher: the safe-zone
writeFileSync(join(dir, 'ic_launcher.png'), await shot(comp, lg, false)); // composite, circle-clipped (the master's corner ✻ would be clipped by the round mask).
writeFileSync(join(dir, 'ic_launcher.png'), await shot(im(MASTER, lg), lg, false));
const round = `<div style="width:${lg}px;height:${lg}px;border-radius:50%;overflow:hidden;position:relative">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`; const round = `<div style="width:${lg}px;height:${lg}px;border-radius:50%;overflow:hidden;position:relative">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true)); writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
console.log('mipmap-' + d, 'ok'); console.log('mipmap-' + d, 'ok');
+6 -2
View File
@@ -33,8 +33,12 @@ const SHADE = 0.15; // black, this alpha bottom-left -> transp
// The mark, placed for the standalone master (bottom-right biased, full-bleed): // The mark, placed for the standalone master (bottom-right biased, full-bleed):
const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] }; const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] };
// The mark, placed for the Android foreground layer (centred-ish inside the 61% safe // The mark, placed for the Android foreground layer (centred-ish inside the 61% safe
// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻): // zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻).
const FOREGROUND = { lh: 0.5391, lc: [0.5234, 0.4951], sd: 0.1318, sc: [0.7627, 0.7266] }; // Every value is the earlier placement scaled 0.75 about the icon centre (0.5, 0.5): the
// whole mark is 25% smaller, its «Э»↔✻ arrangement unchanged, so the ✻ that used to graze
// the round mask now sits well inside the safe zone. Round-mask launchers (adaptive + the
// legacy round icon) get more breathing room; the full-bleed master is untouched.
const FOREGROUND = { lh: 0.4043, lc: [0.5176, 0.4963], sd: 0.0989, sc: [0.6970, 0.6700] };
const PALETTE = { const PALETTE = {
light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' }, light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+4 -1
View File
@@ -54,7 +54,10 @@ already fill the 108 dp canvas (foreground inside the 61 % safe zone, background
full-bleed), so they must be placed with **no inset**, and we add the **monochrome** full-bleed), so they must be placed with **no inset**, and we add the **monochrome**
themed layer, neither of which `capacitor-assets` does. Per density mdpi→xxxhdpi (+ldpi): themed layer, neither of which `capacitor-assets` does. Per density mdpi→xxxhdpi (+ldpi):
- `mipmap-*/ic_launcher.png` / `ic_launcher_round.png` — legacy square / round (composite) - `mipmap-*/ic_launcher.png` — legacy square (< API 26): the **full-bleed master** (large
mark), so old square launchers do not shrink it into the safe zone
- `mipmap-*/ic_launcher_round.png` — legacy round (< API 26): the safe-zone **composite**,
circle-clipped (a round mask would clip the master's corner ✻)
- `mipmap-*/ic_launcher_foreground.png` + `ic_launcher_background.png` — adaptive layers - `mipmap-*/ic_launcher_foreground.png` + `ic_launcher_background.png` — adaptive layers
- `mipmap-*/ic_launcher_monochrome.png` — themed layer - `mipmap-*/ic_launcher_monochrome.png` — themed layer
- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml`**no `<inset>`**, with - `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml`**no `<inset>`**, with
+6 -5
View File
@@ -123,12 +123,13 @@ So Android is **not** the full-bleed master — it is built as two layers:
and square** (no rounded corners; the OS supplies the shape). It fills the whole 108 dp and square** (no rounded corners; the OS supplies the shape). It fills the whole 108 dp
layer, so the sacrificial outer ring is just more wood. layer, so the sacrificial outer ring is just more wood.
- **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside - **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside
the mask: the mask. The whole mark is the master's optical placement (nudged right 8 % / down 3 %)
- «Э»: box height **53.9 %**, box center **52.3 % / 49.5 %**. scaled **0.75 about the icon centre****25 % smaller**, its «Э» ↔ ✻ arrangement
- ✻: outer diameter **13.2 %** (smaller than the master's), center **76.3 % / 72.7 %**, unchanged — so the ✻ that used to graze the round mask now sits well inside the **61 %
safe zone** with margin to spare:
- «Э»: box height **40.4 %**, box center **51.8 % / 49.6 %**.
- ✻: outer diameter **9.9 %** (smaller than the master's), center **69.7 % / 67.0 %**,
tucked closer to the letter. tucked closer to the letter.
- The mark is centred, then nudged **right 8 % / down 3 %** so it reads optically
centred under the round mask, and the whole group fits within the **61 % safe zone**.
Everything else (palette, grain, gradients, star construction) is identical to the master. Everything else (palette, grain, gradients, star construction) is identical to the master.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+14 -11
View File
@@ -43,20 +43,22 @@ test.describe('native offline-first', () => {
await page.goto('/'); await page.goto('/');
await expectOfflineGuestLobby(page); await expectOfflineGuestLobby(page);
// Play a local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). The dictionary // Play a local Erudit vs_ai game with a pinned bag seed (deals the rack ОЖЬЯНАО). Erudit is the
// comes from the APK's bundled tier (./dict/scrabble_en@dev.dawg served from dist-e2e), so no // only variant the profileless offline guest is offered (the new-account default), so the single
// network is needed — this is the device-local guest playing with zero connectivity. // `.variant` click both selects it and asserts nothing else is on offer. The dictionary comes from
// the APK's bundled tier (./dict/ru_erudit@dev.dawg served from dist-e2e), so no network is needed
// — this is the device-local guest playing with zero connectivity.
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1')); await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
await page.locator('button.tab').nth(0).click(); await page.locator('button.tab').nth(0).click();
await page.locator('.variant', { hasText: 'Scrabble' }).click(); await page.locator('.variant').click();
await page.locator('button.invite').click(); await page.locator('button.invite').click();
await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('[data-cell]').first()).toBeVisible();
// The human plays WAY horizontally across the centre (7,5)-(7,7); the local robot replies with a // The human plays НОЖ horizontally across the centre (7,5)-(7,7); the local robot replies with a
// real move (which needs the bundled dictionary), so the board gains tiles beyond WAY's three. // real move (which needs the bundled dictionary), so the board gains tiles beyond НОЖ's three.
await placeTile(page, 'W', 7, 5); await placeTile(page, 'Н', 7, 5);
await placeTile(page, 'A', 7, 6); await placeTile(page, 'О', 7, 6);
await placeTile(page, 'Y', 7, 7); await placeTile(page, 'Ж', 7, 7);
await expect(page.locator('[data-cell].pending')).toHaveCount(3); await expect(page.locator('[data-cell].pending')).toHaveCount(3);
await page.locator('.make').click(); await page.locator('.make').click();
await expect(async () => { await expect(async () => {
@@ -95,14 +97,15 @@ test.describe('native offline-first', () => {
await expectOfflineGuestLobby(page); await expectOfflineGuestLobby(page);
// New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest. // New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest.
// A minimal create: set the mandatory host (master) PIN, decline a seat, English, two open seats. // A minimal create: set the mandatory host (master) PIN, decline a seat, the sole offered variant
// (Erudit — the profileless guest's default), two open seats.
await page.locator('button.tab').nth(0).click(); await page.locator('button.tab').nth(0).click();
await page.getByRole('button', { name: /With friends|друзьями/i }).click(); await page.getByRole('button', { name: /With friends|друзьями/i }).click();
await page.locator('.hostpin .plink').click(); await page.locator('.hostpin .plink').click();
await typePin(page, '9999'); await typePin(page, '9999');
await typePin(page, '9999'); await typePin(page, '9999');
await page.getByRole('button', { name: /^(No|Нет)$/ }).click(); await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
await page.locator('.variant', { hasText: 'Scrabble' }).click(); await page.locator('.variant').click();
await page.locator('.pname').nth(0).fill('Ann'); await page.locator('.pname').nth(0).fill('Ann');
await page.locator('.pname').nth(1).fill('Bob'); await page.locator('.pname').nth(1).fill('Bob');
await page.locator('button.invite').click(); await page.locator('button.invite').click();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 106 KiB

+6 -3
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import { import {
ALL_VARIANTS, ALL_VARIANTS,
DEFAULT_VARIANTS,
availableVariants, availableVariants,
supportsMultipleWordsToggle, supportsMultipleWordsToggle,
multipleWordsForRequest, multipleWordsForRequest,
@@ -16,9 +17,11 @@ describe('ALL_VARIANTS', () => {
}); });
describe('availableVariants', () => { describe('availableVariants', () => {
it('is ungated (all variants) for an empty or absent preference set', () => { it('falls back to the Erudit-only default for an empty or absent preference set', () => {
expect(availableVariants([])).toEqual(ALL_VARIANTS); const fallback = ALL_VARIANTS.filter((v) => DEFAULT_VARIANTS.includes(v.id));
expect(availableVariants(undefined)).toEqual(ALL_VARIANTS); expect(fallback.map((v) => v.id)).toEqual(['erudit_ru']);
expect(availableVariants([])).toEqual(fallback);
expect(availableVariants(undefined)).toEqual(fallback);
}); });
it('offers only the preferred variants', () => { it('offers only the preferred variants', () => {
+15 -5
View File
@@ -62,13 +62,23 @@ export function usesStarBlank(v: Variant): boolean {
return v === 'erudit_ru'; return v === 'erudit_ru';
} }
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set // DEFAULT_VARIANTS is the variant set a player sees before any preference is stored — a fresh
// they enabled in Settings). An empty or absent set is ungated (returns every variant) // or offline native client whose profile has not been synced yet (the device-local guest boots
// — a safety fallback; a real profile always carries at least one preference. // with no profile at all). It mirrors the backend's new-account default (the account service
// seeds Erudit only), so the local guest and a later synced account agree on what is enabled.
// Every dictionary is still bundled; the other variants are simply off until the player turns
// them on in Settings.
export const DEFAULT_VARIANTS: Variant[] = ['erudit_ru'];
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set they
// enabled in Settings). An empty or absent set falls back to DEFAULT_VARIANTS rather than every
// variant: a real profile always carries at least one preference, and a profileless client (a
// fresh offline native launch) must match the server's Erudit-only default instead of exposing
// the English game before the player opts in.
export function availableVariants(preferences: Variant[] | undefined): VariantOption[] { export function availableVariants(preferences: Variant[] | undefined): VariantOption[] {
const prefs = preferences ?? []; const prefs = preferences ?? [];
if (prefs.length === 0) return ALL_VARIANTS; const effective = prefs.length === 0 ? DEFAULT_VARIANTS : prefs;
return ALL_VARIANTS.filter((v) => prefs.includes(v.id)); return ALL_VARIANTS.filter((v) => effective.includes(v.id));
} }
// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle // supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle