1
0
forked from wrenn/wrenn

Polish team page: delight micro-interactions and layout improvements

- Slug + Team ID rows collapsed into a 2-column grid for better density
- "you" badge moved inline with email instead of stacked below it
- Copy checkmark draws itself via SVG stroke-dashoffset animation
- New member row flashes accent-green on entry
- Removed member row slides out smoothly (fly transition)
- Member rows use staggered fly-in on page load
- Team name briefly highlights accent color after a successful rename
- Search result avatars get colorized initials based on email character
This commit is contained in:
2026-03-24 14:56:19 +06:00
parent bf494f73fc
commit 90c296f5e1

View File

@ -2,6 +2,8 @@
import Sidebar from '$lib/components/Sidebar.svelte'; import Sidebar from '$lib/components/Sidebar.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { auth } from '$lib/auth.svelte'; import { auth } from '$lib/auth.svelte';
import { toast } from '$lib/toast.svelte'; import { toast } from '$lib/toast.svelte';
import { import {
@ -73,6 +75,10 @@
let removing = $state(false); let removing = $state(false);
let removeError = $state<string | null>(null); let removeError = $state<string | null>(null);
// Delight: new member row flash, name saved flash
let recentlyAddedId = $state<string | null>(null);
let nameSavedFlash = $state(false);
// Danger zone // Danger zone
let showDangerConfirm = $state(false); let showDangerConfirm = $state(false);
let dangerLoading = $state(false); let dangerLoading = $state(false);
@ -124,6 +130,8 @@
if (result.ok) { if (result.ok) {
team = { ...team, name: trimmed }; team = { ...team, name: trimmed };
editingName = false; editingName = false;
nameSavedFlash = true;
setTimeout(() => (nameSavedFlash = false), 900);
toast.success('Team name updated'); toast.success('Team name updated');
} else { } else {
nameError = result.error; nameError = result.error;
@ -177,6 +185,8 @@
const result = await addMember(team.id, addEmail.trim().toLowerCase()); const result = await addMember(team.id, addEmail.trim().toLowerCase());
if (result.ok) { if (result.ok) {
members = [...members, result.data]; members = [...members, result.data];
recentlyAddedId = result.data.user_id;
setTimeout(() => (recentlyAddedId = null), 1200);
showAddMember = false; showAddMember = false;
addEmail = ''; addEmail = '';
searchResults = []; searchResults = [];
@ -251,6 +261,11 @@
} }
} }
function avatarColor(email: string): string {
const palette = ['#5e8c58', '#5a9fd4', '#d4a73c', '#a07ab0', '#cf8172'];
return palette[email.charCodeAt(0) % palette.length];
}
function formatDate(iso: string): string { function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', { return new Date(iso).toLocaleDateString('en-US', {
month: 'short', month: 'short',
@ -471,7 +486,7 @@
title={canManage ? 'Click to edit' : undefined} title={canManage ? 'Click to edit' : undefined}
> >
<span <span
class="text-ui font-medium text-[var(--color-text-bright)] {canManage ? 'group-hover:text-[var(--color-text-bright)]' : ''}" class="text-ui font-medium transition-colors duration-300 {nameSavedFlash ? 'text-[var(--color-accent-mid)]' : 'text-[var(--color-text-bright)]'} {canManage ? 'group-hover:text-[var(--color-text-bright)]' : ''}"
> >
{team.name} {team.name}
</span> </span>
@ -498,20 +513,23 @@
</div> </div>
</div> </div>
<!-- Slug row --> <!-- Slug + ID row (2-col grid) -->
<div class="flex items-center gap-4 border-b border-[var(--color-border)] px-5 py-4"> <div class="grid grid-cols-2 divide-x divide-[var(--color-border)]">
<!-- Slug -->
<div class="flex items-center gap-3 px-5 py-4">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div <div
class="mb-1.5 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]" class="mb-1 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
> >
Slug Slug
</div> </div>
<span class="font-mono text-ui text-[var(--color-text-secondary)]" <span class="block truncate font-mono text-ui text-[var(--color-text-secondary)]"
>{team.slug}</span >{team.slug}</span
> >
</div> </div>
<button <button
onclick={() => copyToClipboard(team!.slug, 'slug')} onclick={() => copyToClipboard(team!.slug, 'slug')}
title="Copy slug"
class="flex shrink-0 items-center gap-1.5 rounded-[var(--radius-button)] border px-3 py-1.5 text-meta font-semibold transition-all duration-150 class="flex shrink-0 items-center gap-1.5 rounded-[var(--radius-button)] border px-3 py-1.5 text-meta font-semibold transition-all duration-150
{copiedSlug {copiedSlug
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-glow-mid)] text-[var(--color-accent-mid)]' ? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-glow-mid)] text-[var(--color-accent-mid)]'
@ -527,6 +545,7 @@
stroke-width="2.5" stroke-width="2.5"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
class="checkmark-draw"
> >
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
@ -550,20 +569,21 @@
</button> </button>
</div> </div>
<!-- Team ID row --> <!-- Team ID -->
<div class="flex items-center gap-4 px-5 py-4"> <div class="flex items-center gap-3 px-5 py-4">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div <div
class="mb-1.5 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]" class="mb-1 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
> >
Team ID Team ID
</div> </div>
<span class="font-mono text-ui text-[var(--color-text-secondary)]" <span class="block truncate font-mono text-ui text-[var(--color-text-secondary)]"
>{team.id}</span >{team.id}</span
> >
</div> </div>
<button <button
onclick={() => copyToClipboard(team!.id, 'id')} onclick={() => copyToClipboard(team!.id, 'id')}
title="Copy team ID"
class="flex shrink-0 items-center gap-1.5 rounded-[var(--radius-button)] border px-3 py-1.5 text-meta font-semibold transition-all duration-150 class="flex shrink-0 items-center gap-1.5 rounded-[var(--radius-button)] border px-3 py-1.5 text-meta font-semibold transition-all duration-150
{copiedId {copiedId
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-glow-mid)] text-[var(--color-accent-mid)]' ? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-glow-mid)] text-[var(--color-accent-mid)]'
@ -579,6 +599,7 @@
stroke-width="2.5" stroke-width="2.5"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
class="checkmark-draw"
> >
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
@ -602,6 +623,7 @@
</button> </button>
</div> </div>
</div> </div>
</div>
</section> </section>
<!-- ── Members ── --> <!-- ── Members ── -->
@ -676,18 +698,21 @@
{#each members as member, i (member.user_id)} {#each members as member, i (member.user_id)}
<div <div
class="grid grid-cols-[1fr_120px_140px_120px] items-center border-b border-[var(--color-border)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] last:border-b-0" class="grid grid-cols-[1fr_120px_140px_120px] items-center border-b border-[var(--color-border)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] last:border-b-0 {recentlyAddedId === member.user_id ? 'member-flash' : ''}"
style="animation: fadeUp 0.35s ease both; animation-delay: {i * 40}ms" in:fly={{ y: 6, duration: 200, delay: i * 30, easing: cubicOut }}
out:fly={{ x: -16, duration: 180, easing: cubicOut }}
> >
<!-- Email --> <!-- Email -->
<div class="min-w-0 px-5 py-4"> <div class="min-w-0 px-5 py-4">
<span class="block truncate text-ui text-[var(--color-text-bright)]" <div class="flex min-w-0 items-center gap-2">
<span class="truncate text-ui text-[var(--color-text-bright)]"
>{member.email}</span >{member.email}</span
> >
{#if member.user_id === auth.userId} {#if member.user_id === auth.userId}
<span class="text-meta text-[var(--color-text-muted)]">you</span> <span class="shrink-0 rounded-[2px] bg-[var(--color-bg-4)] px-1.5 py-0.5 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">you</span>
{/if} {/if}
</div> </div>
</div>
<!-- Role badge --> <!-- Role badge -->
<div class="px-4 py-4"> <div class="px-4 py-4">
@ -989,7 +1014,8 @@
class="flex w-full items-center gap-2.5 px-3 py-2 text-ui transition-colors duration-150 hover:bg-[var(--color-bg-3)]" class="flex w-full items-center gap-2.5 px-3 py-2 text-ui transition-colors duration-150 hover:bg-[var(--color-bg-3)]"
> >
<div <div
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[var(--color-bg-5)] text-badge font-bold uppercase text-[var(--color-text-secondary)]" class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-badge font-bold uppercase"
style="background: {avatarColor(result.email)}22; color: {avatarColor(result.email)}"
> >
{result.email[0]} {result.email[0]}
</div> </div>
@ -1228,3 +1254,27 @@
</div> </div>
</div> </div>
{/if} {/if}
<style>
/* Checkmark SVG path draw animation for copy buttons */
.checkmark-draw polyline {
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: draw-check 0.25s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes draw-check {
to {
stroke-dashoffset: 0;
}
}
/* New member row entrance flash */
.member-flash {
animation: member-added 1.2s ease forwards;
}
@keyframes member-added {
0% { background-color: transparent; }
15% { background-color: var(--color-accent-glow-mid); }
100% { background-color: transparent; }
}
</style>