diff --git a/assets/icons/brand/android-monochrome.png b/assets/icons/brand/android-monochrome.png
index fe88567..d551201 100644
Binary files a/assets/icons/brand/android-monochrome.png and b/assets/icons/brand/android-monochrome.png differ
diff --git a/assets/icons/brand/build-android-res.mjs b/assets/icons/brand/build-android-res.mjs
index b237389..5136b56 100644
--- a/assets/icons/brand/build-android-res.mjs
+++ b/assets/icons/brand/build-android-res.mjs
@@ -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 FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.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)]
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_foreground.png'), await shot(im(FG, fg), fg, true));
writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true));
- const comp = `
${im(BG, lg)}
${im(FG, lg)}
`;
- writeFileSync(join(dir, 'ic_launcher.png'), await shot(comp, lg, false));
+ // Square launcher: the full-bleed master (large mark). Round launcher: the safe-zone
+ // 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 = `${im(BG, lg)}
${im(FG, lg)}
`;
writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
console.log('mipmap-' + d, 'ok');
diff --git a/assets/icons/brand/build-icon.mjs b/assets/icons/brand/build-icon.mjs
index 7d5b1ea..bf54c03 100644
--- a/assets/icons/brand/build-icon.mjs
+++ b/assets/icons/brand/build-icon.mjs
@@ -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):
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
-// 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] };
+// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻).
+// 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 = {
light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
diff --git a/assets/icons/brand/icon-dark-android-foreground.png b/assets/icons/brand/icon-dark-android-foreground.png
index a4a22d6..02de1ff 100644
Binary files a/assets/icons/brand/icon-dark-android-foreground.png and b/assets/icons/brand/icon-dark-android-foreground.png differ
diff --git a/assets/icons/brand/icon-light-android-foreground.png b/assets/icons/brand/icon-light-android-foreground.png
index 12b4a8e..123fd76 100644
Binary files a/assets/icons/brand/icon-light-android-foreground.png and b/assets/icons/brand/icon-light-android-foreground.png differ
diff --git a/docs/ICONS.md b/docs/ICONS.md
index 12ff0d0..17a787d 100644
--- a/docs/ICONS.md
+++ b/docs/ICONS.md
@@ -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**
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_monochrome.png` — themed layer
- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml` — **no ``**, with
diff --git a/docs/ICON_BRANDBOOK.md b/docs/ICON_BRANDBOOK.md
index 0a0e713..4bb0a04 100644
--- a/docs/ICON_BRANDBOOK.md
+++ b/docs/ICON_BRANDBOOK.md
@@ -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
layer, so the sacrificial outer ring is just more wood.
- **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside
- the mask:
- - «Э»: box height **53.9 %**, box center **52.3 % / 49.5 %**.
- - ✻: outer diameter **13.2 %** (smaller than the master's), center **76.3 % / 72.7 %**,
+ the mask. The whole mark is the master's optical placement (nudged right 8 % / down 3 %)
+ scaled **0.75 about the icon centre** — **25 % smaller**, its «Э» ↔ ✻ arrangement
+ 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.
- - 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.
diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index 48fdd77..8fb8bd2 100644
Binary files a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
index 9024f34..63228c3 100644
Binary files a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
index f83e989..ddaafc8 100644
Binary files a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ
diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
index 8909874..16c1180 100644
Binary files a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png
index a4ff54b..c546019 100644
Binary files a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png differ
diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png
index eb08478..4fe946d 100644
Binary files a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png differ
diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_monochrome.png
index 1d171d7..5f3ae18 100644
Binary files a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_monochrome.png and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_monochrome.png differ
diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png
index 1be6faa..168bd44 100644
Binary files a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png differ
diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index ee098ab..9a2a994 100644
Binary files a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
index e2a7460..a648a5a 100644
Binary files a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
index 497be0f..1adbd1a 100644
Binary files a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ
diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
index 05d4e1c..1ed63a7 100644
Binary files a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 8e448c3..94b3c96 100644
Binary files a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
index 69a8967..7257423 100644
Binary files a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
index b0e40ea..5b6f910 100644
Binary files a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
index b517260..97bb510 100644
Binary files a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index 88ece49..2218429 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
index 15a62cf..032d0f3 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
index 1eae863..57d6eb4 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
index 38ba881..9c168d0 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index b705035..6dffad5 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
index 717737b..7c13b89 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
index c22f4e4..426fadd 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ
diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
index 6133338..abac6c3 100644
Binary files a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/ui/assets/icon-foreground.png b/ui/assets/icon-foreground.png
index 12b4a8e..123fd76 100644
Binary files a/ui/assets/icon-foreground.png and b/ui/assets/icon-foreground.png differ
diff --git a/ui/e2e/native.spec.ts b/ui/e2e/native.spec.ts
index d3d8666..4fc1f5b 100644
--- a/ui/e2e/native.spec.ts
+++ b/ui/e2e/native.spec.ts
@@ -43,20 +43,22 @@ test.describe('native offline-first', () => {
await page.goto('/');
await expectOfflineGuestLobby(page);
- // Play a local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). The dictionary
- // comes from the APK's bundled tier (./dict/scrabble_en@dev.dawg served from dist-e2e), so no
- // network is needed — this is the device-local guest playing with zero connectivity.
+ // Play a local Erudit vs_ai game with a pinned bag seed (deals the rack ОЖЬЯНАО). Erudit is the
+ // only variant the profileless offline guest is offered (the new-account default), so the single
+ // `.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.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 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
- // real move (which needs the bundled dictionary), so the board gains tiles beyond WAY's three.
- await placeTile(page, 'W', 7, 5);
- await placeTile(page, 'A', 7, 6);
- await placeTile(page, 'Y', 7, 7);
+ // 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 НОЖ's three.
+ await placeTile(page, 'Н', 7, 5);
+ await placeTile(page, 'О', 7, 6);
+ await placeTile(page, 'Ж', 7, 7);
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
await page.locator('.make').click();
await expect(async () => {
@@ -95,14 +97,15 @@ test.describe('native offline-first', () => {
await expectOfflineGuestLobby(page);
// 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.getByRole('button', { name: /With friends|друзьями/i }).click();
await page.locator('.hostpin .plink').click();
await typePin(page, '9999');
await typePin(page, '9999');
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(1).fill('Bob');
await page.locator('button.invite').click();
diff --git a/ui/public/icon-maskable-512-dark.png b/ui/public/icon-maskable-512-dark.png
index 9b6d045..24389fa 100644
Binary files a/ui/public/icon-maskable-512-dark.png and b/ui/public/icon-maskable-512-dark.png differ
diff --git a/ui/public/icon-maskable-512.png b/ui/public/icon-maskable-512.png
index ee6e488..a6aad67 100644
Binary files a/ui/public/icon-maskable-512.png and b/ui/public/icon-maskable-512.png differ
diff --git a/ui/src/lib/variants.test.ts b/ui/src/lib/variants.test.ts
index e8a208d..9558a62 100644
--- a/ui/src/lib/variants.test.ts
+++ b/ui/src/lib/variants.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
ALL_VARIANTS,
+ DEFAULT_VARIANTS,
availableVariants,
supportsMultipleWordsToggle,
multipleWordsForRequest,
@@ -16,9 +17,11 @@ describe('ALL_VARIANTS', () => {
});
describe('availableVariants', () => {
- it('is ungated (all variants) for an empty or absent preference set', () => {
- expect(availableVariants([])).toEqual(ALL_VARIANTS);
- expect(availableVariants(undefined)).toEqual(ALL_VARIANTS);
+ it('falls back to the Erudit-only default for an empty or absent preference set', () => {
+ const fallback = ALL_VARIANTS.filter((v) => DEFAULT_VARIANTS.includes(v.id));
+ expect(fallback.map((v) => v.id)).toEqual(['erudit_ru']);
+ expect(availableVariants([])).toEqual(fallback);
+ expect(availableVariants(undefined)).toEqual(fallback);
});
it('offers only the preferred variants', () => {
diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts
index 5981007..b520082 100644
--- a/ui/src/lib/variants.ts
+++ b/ui/src/lib/variants.ts
@@ -62,13 +62,23 @@ export function usesStarBlank(v: Variant): boolean {
return v === 'erudit_ru';
}
-// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
-// they enabled in Settings). An empty or absent set is ungated (returns every variant)
-// — a safety fallback; a real profile always carries at least one preference.
+// DEFAULT_VARIANTS is the variant set a player sees before any preference is stored — a fresh
+// or offline native client whose profile has not been synced yet (the device-local guest boots
+// 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[] {
const prefs = preferences ?? [];
- if (prefs.length === 0) return ALL_VARIANTS;
- return ALL_VARIANTS.filter((v) => prefs.includes(v.id));
+ const effective = prefs.length === 0 ? DEFAULT_VARIANTS : prefs;
+ return ALL_VARIANTS.filter((v) => effective.includes(v.id));
}
// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle