From 8e0d7f9e17d191b02095798da97d177c1b996804 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 18:14:19 +0200 Subject: [PATCH] feat(ui): external dictionary lookup link on the word-check tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a checked word is found, show a 'look it up' text link beside the complaint button that opens an external reference dictionary in a new tab: gramota.ru for the Russian variants, scrabblewordfinder.org for English (word lower-cased and percent-encoded). The link hides for a word that is not found. Inside Telegram it routes through the Mini App SDK's openLink, so Telegram opens it directly instead of the WebView's 'open this link?' confirmation; in a browser the anchor's own target=_blank handles it. Relabel the complaint button to 'Возражаю' (ru); English stays 'Disagree'. --- docs/FUNCTIONAL.md | 4 +++- docs/FUNCTIONAL_ru.md | 4 +++- ui/e2e/game.spec.ts | 7 +++++++ ui/src/game/CheckScreen.svelte | 32 +++++++++++++++++++++++++++++--- ui/src/lib/checkword.test.ts | 17 ++++++++++++++++- ui/src/lib/checkword.ts | 15 +++++++++++++++ ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 3 ++- ui/src/lib/telegram.test.ts | 23 ++++++++++++++++++++++- ui/src/lib/telegram.ts | 15 +++++++++++++++ 10 files changed, 113 insertions(+), 8 deletions(-) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 36c1c57..f5193e6 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -111,7 +111,9 @@ an existing word (down a column or across a row) is accepted. A play is validate against the game's dictionary at submit time and scored; an unlimited preview reports the word(s) a tentative move would form and its score, or that it is not legal, and the move is offered for submission only once it is confirmed legal. The dictionary check tool is -unlimited and offers a complaint on any result. Hints are governed per game — +unlimited and offers a complaint on any result; for a word it finds, it also links out to an +external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for +English) to look it up. Hints are governed per game — whether they are allowed and how many each player starts with — and draw on a personal hint wallet once the per-game allowance is spent. The game ends when the bag empties and a player clears their rack, after 6 consecutive scoreless turns, diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 2a7d38a..a08598a 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -115,7 +115,9 @@ nudge) приходят от бота **этой партии** — по язы сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и -предлагает пожаловаться на любой результат. Подсказки управляются настройками +предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на +внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских), +чтобы его посмотреть. Подсказки управляются настройками партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index 35c62a1..db5c8a1 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -221,6 +221,13 @@ test('check-word sanitises input and shows a verdict', async ({ page }) => { await page.locator('.check button').click(); // Check (enabled: length 3) await expect(page.locator('.ok, .bad')).toBeVisible(); + + // A found word offers an external dictionary lookup link that opens in a new tab; the + // verdict still carries the complaint button. + const lookup = page.getByRole('link', { name: 'Look it up' }); + await expect(lookup).toBeVisible(); + await expect(lookup).toHaveAttribute('target', '_blank'); + await expect(lookup).toHaveAttribute('href', /qza$/); }); test('dropping the game ends it and shows the result', async ({ page }) => { diff --git a/ui/src/game/CheckScreen.svelte b/ui/src/game/CheckScreen.svelte index b9889dd..b4c3ea3 100644 --- a/ui/src/game/CheckScreen.svelte +++ b/ui/src/game/CheckScreen.svelte @@ -4,7 +4,8 @@ import { handleError, showToast } from '../lib/app.svelte'; import { t } from '../lib/i18n/index.svelte'; import { alphabetLetters } from '../lib/alphabet'; - import { canCheckWord, sanitizeCheckWord } from '../lib/checkword'; + import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword'; + import { telegramOpenExternalLink } from '../lib/telegram'; import type { Variant } from '../lib/model'; // Word-check on its own screen: unlimited dictionary lookups, each with a @@ -57,6 +58,12 @@ handleError(e); } } + // Inside Telegram, route the dictionary link through the Mini App SDK so Telegram opens it in + // its in-app browser instead of the WebView's "open this link?" confirmation; in a plain + // browser the anchor's own target=_blank handles it. + function openLookup(e: MouseEvent) { + if (telegramOpenExternalLink((e.currentTarget as HTMLAnchorElement).href)) e.preventDefault(); + }
@@ -75,7 +82,17 @@ ? t('game.wordLegal', { word: result.word }) : t('game.wordIllegal', { word: result.word })}

- +
+ + {#if result.legal} + {t('game.lookup')} + {/if} +
{/if}
@@ -120,12 +137,21 @@ .verdict.bad { color: var(--danger, #c0392b); } + .actions { + display: flex; + align-items: center; + gap: 12px; + } .complain { - align-self: flex-start; padding: 8px 14px; border: 1px solid var(--border); background: var(--surface); color: var(--text); border-radius: var(--radius-sm); } + .lookup { + color: var(--accent); + text-decoration: underline; + font-size: 0.95em; + } diff --git a/ui/src/lib/checkword.test.ts b/ui/src/lib/checkword.test.ts index 5a05ccf..e675160 100644 --- a/ui/src/lib/checkword.test.ts +++ b/ui/src/lib/checkword.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { canCheckWord, MAX_WORD_LEN, sanitizeCheckWord } from './checkword'; +import { canCheckWord, dictionaryLookupUrl, MAX_WORD_LEN, sanitizeCheckWord } from './checkword'; const EN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); @@ -40,3 +40,18 @@ describe('canCheckWord', () => { expect(canCheckWord(' a ', false, false)).toBe(false); }); }); + +describe('dictionaryLookupUrl', () => { + it('points the Russian variants at gramota.ru with a lower-cased, encoded query', () => { + expect(dictionaryLookupUrl('КОТ', 'scrabble_ru')).toBe( + 'https://gramota.ru/poisk?query=' + encodeURIComponent('кот'), + ); + expect(dictionaryLookupUrl('ЭРА', 'erudit_ru')).toBe( + 'https://gramota.ru/poisk?query=' + encodeURIComponent('эра'), + ); + }); + + it('points English at scrabblewordfinder.org with a lower-cased path', () => { + expect(dictionaryLookupUrl('CAT', 'scrabble_en')).toBe('https://scrabblewordfinder.org/dictionary/cat'); + }); +}); diff --git a/ui/src/lib/checkword.ts b/ui/src/lib/checkword.ts index cdb32d6..89e50af 100644 --- a/ui/src/lib/checkword.ts +++ b/ui/src/lib/checkword.ts @@ -3,6 +3,8 @@ // variant alphabet, the length bounds, the answered-word cache and the cool-down // throttle) are unit-testable and stay in lockstep with the UI. +import type { Variant } from './model'; + /** The longest word that fits on a standard 15-cell board line. */ export const MAX_WORD_LEN = 15; /** The shortest word worth checking. */ @@ -29,3 +31,16 @@ export function canCheckWord(word: string, alreadyChecked: boolean, cooling: boo const w = word.trim(); return w.length >= MIN_WORD_LEN && w.length <= MAX_WORD_LEN && !alreadyChecked && !cooling; } + +/** + * dictionaryLookupUrl returns the external reference-dictionary search URL for a checked word + * in the given variant: gramota.ru for the Russian variants (scrabble_ru, erudit_ru) and + * scrabblewordfinder.org for English. The word is lower-cased and percent-encoded, so a + * Cyrillic query travels safely. + */ +export function dictionaryLookupUrl(word: string, variant: Variant): string { + const w = encodeURIComponent(word.toLowerCase()); + return variant.endsWith('_ru') + ? `https://gramota.ru/poisk?query=${w}` + : `https://scrabblewordfinder.org/dictionary/${w}`; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 7aaabe1..7092e19 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -92,6 +92,7 @@ export const en = { 'game.wordLegal': '“{word}” is valid', 'game.wordIllegal': '“{word}” is not valid', 'game.complain': 'Disagree', + 'game.lookup': 'Look it up', 'game.complaintSent': 'Thanks, sent for review.', 'game.check': 'Check', 'game.checkWait': 'Please wait a moment.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index fa5e58f..5107cc1 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -92,7 +92,8 @@ export const ru: Record = { 'game.checkWordPrompt': 'Введите слово', 'game.wordLegal': '«{word}» допустимо', 'game.wordIllegal': '«{word}» недопустимо', - 'game.complain': 'Не согласен', + 'game.complain': 'Возражаю', + 'game.lookup': 'Поискать в словаре', 'game.complaintSent': 'Спасибо, отправлено на проверку.', 'game.check': 'Проверить', 'game.checkWait': 'Секунду, пожалуйста.', diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts index 0428034..36b6b01 100644 --- a/ui/src/lib/telegram.test.ts +++ b/ui/src/lib/telegram.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { insideTelegram, telegramClosingConfirmation, telegramLaunch, telegramRequestFullscreen } from './telegram'; +import { + insideTelegram, + telegramClosingConfirmation, + telegramLaunch, + telegramOpenExternalLink, + telegramRequestFullscreen, +} from './telegram'; function stubWebApp(initData: string, startParam?: string) { vi.stubGlobal('window', { @@ -100,3 +106,18 @@ describe('telegramRequestFullscreen', () => { } }); }); + +describe('telegramOpenExternalLink', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('routes an external URL through the SDK openLink inside Telegram', () => { + const openLink = vi.fn(); + vi.stubGlobal('window', { Telegram: { WebApp: { openLink } } }); + expect(telegramOpenExternalLink('https://gramota.ru/poisk?query=кот')).toBe(true); + expect(openLink).toHaveBeenCalledWith('https://gramota.ru/poisk?query=кот'); + }); + + it('returns false without the SDK so the caller falls back to the anchor', () => { + expect(telegramOpenExternalLink('https://x.io')).toBe(false); + }); +}); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index 252252b..3088e58 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -18,6 +18,7 @@ interface TelegramWebApp { expand?: () => void; requestFullscreen?: () => void; openTelegramLink?: (url: string) => void; + openLink?: (url: string) => void; onEvent?: (event: string, handler: () => void) => void; setHeaderColor?: (color: string) => void; setBackgroundColor?: (color: string) => void; @@ -65,6 +66,20 @@ export function telegramOpenLink(url: string): boolean { return true; } +/** + * telegramOpenExternalLink opens an arbitrary external URL through the Mini App SDK's openLink, + * so Telegram opens it directly in its in-app browser instead of the WebView's generic "open + * this link?" confirmation that a plain target=_blank navigation triggers. Returns false + * outside Telegram or when the SDK lacks the method, so the caller can fall back to a normal + * anchor. + */ +export function telegramOpenExternalLink(url: string): boolean { + const w = webApp(); + if (!w?.openLink) return false; + w.openLink(url); + return true; +} + /** * shareTelegramLink opens Telegram's native "share to a chat" picker for url with a * caption, through the Mini App SDK (https://t.me/share/url). Returns false outside