Compare commits

..

6 Commits

Author SHA1 Message Date
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 91de26d80b Merge pull request 'Finalize docs to production + lobby/new-game UI tweaks + Telegram name fallback' (#107) from feature/finalize-docs into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m22s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 07:15:38 +00:00
Ilia Denisov 6b6362a629 fix(account): Telegram display-name falls back to the @username verbatim
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
When the Telegram first name yields no usable letters, fall back to the @username
taken whole (trimmed + length-capped, never character-stripped like the real name)
rather than a sanitized form; the generated placeholder is reached only when no
username is set. Precedence: real name -> @username (verbatim) -> placeholder.
2026-06-22 09:11:36 +02:00
Ilia Denisov 8a06fbc3c7 feat(ui): lobby invitation card redesign + new-game tweaks
- Lobby friend-invitation card: icon-only checkmark/cross actions stacked in a
  min-width right column; the middle column (From <name> + flag + variant rules,
  like New Game) grows and wraps. The cross now opens a decline-confirmation modal
  (mirroring the in-game resign confirm) instead of declining on first tap.
- New Game with a friend: a lone offered variant is pre-selected and its picker
  disabled (nothing else to choose); relabel 'Тип игры' -> 'Вариант' and
  'Подсказок на игрока' -> 'Подсказки'.
- Quick game: pin the Start button to the bottom of the screen, mirroring the
  friend-game Send-invitation button.
2026-06-22 09:11:36 +02:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
6 changed files with 121 additions and 38 deletions
+12 -5
View File
@@ -207,10 +207,11 @@ type provisionSeed struct {
// telegramSeed derives the create-time seed from Telegram launch fields: a // telegramSeed derives the create-time seed from Telegram launch fields: a
// supported preferred language from languageCode (an ISO-639 code, possibly // supported preferred language from languageCode (an ISO-639 code, possibly
// region-tagged like "ru-RU"), and a display name sanitized from firstName or, // region-tagged like "ru-RU"), and a display name. The name precedence is the real
// failing that, username (sanitizeDisplayName strips disallowed characters to the // name (firstName, sanitized to the editable format) → the @username taken verbatim
// editable format). When neither yields any letters, it falls back to a generated // (already a valid handle, only trimmed and length-capped, never character-stripped)
// placeholder in the seeded language (placeholderDisplayName). // → a generated placeholder in the seeded language (placeholderDisplayName), reached
// only when firstName has no usable letters and no username is set.
func telegramSeed(languageCode, username, firstName string) provisionSeed { func telegramSeed(languageCode, username, firstName string) provisionSeed {
var seed provisionSeed var seed provisionSeed
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" { if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
@@ -218,7 +219,13 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
} }
name := sanitizeDisplayName(firstName) name := sanitizeDisplayName(firstName)
if name == "" { if name == "" {
name = sanitizeDisplayName(username) // The real name yielded nothing usable: fall back to the @username verbatim
// (Telegram guarantees a valid handle), only trimmed and capped to the column
// width — never character-stripped like the real name.
name = strings.TrimSpace(username)
if r := []rune(name); len(r) > maxDisplayName {
name = strings.TrimRight(string(r[:maxDisplayName]), " ")
}
} }
if name == "" { if name == "" {
name = placeholderDisplayName(seed.preferredLanguage) name = placeholderDisplayName(seed.preferredLanguage)
+8 -6
View File
@@ -9,8 +9,9 @@ import (
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the // TestTelegramSeed covers the pure mapping from Telegram launch fields to the
// create-time account seed: supported-language detection (bare and region-tagged), // create-time account seed: supported-language detection (bare and region-tagged),
// the first-name / username display-name precedence, and the sanitization that // the real-name → @username (verbatim) → placeholder display-name precedence, and
// strips disallowed characters (emoji, digits, punctuation) to the editable format. // the sanitization of the real name (emoji, digits, punctuation stripped to the
// editable format). The username, when used, is kept verbatim.
func TestTelegramSeed(t *testing.T) { func TestTelegramSeed(t *testing.T) {
cases := map[string]struct { cases := map[string]struct {
languageCode, username, firstName string languageCode, username, firstName string
@@ -28,6 +29,7 @@ func TestTelegramSeed(t *testing.T) {
"punct to space": {"en", "user", "John❤Doe", "en", "John Doe"}, "punct to space": {"en", "user", "John❤Doe", "en", "John Doe"},
"digits dropped": {"ru", "user", "Маша123", "ru", "Маша"}, "digits dropped": {"ru", "user", "Маша123", "ru", "Маша"},
"garbage to username": {"en", "good", "123!@#", "en", "good"}, "garbage to username": {"en", "good", "123!@#", "en", "good"},
"username verbatim": {"en", "co_ol99", "🎮🎮", "en", "co_ol99"},
} }
for name, tc := range cases { for name, tc := range cases {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
@@ -49,10 +51,10 @@ func TestTelegramSeedPlaceholder(t *testing.T) {
languageCode, username, firstName string languageCode, username, firstName string
wantRe string wantRe string
}{ }{
"en empty": {"en", "", "", `^Player-\d{5}$`}, "en empty": {"en", "", "", `^Player-\d{5}$`},
"ru empty": {"ru", "", "", `^Игрок-\d{5}$`}, "ru empty": {"ru", "", "", `^Игрок-\d{5}$`},
"default en": {"fr", "", "", `^Player-\d{5}$`}, "default en": {"fr", "", "", `^Player-\d{5}$`},
"both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`}, "name garbage, no username": {"ru", "", "!!!", `^Игрок-\d{5}$`},
} }
for name, tc := range cases { for name, tc := range cases {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
+3 -2
View File
@@ -257,6 +257,7 @@ export const en = {
'invitations.with': 'With {names}', 'invitations.with': 'With {names}',
'invitations.accept': 'Accept', 'invitations.accept': 'Accept',
'invitations.decline': 'Decline', 'invitations.decline': 'Decline',
'invitations.declineConfirm': 'Decline invitation?',
'invitations.cancel': 'Cancel', 'invitations.cancel': 'Cancel',
'invitations.waiting': 'Waiting for replies', 'invitations.waiting': 'Waiting for replies',
@@ -264,10 +265,10 @@ export const en = {
'new.withFriends': 'Play with friends', 'new.withFriends': 'Play with friends',
'new.pickFriends': 'Choose who to invite', 'new.pickFriends': 'Choose who to invite',
'new.searchFriends': 'Search friends', 'new.searchFriends': 'Search friends',
'new.gameType': 'Game type', 'new.gameType': 'Variant',
'new.invite': 'Send invitation', 'new.invite': 'Send invitation',
'new.moveTime': 'Move time', 'new.moveTime': 'Move time',
'new.hintsPerPlayer': 'Hints per player', 'new.hintsPerPlayer': 'Hints',
'new.multipleWordsPerTurn': 'Multiple words per turn', 'new.multipleWordsPerTurn': 'Multiple words per turn',
'new.start': 'Start game', 'new.start': 'Start game',
'new.invited': 'Invitation sent.', 'new.invited': 'Invitation sent.',
+3 -2
View File
@@ -258,6 +258,7 @@ export const ru: Record<MessageKey, string> = {
'invitations.with': 'С {names}', 'invitations.with': 'С {names}',
'invitations.accept': 'Принять', 'invitations.accept': 'Принять',
'invitations.decline': 'Отклонить', 'invitations.decline': 'Отклонить',
'invitations.declineConfirm': 'Отклонить приглашение?',
'invitations.cancel': 'Отменить', 'invitations.cancel': 'Отменить',
'invitations.waiting': 'Ожидаем ответы', 'invitations.waiting': 'Ожидаем ответы',
@@ -265,10 +266,10 @@ export const ru: Record<MessageKey, string> = {
'new.withFriends': 'Игра с друзьями', 'new.withFriends': 'Игра с друзьями',
'new.pickFriends': 'Кого пригласить', 'new.pickFriends': 'Кого пригласить',
'new.searchFriends': 'Поиск друзей', 'new.searchFriends': 'Поиск друзей',
'new.gameType': 'Тип игры', 'new.gameType': 'Вариант',
'new.invite': 'Отправить приглашение', 'new.invite': 'Отправить приглашение',
'new.moveTime': 'Время на ход', 'new.moveTime': 'Время на ход',
'new.hintsPerPlayer': 'Подсказок на игрока', 'new.hintsPerPlayer': 'Подсказки',
'new.multipleWordsPerTurn': 'Несколько слов за ход', 'new.multipleWordsPerTurn': 'Несколько слов за ход',
'new.start': 'Начать игру', 'new.start': 'Начать игру',
'new.invited': 'Приглашение отправлено.', 'new.invited': 'Приглашение отправлено.',
+83 -20
View File
@@ -3,17 +3,19 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import Screen from '../components/Screen.svelte'; import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte'; import TabBar from '../components/TabBar.svelte';
import Modal from '../components/Modal.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte'; import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result'; import { resultBadge } from '../lib/result';
import { badgeKind } from '../lib/unread'; import { badgeKind } from '../lib/unread';
import { getLobby, setLobby } from '../lib/lobbycache'; import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload'; import { preloadGames } from '../lib/preload';
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort'; import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model'; import type { AccountRef, GameView, Invitation } from '../lib/model';
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
let games = $state<GameView[]>([]); let games = $state<GameView[]>([]);
let invitations = $state<Invitation[]>([]); let invitations = $state<Invitation[]>([]);
@@ -209,11 +211,14 @@
} }
} }
const variantKey: Record<string, MessageKey> = { // The invitation pending a decline confirmation: the ❌ opens a modal (mirroring the
scrabble_en: 'new.english', // in-game resign confirmation) rather than declining on the first tap.
scrabble_ru: 'new.russian', let declineTarget = $state<Invitation | null>(null);
erudit_ru: 'new.erudit', function confirmDecline() {
}; const inv = declineTarget;
declineTarget = null;
if (inv) declineInvite(inv);
}
</script> </script>
<Screen title={app.profile?.displayName ?? t('app.title')}> <Screen title={app.profile?.displayName ?? t('app.title')}>
@@ -230,16 +235,23 @@
<span class="sub">{t('invitations.waiting')}</span> <span class="sub">{t('invitations.waiting')}</span>
{:else} {:else}
<span class="who">{t('invitations.from', { name: inv.inviter.displayName })}</span> <span class="who">{t('invitations.from', { name: inv.inviter.displayName })}</span>
<span class="sub">{t(variantKey[inv.variant] ?? 'new.english')}</span> <span class="vrow">
{#if VARIANT_FLAG[inv.variant]}
<span class="vflag">{VARIANT_FLAG[inv.variant]}</span>
{:else}
<img class="vflag-img" src="flag-ussr.svg" alt="" />
{/if}
<span class="sub">{t(VARIANT_RULES[inv.variant])}</span>
</span>
{/if} {/if}
{#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if} {#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if}
</span> </span>
<span class="acts"> <span class="acts">
{#if inv.inviter.accountId === myId} {#if inv.inviter.accountId === myId}
<button class="ghost" onclick={() => cancelInvite(inv)}>{t('invitations.cancel')}</button> <button class="iconbtn" onclick={() => cancelInvite(inv)} aria-label={t('invitations.cancel')}></button>
{:else} {:else}
<button class="btn" onclick={() => acceptInvite(inv)}>{t('invitations.accept')}</button> <button class="iconbtn" onclick={() => acceptInvite(inv)} aria-label={t('invitations.accept')}></button>
<button class="ghost" onclick={() => declineInvite(inv)}>{t('invitations.decline')}</button> <button class="iconbtn" onclick={() => (declineTarget = inv)} aria-label={t('invitations.decline')}></button>
{/if} {/if}
</span> </span>
</div> </div>
@@ -308,6 +320,15 @@
{/if} {/if}
</div> </div>
{#if declineTarget}
<Modal title={t('invitations.declineConfirm')} onclose={() => (declineTarget = null)}>
<div class="confirm-row">
<button class="cancel" onclick={() => (declineTarget = null)}>{t('common.cancel')}</button>
<button class="danger" onclick={confirmDecline} disabled={!connection.online}>{t('invitations.decline')}</button>
</div>
</Modal>
{/if}
{#snippet tabbar()} {#snippet tabbar()}
<TabBar> <TabBar>
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}> <button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
@@ -365,6 +386,40 @@
border-radius: var(--radius); border-radius: var(--radius);
user-select: none; user-select: none;
} }
/* The middle column grows; the envelope and the action column stay at their natural width. */
.invite .info {
flex: 1;
}
/* The variant row in an invitation: flag + the rules summary (mirrors NewGame). */
.vrow {
display: flex;
align-items: baseline;
gap: 6px;
}
.vflag {
font-size: 1.1rem;
line-height: 1;
}
.vflag-img {
width: 1.3rem;
height: auto;
border-radius: 2px;
align-self: center;
}
/* Borderless icon action (✅ / ❌), like the in-game .hicon buttons. */
.iconbtn {
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1;
padding: 4px 6px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.iconbtn:active {
background: var(--bg-elev);
}
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between /* Game rows are a compact, flat list: no per-card frame, a hairline divider between
consecutive rows. */ consecutive rows. */
.list { .list {
@@ -516,23 +571,31 @@
opacity: 0; opacity: 0;
} }
} }
/* The ✅ / ❌ actions stack vertically with a small gap — a min-width right column. */
.acts { .acts {
display: flex; display: flex;
gap: 8px; flex-direction: column;
gap: 10px;
flex: 0 0 auto; flex: 0 0 auto;
justify-content: center;
} }
.btn { /* Decline-confirmation modal (mirrors the in-game resign confirm). */
padding: 8px 12px; .confirm-row {
border: 1px solid var(--accent); display: flex;
background: var(--accent); gap: 8px;
color: var(--accent-text); }
.confirm-row button {
flex: 1;
padding: 11px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
}
.ghost {
padding: 8px 12px;
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); font-weight: 600;
}
.danger {
background: var(--danger) !important;
color: #fff !important;
border-color: var(--danger) !important;
} }
</style> </style>
+12 -3
View File
@@ -67,9 +67,12 @@
let friends = $state<AccountRef[]>([]); let friends = $state<AccountRef[]>([]);
let selected = $state<string[]>([]); let selected = $state<string[]>([]);
let friendFilter = $state(''); let friendFilter = $state('');
// No default game type yet — the player must pick one (a smarter default from play // A lone offered variant is pre-selected and its picker disabled (nothing else to choose);
// history / language would be a future refinement). '' renders the disabled placeholder option. // with several, the player picks. '' renders the disabled placeholder option.
let inviteVariant = $state<Variant | ''>(''); let inviteVariant = $state<Variant | ''>('');
$effect(() => {
if (variants.length === 1 && !inviteVariant) inviteVariant = variants[0].id;
});
let timeoutSecs = $state(86400); let timeoutSecs = $state(86400);
let hints = $state(1); let hints = $state(1);
@@ -152,6 +155,7 @@
<input type="checkbox" bind:checked={multipleWords} /> <input type="checkbox" bind:checked={multipleWords} />
</label> </label>
{/if} {/if}
<div class="grow"></div>
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p> <p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if} {#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
<button <button
@@ -179,7 +183,7 @@
<div class="settings-row"> <div class="settings-row">
<label class="field"> <label class="field">
<span>{t('new.gameType')}</span> <span>{t('new.gameType')}</span>
<select bind:value={inviteVariant} class:placeholder={!inviteVariant}> <select bind:value={inviteVariant} class:placeholder={!inviteVariant} disabled={variants.length === 1}>
<option value="" disabled></option> <option value="" disabled></option>
{#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each} {#each variants as v (v.id)}<option value={v.id}>{t(v.label)}</option>{/each}
</select> </select>
@@ -387,6 +391,11 @@
color: var(--text-muted); color: var(--text-muted);
margin: 0; margin: 0;
} }
/* Pushes the auto-match start cluster (move-limit hint + Start button) to the bottom,
mirroring the friend-game invite button pinned by the .fg scroll area. */
.grow {
flex: 1;
}
.invite { .invite {
flex: 0 0 auto; flex: 0 0 auto;
padding: 14px; padding: 14px;