feat(ui): external dictionary lookup link on the word-check tool
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s

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'.
This commit is contained in:
Ilia Denisov
2026-06-15 18:14:19 +02:00
parent 4d6df4bd8b
commit 8e0d7f9e17
10 changed files with 113 additions and 8 deletions
+3 -1
View File
@@ -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 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 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 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 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 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, bag empties and a player clears their rack, after 6 consecutive scoreless turns,
+3 -1
View File
@@ -115,7 +115,9 @@ nudge) приходят от бота **этой партии** — по язы
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и
предлагает пожаловаться на любой результат. Подсказки управляются настройками предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
чтобы его посмотреть. Подсказки управляются настройками
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
+7
View File
@@ -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 page.locator('.check button').click(); // Check (enabled: length 3)
await expect(page.locator('.ok, .bad')).toBeVisible(); 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 }) => { test('dropping the game ends it and shows the result', async ({ page }) => {
+29 -3
View File
@@ -4,7 +4,8 @@
import { handleError, showToast } from '../lib/app.svelte'; import { handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { alphabetLetters } from '../lib/alphabet'; 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'; import type { Variant } from '../lib/model';
// Word-check on its own screen: unlimited dictionary lookups, each with a // Word-check on its own screen: unlimited dictionary lookups, each with a
@@ -57,6 +58,12 @@
handleError(e); 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();
}
</script> </script>
<div class="wrap"> <div class="wrap">
@@ -75,7 +82,17 @@
? t('game.wordLegal', { word: result.word }) ? t('game.wordLegal', { word: result.word })
: t('game.wordIllegal', { word: result.word })} : t('game.wordIllegal', { word: result.word })}
</p> </p>
<button class="complain" onclick={complain}>{t('game.complain')}</button> <div class="actions">
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{#if result.legal}
<a
class="lookup"
href={dictionaryLookupUrl(result.word, variant)}
target="_blank"
rel="noopener noreferrer"
onclick={openLookup}>{t('game.lookup')}</a>
{/if}
</div>
{/if} {/if}
</div> </div>
@@ -120,12 +137,21 @@
.verdict.bad { .verdict.bad {
color: var(--danger, #c0392b); color: var(--danger, #c0392b);
} }
.actions {
display: flex;
align-items: center;
gap: 12px;
}
.complain { .complain {
align-self: flex-start;
padding: 8px 14px; padding: 8px 14px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.lookup {
color: var(--accent);
text-decoration: underline;
font-size: 0.95em;
}
</style> </style>
+16 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; 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(''); const EN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
@@ -40,3 +40,18 @@ describe('canCheckWord', () => {
expect(canCheckWord(' a ', false, false)).toBe(false); 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');
});
});
+15
View File
@@ -3,6 +3,8 @@
// variant alphabet, the length bounds, the answered-word cache and the cool-down // variant alphabet, the length bounds, the answered-word cache and the cool-down
// throttle) are unit-testable and stay in lockstep with the UI. // 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. */ /** The longest word that fits on a standard 15-cell board line. */
export const MAX_WORD_LEN = 15; export const MAX_WORD_LEN = 15;
/** The shortest word worth checking. */ /** The shortest word worth checking. */
@@ -29,3 +31,16 @@ export function canCheckWord(word: string, alreadyChecked: boolean, cooling: boo
const w = word.trim(); const w = word.trim();
return w.length >= MIN_WORD_LEN && w.length <= MAX_WORD_LEN && !alreadyChecked && !cooling; 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}`;
}
+1
View File
@@ -92,6 +92,7 @@ export const en = {
'game.wordLegal': '“{word}” is valid', 'game.wordLegal': '“{word}” is valid',
'game.wordIllegal': '“{word}” is not valid', 'game.wordIllegal': '“{word}” is not valid',
'game.complain': 'Disagree', 'game.complain': 'Disagree',
'game.lookup': 'Look it up',
'game.complaintSent': 'Thanks, sent for review.', 'game.complaintSent': 'Thanks, sent for review.',
'game.check': 'Check', 'game.check': 'Check',
'game.checkWait': 'Please wait a moment.', 'game.checkWait': 'Please wait a moment.',
+2 -1
View File
@@ -92,7 +92,8 @@ export const ru: Record<MessageKey, string> = {
'game.checkWordPrompt': 'Введите слово', 'game.checkWordPrompt': 'Введите слово',
'game.wordLegal': '«{word}» допустимо', 'game.wordLegal': '«{word}» допустимо',
'game.wordIllegal': '«{word}» недопустимо', 'game.wordIllegal': '«{word}» недопустимо',
'game.complain': 'Не согласен', 'game.complain': 'Возражаю',
'game.lookup': 'Поискать в словаре',
'game.complaintSent': 'Спасибо, отправлено на проверку.', 'game.complaintSent': 'Спасибо, отправлено на проверку.',
'game.check': 'Проверить', 'game.check': 'Проверить',
'game.checkWait': 'Секунду, пожалуйста.', 'game.checkWait': 'Секунду, пожалуйста.',
+22 -1
View File
@@ -1,5 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; 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) { function stubWebApp(initData: string, startParam?: string) {
vi.stubGlobal('window', { 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);
});
});
+15
View File
@@ -18,6 +18,7 @@ interface TelegramWebApp {
expand?: () => void; expand?: () => void;
requestFullscreen?: () => void; requestFullscreen?: () => void;
openTelegramLink?: (url: string) => void; openTelegramLink?: (url: string) => void;
openLink?: (url: string) => void;
onEvent?: (event: string, handler: () => void) => void; onEvent?: (event: string, handler: () => void) => void;
setHeaderColor?: (color: string) => void; setHeaderColor?: (color: string) => void;
setBackgroundColor?: (color: string) => void; setBackgroundColor?: (color: string) => void;
@@ -65,6 +66,20 @@ export function telegramOpenLink(url: string): boolean {
return true; 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 * 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 * caption, through the Mini App SDK (https://t.me/share/url). Returns false outside