+
{#key step}
{@const frozen = untrack(() => step)}
-
+
+
{@render children(frozen)}
{/key}
diff --git a/web/src/lib/components/welcome/spring.ts b/web/src/lib/components/welcome/spring.ts
new file mode 100644
index 000000000..66381c634
--- /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 1a4de1dbb..25c698ebe 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 9c485e34c..b46681048 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}