diff --git a/web/src/routes/welcome/+page.svelte b/web/src/routes/welcome/+page.svelte --- 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(); - - const profileFilled = $derived( - draft.avatarFile !== null || - [draft.description, draft.pronouns, draft.website].some((value) => value.trim() !== "") + let socialTouched = $state( + untrack( + () => + people.some((person) => person.viewerFollowRkey) || + repos.some((repo) => repo.viewerStarRkey) + ) ); - const nextReady = $derived(step === 0 ? profileFilled : true); + + // 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 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} diff --git a/web/src/lib/components/profile/FollowCard.svelte b/web/src/lib/components/profile/FollowCard.svelte --- 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 --- 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 --- a/web/src/lib/components/profile/types.ts +++ b/web/src/lib/components/profile/types.ts @@ -45,6 +45,14 @@ 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 --- 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 --- 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 --- 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 --- 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 --- 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 --- 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 --- 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 --- 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 --- 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 --- /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 --- 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;