Compare commits

..

2 Commits

Author SHA1 Message Date
Ilia Denisov fb0ddab0f1 feat(ui): hide GCG export in honest-AI games
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m1s
A vs_ai game is throwaway practice, so its finished history header no longer
offers the 📤 GCG export (an empty slot keeps the comms icon pinned right).
Docs note the AI exclusion; UI_DESIGN also records that confirming a resign
reveals the full board (closes the history drawer, zooms out).
2026-06-17 14:01:08 +02:00
Ilia Denisov e850ecd83b fix(ui): don't strand the Mini App on a cancelled GCG share
On iOS WKWebView (the Telegram Mini App), cancelling the Web Share sheet fell
through to the Blob <a download> fallback. iOS ignores the download attribute,
so clicking the anchor navigated the webview to the blob: URL — replacing the
SPA with the raw GCG file, with no way back (force-quit only).

The share path no longer falls back to a download: Web Share is available on
that platform, so a cancelled or failed share is a no-op and the user can
retry. The Blob download stays the desktop-only path (no Web Share).
2026-06-17 14:01:02 +02:00
7 changed files with 91 additions and 13 deletions
+4 -3
View File
@@ -224,9 +224,10 @@ barred from feedback (a role, not a full account block) sees the send control di
### History & statistics ### History & statistics
Finished games are archived in a dictionary-independent form and exportable to Finished games are archived in a dictionary-independent form and exportable to
GCG; the export is offered **only once a game is finished** (exporting a live game GCG; the export is offered **only once a game is finished**, and never for an
would leak the move journal), and the client shares the `.gcg` file where the honest-AI practice game (a live game's export would leak the move journal; an AI
platform supports it, otherwise downloads it. Statistics (durable accounts only): game is throwaway). The client shares the `.gcg` file where the platform supports
it, otherwise downloads it. Statistics (durable accounts only):
wins, losses, draws, max points in a game, and max points for a single move (the wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus). best play, which already includes every word it formed plus the all-tiles bonus).
A game that can no longer be continued — because a rule changed and an earlier A game that can no longer be continued — because a rule changed and an earlier
+4 -3
View File
@@ -228,9 +228,10 @@ UTC), суточного окна отсутствия (away; сетка по 10
### История и статистика ### История и статистика
Завершённые партии архивируются в независимом от словаря виде и экспортируются Завершённые партии архивируются в независимом от словаря виде и экспортируются
в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии в GCG; экспорт доступен **только после завершения партии** и никогда — для
раскрыл бы журнал ходов), и клиент делится файлом `.gcg` там, где платформа это тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия
поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов): с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает,
иначе скачивает его. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). ход, уже включающий все образованные им слова и бонус за все фишки).
Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход Партия, которую больше нельзя продолжить — из-за изменения правил более ранний ход
+4 -2
View File
@@ -300,9 +300,11 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
Safari. Safari.
- **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid - **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid
(the word(s) and the move score, no running total); *Export GCG* (the 📤 in the history (the word(s) and the move score, no running total); *Export GCG* (the 📤 in the history
header) shares or downloads the `.gcg` file and appears only once the game is finished. header) shares or downloads the `.gcg` file and appears only once the game is finished — and
never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board:
it closes the history drawer (portrait) and zooms the board out.
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header - **Finished game**: the board keeps no last-word highlight and no zoom; the history header
offers *Export GCG* (not *Drop game*) and the comms hub hides the 🔎 *Dictionary* tab; and offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab; and
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input
row is turn-driven: on your turn the message field shows until your one allowed message is row is turn-driven: on your turn the message field shows until your one allowed message is
+23
View File
@@ -59,6 +59,29 @@ test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', a
await expect(page.locator('.check input')).toBeEnabled(); await expect(page.locator('.check input')).toBeEnabled();
}); });
// GCG export is pointless for a practice AI game, so the finished AI game hides the 📤 export
// (the comms hub stays). Start an AI game, resign it, reopen the history and check the header.
test('AI game: no GCG export offered after it ends', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click();
await page.locator('.variant').first().click(); // AI is the default opponent
await page.getByRole('button', { name: /Start game/i }).click();
await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible();
// Resign the practice game from the history header.
await page.locator('.scoreboard').click();
await page.getByRole('button', { name: 'Drop game' }).click();
await page.locator('button.danger').click();
await expect(page.locator('.status .over')).toBeVisible();
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export,
// while the comms hub stays reachable.
await page.locator('.scoreboard').click();
await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible();
});
// The opponent_joined push is best-effort and never replayed, so a join that lands while the live // The opponent_joined push is best-effort and never replayed, so a join that lands while the live
// stream is down is lost. The waiting game screen recovers it without a push: a poll while the // stream is down is lost. The waiting game screen recovers it without a push: a poll while the
// stream is down, and a refetch on stream reconnect. The __stream hook (lib/app.svelte.ts) drops / // stream is down, and a refetch on stream reconnect. The __stream hook (lib/app.svelte.ts) drops /
+6
View File
@@ -986,7 +986,13 @@
{#if view} {#if view}
<div class="hhead"> <div class="hhead">
{#if gameOver} {#if gameOver}
{#if view.game.vsAi}
<!-- GCG export is not offered for a practice AI game; an empty slot keeps the comms
icon pinned right (the header is space-between). -->
<span aria-hidden="true"></span>
{:else}
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button> <button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
{/if}
{:else} {:else}
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button> <button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if} {/if}
+42 -2
View File
@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { pickGcgDelivery } from './share'; import { pickGcgDelivery, shareOrDownloadGcg } from './share';
import type { GcgExport } from './model';
const file = {} as File; const file = {} as File;
const gcg: GcgExport = { gameId: 'g1', filename: 'game.gcg', content: '#title game' };
describe('pickGcgDelivery', () => { describe('pickGcgDelivery', () => {
it('shares when the platform can share files', () => { it('shares when the platform can share files', () => {
expect(pickGcgDelivery({ canShare: () => true, share: async () => {} }, file)).toBe('share'); expect(pickGcgDelivery({ canShare: () => true, share: async () => {} }, file)).toBe('share');
@@ -20,3 +23,40 @@ describe('pickGcgDelivery', () => {
expect(pickGcgDelivery({ canShare: () => true } as never, file)).toBe('download'); expect(pickGcgDelivery({ canShare: () => true } as never, file)).toBe('download');
}); });
}); });
describe('shareOrDownloadGcg', () => {
afterEach(() => vi.unstubAllGlobals());
function stubDownloadEnv(canShare: boolean, share: () => Promise<void>) {
const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
const createElement = vi.fn(() => anchor);
vi.stubGlobal('File', class {});
vi.stubGlobal('Blob', class {});
vi.stubGlobal('navigator', { canShare: () => canShare, share });
vi.stubGlobal('document', { createElement, body: { appendChild: vi.fn() } });
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:x'), revokeObjectURL: vi.fn() });
return { anchor, createElement };
}
it('never falls back to the navigating Blob download when a share is cancelled', async () => {
// Reproduces the iOS Telegram Mini App break: Web Share is available and the user cancels it
// (AbortError). The <a download> fallback navigates the WKWebView to the blob: URL and strands
// the app, so a cancelled (or failed) share must do nothing here.
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
const { createElement } = stubDownloadEnv(true, share);
await shareOrDownloadGcg(gcg);
expect(share).toHaveBeenCalledOnce();
expect(createElement).not.toHaveBeenCalled(); // no download anchor → no webview navigation
});
it('downloads via an anchor when the platform cannot share files', async () => {
const { anchor, createElement } = stubDownloadEnv(false, vi.fn());
await shareOrDownloadGcg(gcg);
expect(createElement).toHaveBeenCalledWith('a');
expect(anchor.click).toHaveBeenCalledOnce();
});
});
+7 -2
View File
@@ -25,12 +25,17 @@ export async function shareOrDownloadGcg(gcg: GcgExport): Promise<void> {
const file = new File([gcg.content], gcg.filename, { type: 'application/x-gcg' }); const file = new File([gcg.content], gcg.filename, { type: 'application/x-gcg' });
const nav = typeof navigator !== 'undefined' ? navigator : undefined; const nav = typeof navigator !== 'undefined' ? navigator : undefined;
if (pickGcgDelivery(nav, file) === 'share' && nav) { if (pickGcgDelivery(nav, file) === 'share' && nav) {
// Web Share is available (mobile, including the iOS Telegram Mini App): use it and stop here
// whatever the outcome. Do NOT fall back to the Blob download — on iOS WKWebView an
// <a download> navigates the webview to the blob: URL, replacing the SPA with the raw file
// and stranding the app, so a cancelled or failed share must simply do nothing (the user can
// retry). The download path is only for desktop browsers without Web Share.
try { try {
await nav.share({ files: [file], title: gcg.filename }); await nav.share({ files: [file], title: gcg.filename });
return;
} catch { } catch {
// The user cancelled or sharing failed — fall back to a download. /* cancelled or failed — intentionally a no-op (see above) */
} }
return;
} }
downloadFile(gcg.content, gcg.filename); downloadFile(gcg.content, gcg.filename);
} }