diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 2b0af0d..989ff51 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -16,7 +16,7 @@ the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge real-time in-app updates, switching interface language (en/ru) and theme, and a read-only profile. It also handles managing friends (including one-time friend codes) and blocks, friend-game invitations, editing the profile and binding an -email, the statistics screen, and the in-game history viewer with GCG export. +email, the statistics screen, and the in-game history viewer with GCG / PNG-image export. Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and costs nothing when the rack has no legal move. The word-check accepts only the variant's alphabet, remembers answers within the session and rate-limits repeats. @@ -318,12 +318,37 @@ silently ignores their messages) or clear a thread's messages. This is independe Feedback above — it serves people who write to the bot directly rather than through the app. ### History & statistics -Finished games are archived in a dictionary-independent form and exportable to -GCG; the export is offered **only once a game is finished**, and never for an -honest-AI practice game (a live game's export would leak the move journal; an AI -game is throwaway). The client shares the `.gcg` file where the platform supports -it; on an Android in-app client (Telegram / VK), which has neither Web Share nor a working file -download, it copies the GCG to the clipboard (with a confirming toast); otherwise it downloads the file. Statistics (durable accounts only): +Finished games are archived in a dictionary-independent form and exportable in +**two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of +the final position; the export is offered **only once a game is finished**, and +never for an honest-AI practice game (a live game's export would leak the move +journal; an AI game is throwaway). The format chooser is always the app's **own +modal** — deliberately not Telegram's native popup, whose callback runs without +user activation and silently breaks the share/clipboard delivery it leads to. + +The **image** is rendered on the client (Canvas 2D, always the light theme): the +final board with classic coordinate axes A..O / 1..15 on the left — premium squares +as plain colour fills, no text labels — and a compact per-seat scoresheet on the +right: each seat's name and final score in the header (🏆 by the winner), then one +row per move carrying the main word's classic coordinate (an across play is +row-first, `8G`; a down play column-first, `H8` — the GCG convention), the word and +its points; extra words of a multi-word play ride a smaller second line, non-play +moves show as localized notes (pass/exchange/resign/timeout), and a closing ± row +shows the endgame rack settlement when there was one (the final scores are +authoritative — running totals alone do not include it). The footer stamps the site +host and the finish date in the device locale. The scoresheet typography is fixed; +a long game stretches the board (never below its minimum) so the image carries no +dead space. + +Delivery per format: the **GCG** file is Web-Shared where the platform supports it; +on an Android in-app client (Telegram / VK), which has neither Web Share nor a +working file download, it copies the GCG text to the clipboard (with a confirming +toast); otherwise it downloads the file. The **PNG** is Web-Shared or downloaded the +same way, but a binary image has no clipboard-text fallback at all — so the image +option is currently **withheld inside the in-app webviews** (Telegram / VK) and +offered on the plain web and mobile browsers only; it returns there with the +server-rendered signed-URL delivery (Telegram `downloadFile` / +`VKWebAppDownloadFile`). Statistics (durable accounts only): 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). It also shows the player's **move count** (their plays — passes and exchanges do not diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 5bb3c73..cecd013 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -16,7 +16,7 @@ top-1 подсказку, безлимитную проверку слова с профиль только для чтения. Он также включает управление друзьями (в т.ч. одноразовые коды-приглашения) и блоками, дружеские приглашения в игру, редактирование профиля и привязку email, экран статистики и просмотр истории -партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток +партии с экспортом в GCG / PNG-картинку. В настройках также выбирается стиль подписей бонус-клеток (новичок / классика / без текста). Подсказка **выставляет предложенные фишки на доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет. Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии @@ -327,12 +327,36 @@ Telegram. канал для тех, кто пишет боту напрямую, а не через приложение. ### История и статистика -Завершённые партии архивируются в независимом от словаря виде и экспортируются -в GCG; экспорт доступен **только после завершения партии** и никогда — для -тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия -с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает; -в Android-приложении (Telegram / VK), где нет ни Web Share, ни рабочей загрузки файла, копирует GCG -в буфер обмена (с подтверждающим тостом); иначе скачивает файл. Статистика (только у постоянных аккаунтов): +Завершённые партии архивируются в независимом от словаря виде и экспортируются в +**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка** +финальной позиции; экспорт доступен **только после завершения партии** и никогда — +для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а +партия с ИИ одноразовая). Выбор формата — всегда **собственный модал** приложения, +сознательно не нативный попап Telegram: его коллбек приходит без user activation и +молча ломает доставку через share/буфер, к которой ведёт выбор. + +**Картинка** рендерится на клиенте (Canvas 2D, всегда светлая тема): финальная +доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой +цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам +справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по +строке на ход: классическая координата главного слова (горизонтальный ход — +цифра первой, `8G`; вертикальный — буква первой, `H8` — конвенция GCG), слово и +его очки; дополнительные слова мультисловного хода — второй мелкой строкой, +не-игровые ходы — локализованными пометками (пас/обмен/сдался/таймаут), а +замыкающая строка ± показывает концовочную поправку за рэк, когда она была +(финальные счета авторитетны — суммы ходов её не содержат). В подвале — хост сайта +и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная +партия растягивает доску (не ниже минимума), так что пустот на картинке нет. + +Доставка по форматам: файлом **GCG** клиент делится через Web Share там, где +платформа это поддерживает; в Android-приложении (Telegram / VK), где нет ни Web +Share, ни рабочей загрузки файла, копирует текст GCG в буфер обмена (с +подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так же, +но у бинарной картинки нет текстового фолбэка через буфер вовсе — поэтому пункт +«картинка» пока **скрыт во встроенных webview** (Telegram / VK) и предлагается +только в обычном вебе и мобильных браузерах; туда он вернётся с серверным рендером +и доставкой по подписанному URL (Telegram `downloadFile` / +`VKWebAppDownloadFile`). Статистика (только у постоянных аккаунтов): победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все образованные им слова и бонус за все фишки). Также показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index b25dc6c..b9b32ee 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -403,15 +403,34 @@ enabled on the first, uncached load) and flip in place when an event refreshes t lobby with a calm modal pointing at the bot (`@`), not a red error on the Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in Safari. -- **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 - header) delivers the `.gcg` file — Web Share where available, a clipboard copy in an Android in-app - WebView (Telegram / VK, which has neither Web Share nor a working download), else a Blob download — - 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. +- **History / export**: 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); the 📤 in the history header appears + only once the game is finished — never in an honest-AI game (throwaway practice) — and + opens the **format chooser**: always the app's own modal, never Telegram's native popup + (a `showPopup` callback carries no user activation, which silently breaks the + `navigator.share` / clipboard delivery it leads to — on-device finding). The accent button + leads with the image where offered, the GCG file is the quiet alternative (GCG needs the + connection, the image renders locally). The GCG file goes by Web Share where available, a + clipboard copy in an Android in-app WebView (Telegram / VK, which has neither Web Share nor + a working download), else a Blob download. The image option shows **only outside the in-app + WebViews** for now (a binary PNG has no working route there — no Web Share, a dead + ``, a text-only clipboard, and the long-press menu mangles data: URLs); it + returns there with the server-rendered signed-URL delivery. Confirming a resign reveals + the full board: it closes the history drawer (portrait) and zooms the board out. +- **Export image** (`lib/gameimage.ts`, Canvas 2D, dynamically imported): always the light + palette (pinned constants mirroring `app.css`), the final board with classic axes A..O / + 1..15, premium squares as plain colour fills (no text labels), tiles in the in-game style + (bevel, letter top-left, value bottom-right, ✻ for an Erudit blank), no last-move + highlight. The right column is the scoresheet: per-seat name over the final score (🏆 by + the winner), one fixed-typography row per move — muted classic coordinate (across = + row-first `8G`, down = column-first `H8`), the main word, points right-aligned; extra + words of a multi-word play on a smaller second line; italic localized notes for + pass/exchange/resign/timeout; a closing muted ± row for the endgame rack settlement when + present. Footer: `hostname · finish date` in the **device** locale. The scoresheet's + height drives the board side (never below its minimum): a long game stretches the board, + never the typography, so the image has no dead space. - **Finished game**: the board keeps no last-word highlight and no zoom; the history header - offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and + offers *Export game* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and 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 row is turn-driven: on your turn the message field shows until your one allowed message is diff --git a/ui/e2e/export.spec.ts b/ui/e2e/export.spec.ts new file mode 100644 index 0000000..fae1798 --- /dev/null +++ b/ui/e2e/export.spec.ts @@ -0,0 +1,87 @@ +import { expect, test, type Page } from './fixtures'; + +// The finished-game export: the history header's 📤 button opens the app's own format +// chooser modal — never Telegram's native popup, whose callback runs without user +// activation and so kills navigator.share / clipboard writes (verified on-device). +// The PNG option is offered only outside the in-app WebViews (no working binary +// delivery there until the server-rendered signed-URL path lands); the GCG file is +// always offered. g3 ("vs Rick") is the seeded finished game. Web Share is +// force-disabled per test so both engines deterministically exercise the download +// branch. + +async function disableWebShare(page: Page): Promise { + await page.addInitScript(() => { + Object.defineProperty(navigator, 'canShare', { value: () => false, configurable: true }); + Object.defineProperty(navigator, 'share', { value: undefined, configurable: true }); + }); +} + +async function openFinishedHistory(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /Rick/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await page.locator('.scoreboard').click(); + await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible(); +} + +test('the export chooser downloads the PNG image on a plain browser', async ({ page }) => { + await disableWebShare(page); + await openFinishedHistory(page); + await page.getByRole('button', { name: 'Export game' }).click(); + + // The app's own chooser modal offers both formats on the plain web. + await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible(); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Image (PNG)' }).click(); + expect((await download).suggestedFilename()).toMatch(/^game-.+\.png$/); +}); + +test('the export chooser downloads the GCG file on a plain browser', async ({ page }) => { + await disableWebShare(page); + await openFinishedHistory(page); + await page.getByRole('button', { name: 'Export game' }).click(); + + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'GCG file' }).click(); + expect((await download).suggestedFilename()).toMatch(/^game-.+\.gcg$/); +}); + +test('inside Telegram the chooser is the app modal and withholds the image option', async ({ + page, +}) => { + await disableWebShare(page); + // A Telegram WebApp stub with showPopup present: were the chooser still native, it would + // be called — the spy locks the regression (its activation-less callback breaks share and + // clipboard delivery on real devices). + await page.addInitScript(() => { + Object.assign(window, { + Telegram: { + WebApp: { + initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef', + initDataUnsafe: {}, + ready() {}, + expand() {}, + showPopup(_params: unknown, cb: (id: string) => void) { + (window as unknown as { __popupCalled?: boolean }).__popupCalled = true; + setTimeout(() => cb(''), 0); + }, + }, + }, + }); + }); + await page.goto('/'); + await expect(page.getByText('Your turn')).toBeVisible(); // auto-authenticated lobby + await page.getByRole('button', { name: /Rick/ }).click(); + await expect(page.locator('[data-cell]').first()).toBeVisible(); + await page.locator('.scoreboard').click(); + await page.getByRole('button', { name: 'Export game' }).click(); + + // The app's own modal opened with the GCG option only — no PNG in an in-app WebView… + await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Image (PNG)' })).toHaveCount(0); + // …and the native popup was never used for the chooser. + expect( + await page.evaluate(() => (window as unknown as { __popupCalled?: boolean }).__popupCalled), + ).toBeFalsy(); +}); diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index 36c27f1..ddf85d2 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -80,7 +80,7 @@ test('AI game: after it ends, no GCG export and no comms entry', async ({ page } // Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and // no comms entry at all. await page.locator('.scoreboard').click(); - await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Export game' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0); }); diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 742044f..fe12a2a 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -154,14 +154,14 @@ test('GCG export appears only for a finished game', async ({ page }) => { // The finished game vs Kaya exposes export 📤 in the history header. await page.getByRole('button', { name: /Kaya/ }).click(); await page.locator('.scoreboard').click(); // open the history - await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible(); }); test('GCG export is hidden for an active game', async ({ page }) => { await loginLobby(page); await page.getByRole('button', { name: /Ann/ }).click(); await page.locator('.scoreboard').click(); // open the history (shows 🏁 leave, not 📤 export) - await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Export game' })).toHaveCount(0); }); test('finished game draws an inert footer and trims live-only controls', async ({ page }) => { @@ -172,7 +172,7 @@ test('finished game draws an inert footer and trims live-only controls', async ( await expect(page.locator('.tab').first()).toBeDisabled(); // The history header offers Export GCG, not Drop game, once the game is over. await page.locator('.scoreboard').click(); - await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0); // The comms hub offers Chat only — the Dictionary tab is hidden for a finished game. await page.getByRole('button', { name: 'Chat' }).click(); // 💬 diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 163e61f..7ae6d9e 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -22,7 +22,7 @@ import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { hintsLeft } from '../lib/hints'; - import { shareOrDownloadGcg } from '../lib/share'; + import { shareOrDownloadGcg, shareOrDownloadImage } from '../lib/share'; import { insideVK, vkCopyText } from '../lib/vk'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; @@ -931,6 +931,22 @@ return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied'); } + // The finished-game export offers two formats — the GCG file and the rendered PNG image — + // behind one 📤 button, always through the app's own modal. Never Telegram's native popup + // here: its callback runs with NO user activation, so navigator.share / clipboard writes + // silently fail from it (verified on-device, TG iOS + Android). A real DOM button click in + // the modal keeps the gesture alive for the delivery APIs. + let exportOpen = $state(false); + // The PNG option is withheld in an in-app WebView (Telegram / VK) for now: a binary image + // has no working delivery there (no Web Share, a dead , text-only clipboard, + // and the long-press menu mangles data: URLs). It returns with the server-rendered + // signed-URL delivery (Telegram downloadFile / VKWebAppDownloadFile). + const exportImageAvailable = $derived(!(insideTelegram() || insideVK())); + + function onExportClick() { + exportOpen = true; + } + async function exportGcg() { try { const gcg = await gateway.exportGcg(id); @@ -942,6 +958,22 @@ } } + // exportImage renders the finished game as a PNG (lib/gameimage, dynamically imported so the + // renderer stays out of the startup bundle) and delivers it — Web Share where the platform + // supports it, else a download (only reachable outside the in-app WebViews, see + // exportImageAvailable). + async function exportImage() { + if (!view) return; + try { + const { renderGameImage } = await import('../lib/gameimage'); + const blob = await renderGameImage(view.game, moves, { actionLabel: moveActionLabel }); + const file = new File([blob], `game-${id.slice(0, 8)}.png`, { type: 'image/png' }); + await shareOrDownloadImage(file); + } catch (e) { + handleError(e); + } + } + // The GCG export copies to the clipboard in an Android in-app WebView (no Web Share, a dead // ): VKWebAppCopyText inside VK — which also works in the desktop VK iframe, where // navigator.clipboard is blocked — and navigator.clipboard elsewhere. @@ -1274,7 +1306,7 @@ icon pinned right (the header is space-between). --> {:else} - + {/if} {:else} @@ -1456,6 +1488,25 @@ {/if} +{#if exportOpen} + (exportOpen = false)}> +
+ {#if exportImageAvailable} + + {/if} + +
+
+{/if} +