From f2093000e0645ff144a8d7210d46efd8076e67e3 Mon Sep 17 00:00:00 2001 From: eti Date: Mon, 24 Aug 2026 19:59:47 +0200 Subject: [PATCH] web/onboarding: improve & add animations Signed-off-by: eti --- .../lib/components/profile/FollowCard.svelte | 8 +- .../profile/FollowCardContent.svelte | 8 +- web/src/lib/components/profile/types.ts | 8 + web/src/lib/components/repo/RepoCard.svelte | 8 +- .../components/repo/RepoCardContent.svelte | 13 +- web/src/lib/components/repo/StarButton.svelte | 13 +- .../lib/components/welcome/FinishStep.svelte | 31 +++- .../lib/components/welcome/KeysStep.svelte | 64 ++++++- .../lib/components/welcome/ProfileStep.svelte | 49 ++++-- .../lib/components/welcome/SocialStep.svelte | 38 ++++- .../lib/components/welcome/StepHeader.svelte | 2 +- web/src/lib/components/welcome/Steps.svelte | 158 ++++++++++++++---- .../lib/components/welcome/WelcomeCard.svelte | 156 ++++++++++++++--- web/src/lib/components/welcome/spring.ts | 33 ++++ web/src/lib/components/welcome/steps.ts | 6 +- web/src/routes/welcome/+page.svelte | 42 ++++- 16 files changed, 533 insertions(+), 104 deletions(-) create mode 100644 web/src/lib/components/welcome/spring.ts diff --git a/web/src/lib/components/profile/FollowCard.svelte b/web/src/lib/components/profile/FollowCard.svelte index 341e129a..1c983d44 100644 --- a/web/src/lib/components/profile/FollowCard.svelte +++ b/web/src/lib/components/profile/FollowCard.svelte @@ -1,15 +1,17 @@ - + diff --git a/web/src/lib/components/profile/FollowCardContent.svelte b/web/src/lib/components/profile/FollowCardContent.svelte index 4996fd21..8a26f584 100644 --- a/web/src/lib/components/profile/FollowCardContent.svelte +++ b/web/src/lib/components/profile/FollowCardContent.svelte @@ -8,14 +8,18 @@ import Separator from "$lib/components/ui/Separator.svelte"; import { formatCount } from "$lib/format"; - let { person }: { person: PersonData } = $props(); + let { person, onFollow }: { person: PersonData; onFollow?: (change: FollowChange) => void } = + $props(); const followerCount = createOptimisticCount({ key: () => person.did, loaded: () => person.followers }); - const followed = (change: FollowChange) => followerCount.adjust(change.delta); + const followed = (change: FollowChange) => { + followerCount.adjust(change.delta); + onFollow?.(change); + };
diff --git a/web/src/lib/components/profile/types.ts b/web/src/lib/components/profile/types.ts index 51285e56..2a6323fb 100644 --- a/web/src/lib/components/profile/types.ts +++ b/web/src/lib/components/profile/types.ts @@ -45,6 +45,14 @@ export interface FollowChange { delta: 1 | -1; } +export interface StarChange { + viewerDid: string; + repoDid: string; + starred: boolean; + rkey: string | null; + delta: 1 | -1; +} + export interface PersonData { did: string; handle: string; diff --git a/web/src/lib/components/repo/RepoCard.svelte b/web/src/lib/components/repo/RepoCard.svelte index d2817a58..e5b76a8a 100644 --- a/web/src/lib/components/repo/RepoCard.svelte +++ b/web/src/lib/components/repo/RepoCard.svelte @@ -2,7 +2,7 @@ import { resolve } from "$app/paths"; import Card, { type CardVariants } from "$lib/components/ui/Card.svelte"; import RepoCardContent from "./RepoCardContent.svelte"; - import type { RepoCardData } from "$lib/components/profile/types"; + import type { RepoCardData, StarChange } from "$lib/components/profile/types"; import { navigatingTo } from "$lib/navPulse"; let { repo, @@ -11,9 +11,11 @@ border = true, shadow = false, background = true, - padding = "regular" + padding = "regular", + onStar }: { repo: RepoCardData; + onStar?: (change: StarChange) => void; starButton?: boolean; showOwner?: boolean; border?: CardVariants["border"]; @@ -32,5 +34,5 @@ {padding} class={navigatingTo(href) ? "row-breathe" : undefined} > - + diff --git a/web/src/lib/components/repo/RepoCardContent.svelte b/web/src/lib/components/repo/RepoCardContent.svelte index 676acd99..e0e50b28 100644 --- a/web/src/lib/components/repo/RepoCardContent.svelte +++ b/web/src/lib/components/repo/RepoCardContent.svelte @@ -6,13 +6,19 @@ import CircleDot from "$icon/circle-dot"; import GitPullRequest from "$icon/git-pull-request"; import StarButton from "./StarButton.svelte"; - import type { RepoCardData } from "$lib/components/profile/types"; + import type { RepoCardData, StarChange } from "$lib/components/profile/types"; import { formatCount } from "$lib/format"; let { repo, starButton = true, - showOwner = true - }: { repo: RepoCardData; starButton?: boolean; showOwner?: boolean } = $props(); + showOwner = true, + onStar + }: { + repo: RepoCardData; + starButton?: boolean; + showOwner?: boolean; + onStar?: (change: StarChange) => void; + } = $props(); const stats = $derived( [ @@ -44,6 +50,7 @@ initialCount={repo.stars} initialRkey={repo.viewerStarRkey} insetShadow={false} + onCommit={onStar} />
{/if} diff --git a/web/src/lib/components/repo/StarButton.svelte b/web/src/lib/components/repo/StarButton.svelte index a66277bb..4637000b 100644 --- a/web/src/lib/components/repo/StarButton.svelte +++ b/web/src/lib/components/repo/StarButton.svelte @@ -12,6 +12,7 @@ import { formatCount } from "$lib/format"; import Bones from "$lib/components/ui/Bones.svelte"; import uiLoadingprimitivesCount from "$lib/bones/ui-loadingprimitives--count.bones.json"; + import type { StarChange } from "$lib/components/profile/types"; import type { RepoCounts } from "./types"; interface Props { @@ -21,6 +22,7 @@ initialCount?: number | RepoCounts | Promise | Promise; initialRkey?: string | null | Promise; insetShadow?: boolean; + onCommit?: (change: StarChange) => void; } let { repoDid, @@ -28,7 +30,8 @@ repoName, initialCount, initialRkey, - insetShadow = true + insetShadow = true, + onCommit }: Props = $props(); const auth = getAuth(); @@ -97,11 +100,19 @@ relation.deleted(); starCount.adjust(-1); profileCounts?.adjust(agent.sub, "stars", -1); + onCommit?.({ + viewerDid: agent.sub, + repoDid, + starred: false, + rkey: null, + delta: -1 + }); } else { const rkey = await createStar(agent, repoDid); relation.created(rkey); starCount.adjust(1); profileCounts?.adjust(agent.sub, "stars", 1); + onCommit?.({ viewerDid: agent.sub, repoDid, starred: true, rkey, delta: 1 }); } } catch { relation.fail(); diff --git a/web/src/lib/components/welcome/FinishStep.svelte b/web/src/lib/components/welcome/FinishStep.svelte index 5f84bad5..fc39ad97 100644 --- a/web/src/lib/components/welcome/FinishStep.svelte +++ b/web/src/lib/components/welcome/FinishStep.svelte @@ -29,11 +29,21 @@ }); export type TileVariants = VariantProps; + + const TILE_STAGGER_MS = 60; + const TILE_MAX_DELAY_MS = 140; + + function tileDelay(index: number) { + return Math.min(index * TILE_STAGGER_MS, TILE_MAX_DELAY_MS); + }
@@ -76,13 +93,24 @@ {#if keys.length > 0}
    {#each keys as pubkey (pubkey.rkey)} -
  • +
  • void remove(pubkey)} + onDelete={() => requestDelete(pubkey)} />
  • {/each} @@ -114,3 +142,29 @@
+ + +

+ This removes the key from your account. You will need to add it again to push with it. +

+ + {#snippet footer()} + + + {/snippet} +
diff --git a/web/src/lib/components/welcome/ProfileStep.svelte b/web/src/lib/components/welcome/ProfileStep.svelte index 6380eecf..3e894117 100644 --- a/web/src/lib/components/welcome/ProfileStep.svelte +++ b/web/src/lib/components/welcome/ProfileStep.svelte @@ -3,7 +3,7 @@ export const avatarPicker = tv({ slots: { - label: "flex size-40 cursor-pointer flex-col items-center justify-center gap-1 overflow-hidden rounded-full border border-border-default text-foreground-subtle transition-colors hover:bg-background-subtle", + label: "flex size-40 cursor-pointer flex-col items-center justify-center gap-1 overflow-hidden rounded-full border border-border-default text-foreground-subtle transition-colors focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-border-focus hover:bg-background-subtle", image: "size-full rounded-full object-cover", placeholder: "flex flex-col items-center justify-center gap-1" } @@ -21,6 +21,7 @@
- +
- {#if pictureError} -

{pictureError}

- {/if} +

+ {pictureError ?? ""} +

@@ -99,3 +115,16 @@
+ + diff --git a/web/src/lib/components/welcome/SocialStep.svelte b/web/src/lib/components/welcome/SocialStep.svelte index 59c468e2..c50959b2 100644 --- a/web/src/lib/components/welcome/SocialStep.svelte +++ b/web/src/lib/components/welcome/SocialStep.svelte @@ -1,11 +1,29 @@
@@ -15,7 +33,11 @@ {#if people.length > 0}
{#each people as person (person.did)} - + interacted(change.following)} + /> {/each}
{/if} @@ -23,11 +45,19 @@ {#if repos.length > 0}
{#each repos as repo (repoKey(repo))} - + interacted(change.starred)} /> {/each}
{/if} {:else} - + + + Browse the timeline + + {/if}
diff --git a/web/src/lib/components/welcome/StepHeader.svelte b/web/src/lib/components/welcome/StepHeader.svelte index 0967edfd..f7829c1b 100644 --- a/web/src/lib/components/welcome/StepHeader.svelte +++ b/web/src/lib/components/welcome/StepHeader.svelte @@ -25,7 +25,7 @@
-

{title}

+

{title}

{#if subtitle}

{subtitle}

{/if} diff --git a/web/src/lib/components/welcome/Steps.svelte b/web/src/lib/components/welcome/Steps.svelte index c44d21a7..35d2e289 100644 --- a/web/src/lib/components/welcome/Steps.svelte +++ b/web/src/lib/components/welcome/Steps.svelte @@ -1,36 +1,49 @@ -
    +
      {#each items as item, index (item.key)} - {#if index > 0} - - {/if} {@const state = stateOf(index)} - {@const slot = steps({ state })} -
    1. - - {#if state === "done"} - - {item.label} + {@const here = !complete && index === current} + {@const filled = complete || index < current} + {@const selectable = index <= furthest} + {@const slot = steps({ state, filled, here, clickable: selectable && !here })} + {@const description = `Step ${index + 1} of ${items.length}: ${item.label}`} +
    2. + {#if selectable} + + {:else} + + + {description} + {/if} + {#if index < items.length - 1} + + {/if}
    3. {/each}
    diff --git a/web/src/lib/components/welcome/WelcomeCard.svelte b/web/src/lib/components/welcome/WelcomeCard.svelte index f3858277..f2ff5b86 100644 --- a/web/src/lib/components/welcome/WelcomeCard.svelte +++ b/web/src/lib/components/welcome/WelcomeCard.svelte @@ -4,9 +4,12 @@ export const welcomeCard = tv({ slots: { root: "overflow-hidden rounded-sm border border-border-default bg-background-default shadow-xs", - header: "overflow-x-auto border-b border-border-default px-6 py-5", - body: "grid overflow-hidden p-6", - pane: "col-start-1 row-start-1 min-w-0", + header: "border-b border-border-default px-6 py-5", + body: "grid overflow-hidden", + // self-start keeps the pane at its content height: once the body carries a + // measured height the grid row would otherwise stretch the pane to match it, + // and measuring that back is a loop that never moves + pane: "col-start-1 row-start-1 min-w-0 self-start p-6 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus", footer: "flex items-center justify-between gap-4 border-t border-border-default px-6 py-4" } }); @@ -17,89 +20,194 @@
    +

    {stepDescription(step)}

    +
    - + + +
    -
    +
    {#key step} {@const frozen = untrack(() => step)} -
    + +
    {@render children(frozen)}
    {/key}
    - {#if step > 0} - - {:else} - - {/if} +
    + {#if step > 0} +
    + +
    + {/if} +
    diff --git a/web/src/lib/components/welcome/spring.ts b/web/src/lib/components/welcome/spring.ts new file mode 100644 index 00000000..66381c63 --- /dev/null +++ b/web/src/lib/components/welcome/spring.ts @@ -0,0 +1,33 @@ +// interior.dev's wizard-steps springs, sampled so CSS transitions and svelte +// transitions run the identical curve. both are overdamped (zeta > 1), which is +// why the closed form below needs no oscillating branch. +const springEasing = (stiffness: number, damping: number, mass: number, duration: number) => { + const omega = Math.sqrt(stiffness / mass); + const zeta = damping / (2 * Math.sqrt(stiffness * mass)); + const spread = omega * Math.sqrt(zeta * zeta - 1); + const fast = -zeta * omega + spread; + const slow = -zeta * omega - spread; + const at = (seconds: number) => + 1 - (slow * Math.exp(fast * seconds) - fast * Math.exp(slow * seconds)) / (slow - fast); + const end = at(duration / 1000); + return (t: number) => at((t * duration) / 1000) / end; +}; + +const cssLinear = (easing: (t: number) => number, steps = 20) => + `linear(${Array.from({ length: steps + 1 }, (_, i) => +easing(i / steps).toFixed(3)).join(", ")})`; + +/** rail markers and progress connectors */ +export const RAIL_DURATION = 400; +export const railEasing = springEasing(520, 40, 0.5, RAIL_DURATION); +export const RAIL_EASING = cssLinear(railEasing); + +/** step panes and the card height that follows them. interior's crossfade spring + (260/34/0.8) settles in 659ms, so it is rescaled at a constant zeta to land + inside the 300ms budget without changing its shape. */ +export const PANE_DURATION = 280; +export const paneEasing = springEasing(1438, 80, 0.8, PANE_DURATION); +export const PANE_EASING = cssLinear(paneEasing); + +/** leaving is shorter and closer in, matching ui/Modal */ +export const EXIT_DURATION = 140; +export const EXIT_EASING = "cubic-bezier(0.4, 0, 1, 1)"; diff --git a/web/src/lib/components/welcome/steps.ts b/web/src/lib/components/welcome/steps.ts index 1a4de1db..25c698eb 100644 --- a/web/src/lib/components/welcome/steps.ts +++ b/web/src/lib/components/welcome/steps.ts @@ -2,9 +2,9 @@ // persists the index in the `onboarding` table, so these values are storage // keys, not just display order — keep them aligned with the Go constants. export const WELCOME_STEPS = [ - { key: "profile", label: "Set up your profile" }, - { key: "social", label: "Follow & star" }, - { key: "keys", label: "Add SSH keys" }, + { key: "profile", label: "Profile" }, + { key: "social", label: "Discover" }, + { key: "keys", label: "SSH keys" }, { key: "finish", label: "Finish" } ] as const; diff --git a/web/src/routes/welcome/+page.svelte b/web/src/routes/welcome/+page.svelte index 9c485e34..b4668104 100644 --- a/web/src/routes/welcome/+page.svelte +++ b/web/src/routes/welcome/+page.svelte @@ -34,21 +34,34 @@ }); let keys = $state(untrack(() => data.keys ?? [])); let step = $state(0); + let furthest = $state(0); let direction = $state(1); let savingProfile = $state(false); let profileWritten = $state(false); let profileError = $state(); + let socialTouched = $state( + untrack( + () => + people.some((person) => person.viewerFollowRkey) || + repos.some((repo) => repo.viewerStarRkey) + ) + ); - const profileFilled = $derived( - draft.avatarFile !== null || - [draft.description, draft.pronouns, draft.website].some((value) => value.trim() !== "") + // green means "nothing left to do on this step", not "you may continue" — + // every field here is optional and Next is never blocked + const profileComplete = $derived( + (draft.avatarFile !== null || Boolean(loadedProfile?.avatar)) && + [draft.description, draft.pronouns, draft.website].every((value) => value.trim() !== "") + ); + const nextReady = $derived( + [profileComplete, socialTouched, keys.length > 0, true][step] ?? false ); - const nextReady = $derived(step === 0 ? profileFilled : true); const go = (to: number) => { if (to < 0 || to >= WELCOME_TOTAL || savingProfile) return; direction = to > step ? 1 : -1; step = to; + furthest = Math.max(furthest, to); }; const persistProfile = async (saveDraft: boolean): Promise => { @@ -106,13 +119,22 @@ } }; + const advance = async (to: number) => { + if (step === 0 && to > 0 && !(await persistProfile(true))) return; + go(to); + }; + const next = async () => { - if (step === 0 && !(await persistProfile(true))) return; if (step === LAST_STEP) { await leave(); return; } - go(step + 1); + await advance(step + 1); + }; + + const select = async (to: number) => { + if (savingProfile || to === step) return; + await advance(to); }; const leave = async () => { @@ -153,7 +175,7 @@
    -

    +

    @@ -161,17 +183,19 @@ go(step - 1)} onNext={() => void next()} + onSelect={(index) => void select(index)} > {#snippet children(current)} {#if current === 0} {:else if current === 1} - + (socialTouched = true)} /> {:else if current === 2} {:else} @@ -188,7 +212,7 @@ loading={savingProfile} onclick={() => void leave()} > - Skip onboarding + Skip for now
    {/if} -- 2.51.2