Stage 17 (#3,#5,#10): hover-hold drag zoom, always-editable profile, drag-back + double-tap recall
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s

- Board drag now auto-zooms toward a cell after holding the tile over it ~1s (#3).
- Profile is inline-editable: drop the Edit/Cancel toggle, form is always shown
  for durable accounts; hint balance stays read-only; re-populate after link/merge (#5).
- A pending tile recalls by double-tap (same cell) or by dragging it back onto the
  rack (unzoomed board); a single tap no longer recalls (#10).
- e2e: lock double-tap recall + single-tap no-op; drop the removed Edit-profile click.
This commit is contained in:
Ilia Denisov
2026-06-06 14:42:09 +02:00
parent 4fd82335db
commit 1bbf0bc654
6 changed files with 171 additions and 90 deletions
+53 -63
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import Screen from '../components/Screen.svelte';
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
@@ -16,7 +17,6 @@
validEmail,
} from '../lib/profileValidation';
let editing = $state(false);
let dn = $state('');
let tz = $state('+00:00');
// Away start/end as hour + 10-minute parts, so the picker is a <select> like every
@@ -47,8 +47,12 @@
return [m[1], awayMinutes.includes(m[2]) ? m[2] : '00'];
}
function startEdit() {
const p = app.profile!;
// populate loads the editable form from the current profile. The profile screen is
// edited inline (no edit/cancel toggle, Stage 17), so this runs on mount and after a
// link/merge swaps the active account.
function populate() {
const p = app.profile;
if (!p) return;
dn = p.displayName;
tz = isOffsetZone(p.timeZone) && timezoneOffsets.includes(p.timeZone) ? p.timeZone : defaultTz();
[startH, startM] = splitTime(p.awayStart);
@@ -56,8 +60,8 @@
blockChat = p.blockChat;
blockFriendRequests = p.blockFriendRequests;
notificationsInAppOnly = p.notificationsInAppOnly;
editing = true;
}
onMount(populate);
const awayStart = $derived(`${startH}:${startM}`);
const awayEnd = $derived(`${endH}:${endM}`);
@@ -79,7 +83,6 @@
blockFriendRequests,
notificationsInAppOnly,
});
editing = false;
showToast(t('profile.saved'));
} catch (e) {
handleError(e);
@@ -111,6 +114,7 @@
return;
}
await applyLinkResult(r);
populate();
resetEmail();
showToast(t('profile.linked'));
} catch (e) {
@@ -129,6 +133,7 @@
return;
}
await applyLinkResult(r);
populate();
showToast(t('profile.linked'));
} catch (e) {
handleError(e);
@@ -143,6 +148,7 @@
? await gateway.linkEmailMerge(emailInput.trim(), codeInput.trim())
: await gateway.linkTelegramMerge(tgData);
await applyLinkResult(r);
populate();
pendingMerge = null;
tgData = '';
resetEmail();
@@ -160,7 +166,11 @@
<div class="name">{p.displayName}</div>
{#if p.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
{#if editing}
<div class="hintbal"><span>{t('profile.hintBalance')}</span><b>{p.hintBalance}</b></div>
{#if p.isGuest}
<p class="muted">{t('profile.guestLocked')}</p>
{:else}
<form class="edit" onsubmit={(e) => { e.preventDefault(); void save(); }}>
<label>
<span>{t('profile.displayName')}</span>
@@ -202,57 +212,41 @@
</label>
<div class="formacts">
<button type="submit" class="btn" disabled={!formValid}>{t('common.save')}</button>
<button type="button" class="ghost" onclick={() => (editing = false)}>{t('common.cancel')}</button>
</div>
</form>
{:else}
<dl>
<dt>{t('profile.timezone')}</dt>
<dd>{p.timeZone}</dd>
<dt>{t('profile.awayWindow')}</dt>
<dd>{p.awayStart}{p.awayEnd}</dd>
<dt>{t('profile.hintBalance')}</dt>
<dd>{p.hintBalance}</dd>
</dl>
{#if p.isGuest}
<p class="muted">{t('profile.guestLocked')}</p>
{:else}
<button class="btn" onclick={startEdit}>{t('profile.edit')}</button>
{/if}
<!-- Linking & merge (Stage 11). Shown to everyone, including guests, who
upgrade by binding their first identity. -->
<section class="emailbox">
<h3>{t('profile.linkAccount')}</h3>
{#if !emailSent}
<div class="addrow">
<input
class:invalid={emailInput.length > 0 && !emailOk}
bind:value={emailInput}
placeholder={t('login.emailPlaceholder')}
type="email"
/>
<button class="ghost" onclick={requestEmail} disabled={!emailOk}>{t('login.sendCode')}</button>
</div>
{:else}
<div class="addrow">
<input
class="codein"
bind:value={codeInput}
placeholder={t('profile.emailCode')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
</div>
{/if}
{#if telegramLinkable}
<button class="ghost tg" onclick={linkTelegram}>{t('profile.linkTelegram')}</button>
{/if}
</section>
{/if}
<!-- Linking & merge (Stage 11). Shown to everyone, including guests, who
upgrade by binding their first identity. -->
<section class="emailbox">
<h3>{t('profile.linkAccount')}</h3>
{#if !emailSent}
<div class="addrow">
<input
class:invalid={emailInput.length > 0 && !emailOk}
bind:value={emailInput}
placeholder={t('login.emailPlaceholder')}
type="email"
/>
<button class="ghost" onclick={requestEmail} disabled={!emailOk}>{t('login.sendCode')}</button>
</div>
{:else}
<div class="addrow">
<input
class="codein"
bind:value={codeInput}
placeholder={t('profile.emailCode')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
</div>
{/if}
{#if telegramLinkable}
<button class="ghost tg" onclick={linkTelegram}>{t('profile.linkTelegram')}</button>
{/if}
</section>
<!-- Logout is hidden for now (Stage 17) but kept wired — drop `hidden` to re-enable
once its entry point is decided; logout() also still runs on an invalid session. -->
<button class="logout" hidden onclick={() => logout()}>{t('login.title')} / logout</button>
@@ -290,18 +284,14 @@
color: var(--text-muted);
font-size: 0.8rem;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 16px;
margin: 0;
}
dt {
.hintbal {
display: flex;
justify-content: space-between;
color: var(--text-muted);
}
dd {
margin: 0;
text-align: right;
.hintbal b {
color: var(--text);
font-weight: 600;
}
.muted {
color: var(--text-muted);