feat(ui): batch of UI polish tweaks
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
- admin dashboard "Users" count excludes robots (CountUsers, humans only) - lobby finished-game delete reveal: background matches header/tab-bar (--bg-elev) - mobile: swipe up on the zoom-out board (no staged tiles) shuffles the rack - lobby: status icons -25%, game-row top/bottom padding halved - toast: info tier drifts up ~a tab-bar height while fading over 2s and dismisses on tap; error tier unchanged - game: confirm-move button stays pinned at the right edge; rack tiles shrink to fit so a full rack never overflows onto it - telegram: a successful invite-link redeem shows a welcome window pointing at the bot - settings: remove the grid-lines toggle; the board is always a gapless checkerboard - settings/comms hubs: the selected tab highlight wraps icon + label as a pill Docs: UI_DESIGN (toast, board surface, selected tab), PLAN; e2e updated for the removed grid-lines toggle.
This commit is contained in:
@@ -776,7 +776,7 @@ cannot submit; three-way admin filter.
|
|||||||
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
|
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
|
||||||
board **zoom reworked** to a width-based zoom in a fixed viewport (real native
|
board **zoom reworked** to a width-based zoom in a fixed viewport (real native
|
||||||
scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter
|
scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter
|
||||||
tiles, contrasting grid lines, last-word dark-tile highlight, and a Settings
|
tiles, last-word dark-tile highlight, and a Settings
|
||||||
**bonus-label style** (beginner/
|
**bonus-label style** (beginner/
|
||||||
classic/none); **hint lays its tiles on the board** (no spend when no move — a new
|
classic/none); **hint lays its tiles on the board** (no spend when no move — a new
|
||||||
`no_hint_available` result code); the history opens as an in-place **slide-down**
|
`no_hint_available` result code); the history opens as an in-place **slide-down**
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
func (s *Server) consoleDashboard(c *gin.Context) {
|
func (s *Server) consoleDashboard(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
view := adminconsole.DashboardView{Variants: s.variantVersions(), ActiveVersion: s.games.ActiveVersion()}
|
view := adminconsole.DashboardView{Variants: s.variantVersions(), ActiveVersion: s.games.ActiveVersion()}
|
||||||
view.Accounts, _ = s.accounts.CountAccounts(ctx)
|
// The Users card counts people only: robots are pool infrastructure, not registered
|
||||||
|
// users, so an empty filter (Robots: false) excludes them.
|
||||||
|
view.Accounts, _ = s.accounts.CountUsers(ctx, account.UserFilter{})
|
||||||
view.Games, _ = s.games.CountGames(ctx, "")
|
view.Games, _ = s.games.CountGames(ctx, "")
|
||||||
view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
|
view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
|
||||||
view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen)
|
view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen)
|
||||||
|
|||||||
+11
-8
@@ -172,11 +172,9 @@ except Login uses `Screen`.
|
|||||||
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
||||||
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
||||||
nothing. Default **beginner**.
|
nothing. Default **beginner**.
|
||||||
- **Grid lines** — a Settings toggle, **default off**. Off: a **gapless
|
- **Board surface** — always a **gapless checkerboard** (there is no grid-lines setting):
|
||||||
checkerboard** — plain cells alternate two shades, and tiles get rounded corners with a
|
plain cells alternate two shades, and tiles get rounded corners with a soft right-side
|
||||||
soft right-side shadow so adjacent gapless tiles still read apart, reclaiming ~14px of
|
shadow so adjacent gapless tiles still read apart, reclaiming ~14px of board width.
|
||||||
board width. On: the classic lined grid, where the inter-cell gap shows a contrasting
|
|
||||||
`--cell-line` (darker in light, lighter in dark) to avoid a wavy-line optical illusion.
|
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
@@ -242,7 +240,9 @@ game **becomes your turn or finishes** while the lobby is open, that status emoj
|
|||||||
twice** (a two-cycle opacity fade, ~2 s; suppressed under reduce-motion) to draw the eye — the
|
twice** (a two-cycle opacity fade, ~2 s; suppressed under reduce-motion) to draw the eye — the
|
||||||
opponent's-turn change is silent. Each card's blink is keyed by game id, so overlapping
|
opponent's-turn change is silent. Each card's blink is keyed by game id, so overlapping
|
||||||
events animate in isolation, and the newest bottom toast cancels the previous one and replays
|
events animate in isolation, and the newest bottom toast cancels the previous one and replays
|
||||||
its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence).
|
its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence). An **info** toast
|
||||||
|
lives ~2 s, drifting up by about the tab-bar height as it fades (a plain fade, no travel, under
|
||||||
|
reduce-motion); an **error** toast dwells longer (~4 s). Either dismisses instantly on tap.
|
||||||
|
|
||||||
**Simultaneous-game cap.** When the player is at the active-quick-game cap (`games.list`
|
**Simultaneous-game cap.** When the player is at the active-quick-game cap (`games.list`
|
||||||
`at_game_limit`), the lobby's **New Game** tab is **disabled** (greyed via the shared
|
`at_game_limit`), the lobby's **New Game** tab is **disabled** (greyed via the shared
|
||||||
@@ -255,8 +255,11 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
|||||||
|
|
||||||
- **Settings hub** (`screens/SettingsHub.svelte`, the lobby ⚙️ tab): one nav bar + a bottom
|
- **Settings hub** (`screens/SettingsHub.svelte`, the lobby ⚙️ tab): one nav bar + a bottom
|
||||||
tab bar over four bodies — ⚙️ Settings, 👤 Profile, 🤝 Friends, ℹ️ About — switched in
|
tab bar over four bodies — ⚙️ Settings, 👤 Profile, 🤝 Friends, ℹ️ About — switched in
|
||||||
place; back always returns to the lobby. Guests see all but Friends. The lobby ⚙️ badge and
|
place; back always returns to the lobby. The **selected** tab is marked by a filled pill
|
||||||
the 🤝 tab badge both show the pending incoming-friend-request count.
|
hugging its icon **and** label (with an accent underline) — a persistent selection cue that
|
||||||
|
the plain, unhighlighted action tabs (lobby nav, in-game controls) never carry. Guests see
|
||||||
|
all but Friends. The lobby ⚙️ badge and the 🤝 tab badge both show the pending
|
||||||
|
incoming-friend-request count.
|
||||||
- **Friends** (`screens/Friends.svelte`, the Settings hub's 🤝 tab): an "add a friend" block
|
- **Friends** (`screens/Friends.svelte`, the Settings hub's 🤝 tab): an "add a friend" block
|
||||||
pairing a code **input** with a **Show my code** action that reveals a large 6-digit
|
pairing a code **input** with a **Show my code** action that reveals a large 6-digit
|
||||||
code + its expiry; then the incoming **requests** (Accept / Decline), the **friends**
|
code + its expiry; then the incoming **requests** (Accept / Decline), the **friends**
|
||||||
|
|||||||
+7
-8
@@ -123,16 +123,15 @@ test('a pending tile recalls on double-tap, not on a single tap', async ({ page
|
|||||||
await expect(page.locator('[data-cell].pending')).toHaveCount(0);
|
await expect(page.locator('[data-cell].pending')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the board is a gapless checkerboard by default; grid lines toggle in Settings', async ({ page }) => {
|
test('the board is always a gapless checkerboard; no grid-lines setting', async ({ page }) => {
|
||||||
await openGame(page);
|
await openGame(page);
|
||||||
await expect(page.locator('.grid.gridless')).toBeVisible(); // lines off by default
|
|
||||||
|
|
||||||
await page.evaluate(() => (location.hash = '/settings'));
|
|
||||||
await page.locator('.gridlines input').check(); // turn grid lines on
|
|
||||||
await page.evaluate(() => (location.hash = '/game/g1'));
|
|
||||||
|
|
||||||
await expect(page.locator('.grid')).toBeVisible();
|
await expect(page.locator('.grid')).toBeVisible();
|
||||||
await expect(page.locator('.grid.gridless')).toHaveCount(0);
|
// The board has no lined variant any more: its cells sit flush, with no inter-cell gap.
|
||||||
|
await expect(page.locator('.grid')).toHaveCSS('column-gap', '0px');
|
||||||
|
|
||||||
|
// Settings no longer offers a grid-lines toggle.
|
||||||
|
await page.evaluate(() => (location.hash = '/settings'));
|
||||||
|
await expect(page.locator('.gridlines')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => {
|
test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
||||||
import Toast from './components/Toast.svelte';
|
import Toast from './components/Toast.svelte';
|
||||||
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
||||||
|
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||||
import Login from './screens/Login.svelte';
|
import Login from './screens/Login.svelte';
|
||||||
import Lobby from './screens/Lobby.svelte';
|
import Lobby from './screens/Lobby.svelte';
|
||||||
import NewGame from './screens/NewGame.svelte';
|
import NewGame from './screens/NewGame.svelte';
|
||||||
@@ -110,6 +111,7 @@
|
|||||||
|
|
||||||
<Toast />
|
<Toast />
|
||||||
<StaleInviteModal />
|
<StaleInviteModal />
|
||||||
|
<WelcomeRedeemModal />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.splash {
|
.splash {
|
||||||
|
|||||||
@@ -47,7 +47,6 @@
|
|||||||
locale: lc,
|
locale: lc,
|
||||||
reduceMotion: prefs.reduceMotion ?? false,
|
reduceMotion: prefs.reduceMotion ?? false,
|
||||||
boardLabels: prefs.boardLabels ?? 'beginner',
|
boardLabels: prefs.boardLabels ?? 'beginner',
|
||||||
boardLines: prefs.boardLines ?? false,
|
|
||||||
});
|
});
|
||||||
prefs = { ...prefs, locale: lc };
|
prefs = { ...prefs, locale: lc };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,10 +51,25 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition: background-color 0.12s;
|
transition: background-color 0.12s;
|
||||||
}
|
}
|
||||||
/* A tab that navigates between peer views (Settings / Comms hubs) stays highlighted
|
/* A tab that navigates between peer views (Settings / Comms hubs) wraps its icon and label
|
||||||
while selected: a filled square with an accent underline. A tap itself leaves no
|
in a .face so the selected highlight hugs both — a filled pill with an accent underline,
|
||||||
highlight — there is no press tint, and the WebKit tap flash is disabled on .tab. */
|
sized to the content with a small padding. A tap itself leaves no highlight (no press
|
||||||
:global(.tab.active .sq) {
|
tint, and the WebKit tap flash is disabled on .tab). Plain action tabs (the lobby nav,
|
||||||
|
the in-game controls) carry no .face and are never .active, so they are unaffected. */
|
||||||
|
:global(.tab .face) {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: background-color 0.12s;
|
||||||
|
}
|
||||||
|
/* Inside a face the icon square needs no padding of its own — the face provides the
|
||||||
|
surround (the bare .sq padding still applies to the unwrapped action tabs). */
|
||||||
|
:global(.tab .face .sq) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
:global(.tab.active .face) {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
box-shadow: inset 0 -2px 0 var(--accent);
|
box-shadow: inset 0 -2px 0 var(--accent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade, fly } from 'svelte/transition';
|
import { fade, fly } from 'svelte/transition';
|
||||||
import { app } from '../lib/app.svelte';
|
import { app, dismissToast } from '../lib/app.svelte';
|
||||||
|
|
||||||
const dur = $derived(app.reduceMotion ? 0 : 260);
|
const dur = $derived(app.reduceMotion ? 0 : 260);
|
||||||
|
// An info bubble owns its whole 2s life through a CSS rise-and-fade animation, so it takes
|
||||||
|
// no Svelte enter/leave transition; an error keeps the fly-in / fade-out dwell behaviour.
|
||||||
|
const isInfo = $derived(app.toast?.kind !== 'error');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if app.toast}
|
{#if app.toast}
|
||||||
<!-- Re-key on the toast's seq so a fresh message tears the old node down (out:fade) and replays
|
<!-- Re-key on the toast's seq so a fresh message tears the old node down and replays the
|
||||||
the entrance (in:fly): the newest toast cancels the previous one and starts its own cycle. -->
|
appear cycle: the newest toast cancels the previous one and starts its own. -->
|
||||||
{#key app.toast.seq}
|
{#key app.toast.seq}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
class="toast {app.toast.kind}"
|
class="toast {app.toast.kind}"
|
||||||
|
class:rise={isInfo && !app.reduceMotion}
|
||||||
|
class:rise-reduced={isInfo && app.reduceMotion}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
in:fly={{ y: 32, duration: dur }}
|
onclick={dismissToast}
|
||||||
out:fade={{ duration: dur }}
|
in:fly={{ y: isInfo ? 0 : 32, duration: isInfo ? 0 : dur }}
|
||||||
|
out:fade={{ duration: isInfo ? 0 : dur }}
|
||||||
>
|
>
|
||||||
{app.toast.text}
|
{app.toast.text}
|
||||||
</div>
|
</div>
|
||||||
@@ -36,9 +44,45 @@
|
|||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
/* An info bubble fades in, then drifts up by roughly the tab-bar height while fading out,
|
||||||
|
all within 2s; the X centring is preserved across the rise. */
|
||||||
|
.toast.rise {
|
||||||
|
animation: toast-rise 2s ease-out forwards;
|
||||||
|
}
|
||||||
|
@keyframes toast-rise {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(8px);
|
||||||
|
}
|
||||||
|
12% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(-56px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Reduced motion: the same 2s life, fade only — no upward travel. */
|
||||||
|
.toast.rise-reduced {
|
||||||
|
animation: toast-rise-reduced 2s ease-out forwards;
|
||||||
|
}
|
||||||
|
@keyframes toast-rise-reduced {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
12%,
|
||||||
|
70% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { app, dismissWelcomeRedeem } from '../lib/app.svelte';
|
||||||
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
import { botUsername } from '../lib/deeplink';
|
||||||
|
import { telegramOpenLink } from '../lib/telegram';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
|
// Point at the bot the player signed in through (its service language), falling back to the
|
||||||
|
// interface locale, so an ru player is sent to the ru bot and an en player to the en one.
|
||||||
|
const username = $derived(botUsername(app.session?.serviceLanguage || app.locale));
|
||||||
|
// Greet the arriving player by their own display name.
|
||||||
|
const name = $derived(app.profile?.displayName || app.session?.displayName || '');
|
||||||
|
// Interpolate the name, then split the rest around the {bot} token so the bot handle renders
|
||||||
|
// as an inline link (the {name} value is substituted first; {bot} is left for the split).
|
||||||
|
const parts = $derived(t('friends.welcomeRedeem', { name }).split('{bot}'));
|
||||||
|
|
||||||
|
function openBot() {
|
||||||
|
if (username) {
|
||||||
|
const url = `https://t.me/${username}`;
|
||||||
|
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
|
||||||
|
// back to a new tab.
|
||||||
|
if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
dismissWelcomeRedeem();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if app.welcomeRedeem}
|
||||||
|
<Modal title={t('friends.welcomeRedeemTitle')} onclose={dismissWelcomeRedeem}>
|
||||||
|
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
|
||||||
|
<button class="ok" onclick={dismissWelcomeRedeem}>{t('common.ok')}</button>
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.msg {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
/* The greeting and the bot sentence are separated by a blank line in the message. */
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.bot {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+18
-33
@@ -18,7 +18,6 @@
|
|||||||
landscape,
|
landscape,
|
||||||
variant,
|
variant,
|
||||||
labelMode,
|
labelMode,
|
||||||
lines,
|
|
||||||
locale,
|
locale,
|
||||||
focus,
|
focus,
|
||||||
recenter,
|
recenter,
|
||||||
@@ -41,8 +40,6 @@
|
|||||||
landscape: boolean;
|
landscape: boolean;
|
||||||
variant: Variant;
|
variant: Variant;
|
||||||
labelMode: BoardLabelMode;
|
labelMode: BoardLabelMode;
|
||||||
/** Draw 1px grid lines between cells; when false the board is a gapless checkerboard. */
|
|
||||||
lines: boolean;
|
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
focus: { row: number; col: number } | null;
|
focus: { row: number; col: number } | null;
|
||||||
/** A monotonic nonce the parent bumps to request a scroll to `focus` even when the zoom state
|
/** A monotonic nonce the parent bumps to request a scroll to `focus` even when the zoom state
|
||||||
@@ -234,7 +231,7 @@
|
|||||||
|
|
||||||
<div class="viewport" class:zoomed class:land={landscape} bind:this={viewport}>
|
<div class="viewport" class:zoomed class:land={landscape} bind:this={viewport}>
|
||||||
<div class="scaler" class:land={landscape} style="--z: {z};">
|
<div class="scaler" class:land={landscape} style="--z: {z};">
|
||||||
<div class="grid" class:gridless={!lines}>
|
<div class="grid">
|
||||||
{#each board as rowCells, r (r)}
|
{#each board as rowCells, r (r)}
|
||||||
{#each rowCells as cell, c (c)}
|
{#each rowCells as cell, c (c)}
|
||||||
{@const p = pending.get(key(r, c))}
|
{@const p = pending.get(key(r, c))}
|
||||||
@@ -311,18 +308,21 @@
|
|||||||
width 0.25s ease,
|
width 0.25s ease,
|
||||||
height 0.25s ease;
|
height 0.25s ease;
|
||||||
}
|
}
|
||||||
|
/* A gapless checkerboard with no grid lines (the board has no lined variant): plain cells
|
||||||
|
alternate shades and tiles get rounded corners plus a soft right-side shadow so adjacent
|
||||||
|
gapless tiles still read as separate pieces. */
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(15, 1fr);
|
grid-template-columns: repeat(15, 1fr);
|
||||||
gap: 1px;
|
gap: 0;
|
||||||
background: var(--cell-line);
|
background: var(--board-bg);
|
||||||
padding: 1px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.cell {
|
.cell {
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 1px;
|
border-radius: 0;
|
||||||
background: var(--cell-bg);
|
background: var(--cell-bg);
|
||||||
color: var(--prem-text);
|
color: var(--prem-text);
|
||||||
/* No mobile tap flash on a cell tap (parity with the web click; the only intentional
|
/* No mobile tap flash on a cell tap (parity with the web click; the only intentional
|
||||||
@@ -344,11 +344,20 @@
|
|||||||
.cell.dl {
|
.cell.dl {
|
||||||
background: var(--prem-dl);
|
background: var(--prem-dl);
|
||||||
}
|
}
|
||||||
|
.cell.dark {
|
||||||
|
/* A gentle checkerboard tint: enough to read the alternation without competing with the
|
||||||
|
bonus cells, standing in for the grid lines that once separated the cells. Lightened
|
||||||
|
from a stronger mix that looked too contrasty in light theme. */
|
||||||
|
background: color-mix(in srgb, var(--cell-bg) 94%, #000);
|
||||||
|
}
|
||||||
.cell.filled,
|
.cell.filled,
|
||||||
.cell.pending {
|
.cell.pending {
|
||||||
background: var(--tile-bg);
|
background: var(--tile-bg);
|
||||||
color: var(--tile-text);
|
color: var(--tile-text);
|
||||||
box-shadow: inset 0 -2px 0 var(--tile-edge);
|
border-radius: 4px;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 -2px 0 var(--tile-edge),
|
||||||
|
2px 0 3px -1px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
.cell.pending {
|
.cell.pending {
|
||||||
background: var(--tile-pending);
|
background: var(--tile-pending);
|
||||||
@@ -356,30 +365,6 @@
|
|||||||
board) instead of the touch starting a board pan. */
|
board) instead of the touch starting a board pan. */
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
/* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they
|
|
||||||
reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles
|
|
||||||
get rounded corners and a soft right-side shadow so adjacent gapless tiles still read
|
|
||||||
as separate pieces. */
|
|
||||||
.grid.gridless {
|
|
||||||
gap: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--board-bg);
|
|
||||||
}
|
|
||||||
.grid.gridless .cell {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
.grid.gridless .cell.dark {
|
|
||||||
/* A gentle checkerboard tint: enough to read the alternation without competing with the
|
|
||||||
bonus cells. Lightened from a stronger mix that looked too contrasty in light theme. */
|
|
||||||
background: color-mix(in srgb, var(--cell-bg) 94%, #000);
|
|
||||||
}
|
|
||||||
.grid.gridless .cell.filled,
|
|
||||||
.grid.gridless .cell.pending {
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 -2px 0 var(--tile-edge),
|
|
||||||
2px 0 3px -1px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
.cell.droptarget {
|
.cell.droptarget {
|
||||||
/* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the
|
/* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the
|
||||||
target reads clearly while dragging without obscuring the bonus label underneath. */
|
target reads clearly while dragging without obscuring the bonus label underneath. */
|
||||||
|
|||||||
@@ -39,11 +39,11 @@
|
|||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
|
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
|
||||||
<span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span>
|
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
{#if active}
|
{#if active}
|
||||||
<button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')}>
|
<button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')}>
|
||||||
<span class="sq" aria-hidden="true">🔎</span><span class="lbl">{t('game.dictionary')}</span>
|
<span class="face"><span class="sq" aria-hidden="true">🔎</span><span class="lbl">{t('game.dictionary')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</TabBar>
|
</TabBar>
|
||||||
|
|||||||
+10
-1
@@ -813,6 +813,8 @@
|
|||||||
// - closed: a downward "pull" opens the history, but only on the zoom-out board scrolled
|
// - closed: a downward "pull" opens the history, but only on the zoom-out board scrolled
|
||||||
// to its top — zoomed, the one-finger drag pans the board, and mid-scroll a downward drag
|
// to its top — zoomed, the one-finger drag pans the board, and mid-scroll a downward drag
|
||||||
// is the stage's own vertical scroll (the conflict that once retired this open gesture).
|
// is the stage's own vertical scroll (the conflict that once retired this open gesture).
|
||||||
|
// On that same closed, zoom-out board an upward swipe with no staged tiles shuffles the
|
||||||
|
// rack (a convenience mirror of the shuffle control).
|
||||||
// Both genuinely set `historyOpen` (closing no longer merely scrolls the slid board out of
|
// Both genuinely set `historyOpen` (closing no longer merely scrolls the slid board out of
|
||||||
// view, which left a stale-open state that made a follow-up score-bar tap "jump" the board).
|
// view, which left a stale-open state that made a follow-up score-bar tap "jump" the board).
|
||||||
let stageEl = $state<HTMLDivElement>();
|
let stageEl = $state<HTMLDivElement>();
|
||||||
@@ -869,6 +871,14 @@
|
|||||||
if (-dy > 32) closeHistoryByGesture(); // enough upward travel
|
if (-dy > 32) closeHistoryByGesture(); // enough upward travel
|
||||||
} else if (dy > 40 && dy > Math.abs(dx) * 1.4) {
|
} else if (dy > 40 && dy > Math.abs(dx) * 1.4) {
|
||||||
openHistoryByGesture(); // a clear, vertical-dominant downward pull
|
openHistoryByGesture(); // a clear, vertical-dominant downward pull
|
||||||
|
} else if (-dy > 40 && -dy > Math.abs(dx) * 1.4 && placement.pending.length === 0) {
|
||||||
|
// The same closed-board arm, opposite direction: a clear upward swipe on the zoom-out
|
||||||
|
// board with no staged tiles shuffles the rack (a convenience mirror of the shuffle
|
||||||
|
// control). Disarm and swallow the synthesised click so it cannot also place a tile.
|
||||||
|
shuffle();
|
||||||
|
boardSwipe = null;
|
||||||
|
swallowClick = true;
|
||||||
|
setTimeout(() => (swallowClick = false), 120);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onBoardWrapUp(e: PointerEvent) {
|
function onBoardWrapUp(e: PointerEvent) {
|
||||||
@@ -1156,7 +1166,6 @@
|
|||||||
{landscape}
|
{landscape}
|
||||||
{variant}
|
{variant}
|
||||||
labelMode={app.boardLabels}
|
labelMode={app.boardLabels}
|
||||||
lines={app.boardLines}
|
|
||||||
locale={app.locale}
|
locale={app.locale}
|
||||||
{focus}
|
{focus}
|
||||||
{recenter}
|
{recenter}
|
||||||
|
|||||||
@@ -83,7 +83,12 @@
|
|||||||
}
|
}
|
||||||
.tile {
|
.tile {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 0 0 auto;
|
/* Sit at the natural tile width, but shrink to fit when the row is crowded so a full
|
||||||
|
rack never overflows its wrapper onto the MakeMove control to its right (the confirm
|
||||||
|
button stays pinned at the right screen edge). flex-grow stays 0 so a sparse rack does
|
||||||
|
not stretch its tiles. */
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
width: min(12.5vw, 46px);
|
width: min(12.5vw, 46px);
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
background: var(--tile-bg);
|
background: var(--tile-bg);
|
||||||
|
|||||||
+29
-12
@@ -56,8 +56,6 @@ export const app = $state<{
|
|||||||
locale: Locale;
|
locale: Locale;
|
||||||
reduceMotion: boolean;
|
reduceMotion: boolean;
|
||||||
boardLabels: BoardLabelMode;
|
boardLabels: BoardLabelMode;
|
||||||
/** Draw grid lines between board cells; off (default) is a gapless checkerboard. */
|
|
||||||
boardLines: boolean;
|
|
||||||
localeLocked: boolean;
|
localeLocked: boolean;
|
||||||
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
|
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
|
||||||
notifications: number;
|
notifications: number;
|
||||||
@@ -72,6 +70,10 @@ export const app = $state<{
|
|||||||
* code is already used/expired, so the visitor lands in the lobby with a gentle pointer to
|
* code is already used/expired, so the visitor lands in the lobby with a gentle pointer to
|
||||||
* the bot instead of a scary error on the Friends screen. */
|
* the bot instead of a scary error on the Friends screen. */
|
||||||
staleInvite: boolean;
|
staleInvite: boolean;
|
||||||
|
/** Whether to show the Telegram "welcome" window: set when a deep-link friend code is
|
||||||
|
* redeemed successfully inside Telegram, greeting the arriving player and pointing them at
|
||||||
|
* the bot for the most convenient way to play. */
|
||||||
|
welcomeRedeem: boolean;
|
||||||
/** Monotonic counter bumped when the app returns to the foreground without the live stream
|
/** Monotonic counter bumped when the app returns to the foreground without the live stream
|
||||||
* having dropped. An open game watches it to refetch once, recovering an in-game event shed
|
* having dropped. An open game watches it to refetch once, recovering an in-game event shed
|
||||||
* from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */
|
* from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */
|
||||||
@@ -88,12 +90,12 @@ export const app = $state<{
|
|||||||
locale: 'en',
|
locale: 'en',
|
||||||
reduceMotion: false,
|
reduceMotion: false,
|
||||||
boardLabels: 'beginner',
|
boardLabels: 'beginner',
|
||||||
boardLines: false,
|
|
||||||
localeLocked: false,
|
localeLocked: false,
|
||||||
notifications: 0,
|
notifications: 0,
|
||||||
chatUnread: {},
|
chatUnread: {},
|
||||||
feedbackReplyUnread: false,
|
feedbackReplyUnread: false,
|
||||||
staleInvite: false,
|
staleInvite: false,
|
||||||
|
welcomeRedeem: false,
|
||||||
resync: 0,
|
resync: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,7 +145,18 @@ function goForeground(): void {
|
|||||||
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
|
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
|
||||||
app.toast = { kind, text, seq: ++toastSeq };
|
app.toast = { kind, text, seq: ++toastSeq };
|
||||||
if (toastTimer) clearTimeout(toastTimer);
|
if (toastTimer) clearTimeout(toastTimer);
|
||||||
toastTimer = setTimeout(() => (app.toast = null), 4000);
|
// An info bubble lives exactly as long as its rise-and-fade animation (2s); an error
|
||||||
|
// dwells longer (4s) so it is not missed.
|
||||||
|
toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dismissToast hides the current toast at once — a tap on the bubble dismisses it. */
|
||||||
|
export function dismissToast(): void {
|
||||||
|
if (toastTimer) {
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = null;
|
||||||
|
}
|
||||||
|
app.toast = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** dismissStaleInvite hides the outdated-invite-link notice (the user acknowledged it). */
|
/** dismissStaleInvite hides the outdated-invite-link notice (the user acknowledged it). */
|
||||||
@@ -151,6 +164,11 @@ export function dismissStaleInvite(): void {
|
|||||||
app.staleInvite = false;
|
app.staleInvite = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** dismissWelcomeRedeem hides the Telegram welcome window (the user acknowledged it). */
|
||||||
|
export function dismissWelcomeRedeem(): void {
|
||||||
|
app.welcomeRedeem = false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* seedChatUnread sets a game's unread flag from an authoritative per-viewer REST view (the lobby
|
* seedChatUnread sets a game's unread flag from an authoritative per-viewer REST view (the lobby
|
||||||
* list, a game's state, or a move result). The live-event GameView omits the flag, so the live
|
* list, a game's state, or a move result). The live-event GameView omits the flag, so the live
|
||||||
@@ -476,7 +494,6 @@ export async function bootstrap(): Promise<void> {
|
|||||||
app.theme = prefs.theme ?? 'auto';
|
app.theme = prefs.theme ?? 'auto';
|
||||||
app.reduceMotion = prefs.reduceMotion ?? false;
|
app.reduceMotion = prefs.reduceMotion ?? false;
|
||||||
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
||||||
app.boardLines = prefs.boardLines ?? false;
|
|
||||||
applyTheme(app.theme);
|
applyTheme(app.theme);
|
||||||
applyReduceMotion(app.reduceMotion);
|
applyReduceMotion(app.reduceMotion);
|
||||||
if (prefs.locale) {
|
if (prefs.locale) {
|
||||||
@@ -560,7 +577,13 @@ async function routeStartParam(param: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const friend = await gateway.friendCodeRedeem(link.code);
|
const friend = await gateway.friendCodeRedeem(link.code);
|
||||||
navigate('/friends');
|
navigate('/friends');
|
||||||
showToast(t('friends.added', { name: friend.displayName }));
|
// Inside Telegram a successful invite-link redeem welcomes the arriving player and
|
||||||
|
// points them at the bot (its native convenience); elsewhere a quiet toast suffices.
|
||||||
|
if (insideTelegram()) {
|
||||||
|
app.welcomeRedeem = true;
|
||||||
|
} else {
|
||||||
|
showToast(t('friends.added', { name: friend.displayName }));
|
||||||
|
}
|
||||||
void refreshNotifications();
|
void refreshNotifications();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = err instanceof GatewayError ? err.code : '';
|
const code = err instanceof GatewayError ? err.code : '';
|
||||||
@@ -633,7 +656,6 @@ function persistPrefs(): void {
|
|||||||
locale: app.locale,
|
locale: app.locale,
|
||||||
reduceMotion: app.reduceMotion,
|
reduceMotion: app.reduceMotion,
|
||||||
boardLabels: app.boardLabels,
|
boardLabels: app.boardLabels,
|
||||||
boardLines: app.boardLines,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,11 +708,6 @@ export function setBoardLabels(mode: BoardLabelMode): void {
|
|||||||
persistPrefs();
|
persistPrefs();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setBoardLines(on: boolean): void {
|
|
||||||
app.boardLines = on;
|
|
||||||
persistPrefs();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background/foreground lifecycle: silence the reconnect banner during a suspend and
|
// Background/foreground lifecycle: silence the reconnect banner during a suspend and
|
||||||
// reconnect quietly on return (and refresh the lobby badge for any push missed while
|
// reconnect quietly on return (and refresh the lobby badge for any push missed while
|
||||||
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
|
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ export const en = {
|
|||||||
'settings.labelsBeginner': 'Beginner',
|
'settings.labelsBeginner': 'Beginner',
|
||||||
'settings.labelsClassic': 'Classic',
|
'settings.labelsClassic': 'Classic',
|
||||||
'settings.labelsNone': 'None',
|
'settings.labelsNone': 'None',
|
||||||
'settings.boardLines': 'Grid lines',
|
|
||||||
'settings.reduceMotion': 'Reduce motion',
|
'settings.reduceMotion': 'Reduce motion',
|
||||||
|
|
||||||
'about.title': 'About',
|
'about.title': 'About',
|
||||||
@@ -241,6 +240,8 @@ export const en = {
|
|||||||
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
|
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
|
||||||
'friends.staleInviteTitle': 'Link expired',
|
'friends.staleInviteTitle': 'Link expired',
|
||||||
'friends.staleInvite': 'You opened the game from an outdated link. Open the bot {bot} to play and get notifications.',
|
'friends.staleInvite': 'You opened the game from an outdated link. Open the bot {bot} to play and get notifications.',
|
||||||
|
'friends.welcomeRedeemTitle': 'Welcome!',
|
||||||
|
'friends.welcomeRedeem': 'Welcome, {name}!\n\nUse {bot} to join games the most convenient way.',
|
||||||
'friends.added': 'Added {name}.',
|
'friends.added': 'Added {name}.',
|
||||||
'friends.blockedList': 'Blocked players',
|
'friends.blockedList': 'Blocked players',
|
||||||
'friends.unblock': 'Unblock',
|
'friends.unblock': 'Unblock',
|
||||||
|
|||||||
@@ -164,7 +164,6 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'settings.labelsBeginner': 'Новичок',
|
'settings.labelsBeginner': 'Новичок',
|
||||||
'settings.labelsClassic': 'Классика',
|
'settings.labelsClassic': 'Классика',
|
||||||
'settings.labelsNone': 'Без текста',
|
'settings.labelsNone': 'Без текста',
|
||||||
'settings.boardLines': 'Линии сетки',
|
|
||||||
'settings.reduceMotion': 'Меньше анимаций',
|
'settings.reduceMotion': 'Меньше анимаций',
|
||||||
|
|
||||||
'about.title': 'О программе',
|
'about.title': 'О программе',
|
||||||
@@ -242,6 +241,8 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
|
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
|
||||||
'friends.staleInviteTitle': 'Ссылка устарела',
|
'friends.staleInviteTitle': 'Ссылка устарела',
|
||||||
'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.',
|
'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.',
|
||||||
|
'friends.welcomeRedeemTitle': 'Добро пожаловать!',
|
||||||
|
'friends.welcomeRedeem': 'Добро пожаловать, {name}!\n\nВоспользуйтесь {bot}, чтобы заходить в игру наиболее удобным способом.',
|
||||||
'friends.added': 'Добавлен(а) {name}.',
|
'friends.added': 'Добавлен(а) {name}.',
|
||||||
'friends.blockedList': 'Заблокированные',
|
'friends.blockedList': 'Заблокированные',
|
||||||
'friends.unblock': 'Разблокировать',
|
'friends.unblock': 'Разблокировать',
|
||||||
|
|||||||
@@ -124,8 +124,6 @@ export interface Prefs {
|
|||||||
locale: Locale;
|
locale: Locale;
|
||||||
reduceMotion: boolean;
|
reduceMotion: boolean;
|
||||||
boardLabels: BoardLabelMode;
|
boardLabels: BoardLabelMode;
|
||||||
/** Draw the 1px grid lines between cells; off (default) shows a gapless checkerboard. */
|
|
||||||
boardLines: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPrefs(): Promise<Partial<Prefs>> {
|
export async function loadPrefs(): Promise<Partial<Prefs>> {
|
||||||
|
|||||||
@@ -380,8 +380,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--danger);
|
background: var(--bg-elev);
|
||||||
color: #fff;
|
color: var(--text);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
@@ -404,7 +404,7 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 10px 6px;
|
padding: 5px 6px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -471,7 +471,7 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.emoji {
|
.emoji {
|
||||||
font-size: 1.8rem;
|
font-size: 1.35rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
setBoardLabels,
|
setBoardLabels,
|
||||||
setBoardLines,
|
|
||||||
setLocalePref,
|
setLocalePref,
|
||||||
setReduceMotion,
|
setReduceMotion,
|
||||||
setTheme,
|
setTheme,
|
||||||
@@ -62,14 +61,6 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<label class="row gridlines">
|
|
||||||
<span>{t('settings.boardLines')}</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={app.boardLines}
|
|
||||||
onchange={(e) => setBoardLines(e.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -124,7 +115,4 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.gridlines {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -46,18 +46,18 @@
|
|||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
|
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
|
||||||
<span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span>
|
<span class="face"><span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')}>
|
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')}>
|
||||||
<span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span>
|
<span class="face"><span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
{#if !guest}
|
{#if !guest}
|
||||||
<button class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')}>
|
<button class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')}>
|
||||||
<span class="sq" aria-hidden="true">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span><span class="lbl">{t('friends.title')}</span>
|
<span class="face"><span class="sq" aria-hidden="true">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span><span class="lbl">{t('friends.title')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}>
|
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}>
|
||||||
<span class="sq" aria-hidden="true">ℹ️{#if app.feedbackReplyUnread}<span class="badge">1</span>{/if}</span><span class="lbl">{t('about.tab')}</span>
|
<span class="face"><span class="sq" aria-hidden="true">ℹ️{#if app.feedbackReplyUnread}<span class="badge">1</span>{/if}</span><span class="lbl">{t('about.tab')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
</TabBar>
|
</TabBar>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
Reference in New Issue
Block a user