Compare commits

...

4 Commits

Author SHA1 Message Date
developer b5fe61279b Merge pull request 'android: shrink adaptive icon mark 25% + full-bleed legacy square + Erudit-only default for the profileless client' (#271) from feature/native-icons-variant-defaults into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-14 20:23:04 +00:00
Ilia Denisov e07886ecb1 test(ui): update the native offline-first e2e for the Erudit-only guest default
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
The offline native guest is now offered only Erudit (the profileless default
from the variants fix), so the specs that picked the English "Scrabble"
variant timed out. Play Erudit instead: the single `.variant` click both
selects it and asserts nothing else is offered; the vs_ai test plays НОЖ from
the pinned Erudit rack (ОЖЬЯНАО), and the bundled ru_erudit dawg drives the
robot reply.
2026-07-14 22:13:42 +02:00
Ilia Denisov 4a3e12c85a feat(icons): shrink the Android adaptive mark 25%, give old square launchers the full-bleed master
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 2m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
The adaptive foreground's asterisk grazed the round mask's safe zone. Scale the
foreground mark 0.75 about the icon centre (25% smaller, the letter-to-star
arrangement unchanged) so it sits well inside the 61% safe zone. Independently,
the legacy square ic_launcher.png now renders the full-bleed master instead of
the safe-zone composite, so old Android (< API 26) shows a large mark rather
than a shrunk one; the legacy round icon keeps the safe-zone composite (a round
mask would clip the master's corner star). Regenerate the layer set, Android
res and the brandbook foreground previews; update ICON_BRANDBOOK.md / ICONS.md.
2026-07-14 21:57:32 +02:00
Ilia Denisov 54bc31c619 fix(ui): default the profileless client to Erudit only, not every variant
availableVariants fell back to all three variants when the player had no
stored preferences. A fresh offline native launch boots with no profile, so
New Game exposed the English game before the player opted in — contrary to the
backend's Erudit-only new-account default (docs/FUNCTIONAL.md). Fall back to
DEFAULT_VARIANTS (Erudit only) instead; all dictionaries stay bundled.
2026-07-14 21:57:21 +02: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 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 = `<div style="position:relative;width:${lg}px;height:${lg}px">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
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 = `<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));
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):
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' },
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**
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 `<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
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.
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 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();
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 {
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', () => {
+15 -5
View File
@@ -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