This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web: add the welcome onboarding flow

Four steps in one card — profile, follow & star, ssh keys, finish — with
the same content as the appview's /welcome templates. The step rail sits
at the top and the body slides left or right as you move between steps.

Data is mocked, nothing talks to an api yet.

Field and Textarea gained the variants the steps needed: sentence-case
labels, and a shorter box for the ssh key.

Signed-off-by: eti <eti@eti.tf>

+838 -16
+24
web/src/lib/components/icons/Bluesky.svelte
··· 1 + <script lang="ts"> 2 + // lucide carries no Bluesky mark, so this is the vector the appview already 3 + // ships at appview/pages/static/icons/bluesky.svg — kept identical so both 4 + // front ends draw the same butterfly. 5 + import type { SVGAttributes } from "svelte/elements"; 6 + 7 + let { class: className = "size-4", ...rest }: SVGAttributes<SVGSVGElement> = $props(); 8 + </script> 9 + 10 + <svg 11 + xmlns="http://www.w3.org/2000/svg" 12 + role="img" 13 + viewBox="-3 -3 30 30" 14 + class={className} 15 + {...rest} 16 + > 17 + <title>Bluesky</title> 18 + <path 19 + fill="none" 20 + stroke="currentColor" 21 + stroke-width="2.25" 22 + d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" 23 + /> 24 + </svg>
+32 -7
web/src/lib/components/ui/Field.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const field = tv({ 5 + slots: { 6 + root: "flex flex-col gap-1.5", 7 + label: "typography-paragraph-small font-medium text-foreground-default", 8 + subtitle: "typography-paragraph-small text-foreground-muted" 9 + }, 10 + variants: { 11 + // settings forms label their fields in small caps; the onboarding steps use 12 + // sentence case, so the casing is a variant rather than baked into the slot 13 + labelCase: { 14 + upper: { label: "tracking-wide uppercase" }, 15 + normal: { label: "" } 16 + } 17 + }, 18 + defaultVariants: { 19 + labelCase: "upper" 20 + } 21 + }); 22 + 23 + export type FieldVariants = VariantProps<typeof field>; 24 + </script> 25 + 1 26 <script lang="ts"> 2 27 import type { Snippet } from "svelte"; 3 28 import type { HTMLAttributes } from "svelte/elements"; ··· 7 32 subtitle?: string; 8 33 for?: string; 9 34 required?: boolean; 35 + labelCase?: FieldVariants["labelCase"]; 10 36 class?: string; 11 37 children: Snippet<[{ id: string }]>; 12 38 } ··· 16 42 subtitle, 17 43 for: forId, 18 44 required = false, 19 - class: className = "", 45 + labelCase = "upper", 46 + class: className, 20 47 children, 21 48 ...rest 22 49 }: Props = $props(); 23 50 24 51 const generatedId = $props.id(); 25 52 const id = $derived(forId ?? generatedId); 53 + const slot = $derived(field({ labelCase })); 26 54 </script> 27 55 28 - <div class={`flex flex-col gap-1.5 ${className}`} {...rest}> 56 + <div class={slot.root({ class: className })} {...rest}> 29 57 {#if label} 30 - <label 31 - for={id} 32 - class="typography-paragraph-small font-medium tracking-wide text-foreground-default uppercase" 33 - > 58 + <label for={id} class={slot.label()}> 34 59 {label} 35 60 {#if required} 36 61 <span class="text-foreground-danger">*</span> ··· 39 64 {/if} 40 65 {@render children({ id })} 41 66 {#if subtitle} 42 - <p class="typography-paragraph-small text-foreground-muted">{subtitle}</p> 67 + <p class={slot.subtitle()}>{subtitle}</p> 43 68 {/if} 44 69 </div>
+18 -7
web/src/lib/components/ui/Textarea.svelte
··· 9 9 export const textareaField = tv({ 10 10 slots: { 11 11 root: "relative flex items-start gap-1 rounded-sm border bg-background-default transition-colors", 12 - // the floor has to live on the textarea, not the root: the resize handle only ever 13 - // shrinks the textarea, so a min-height on the root would let the handle drag past the 14 - // box's own minimum and leave dead space under the field. keep this in sync with 15 - // textareaMinHeight above — it's 2px shorter because the root's border sits outside it. 16 12 textarea: 17 - "min-h-63.5 flex-1 self-stretch bg-transparent py-2 typography-paragraph-small leading-6 outline-none placeholder:text-foreground-placeholder read-only:resize-none disabled:cursor-not-allowed disabled:resize-none" 13 + "flex-1 self-stretch bg-transparent py-2 outline-none placeholder:text-foreground-placeholder read-only:resize-none disabled:cursor-not-allowed disabled:resize-none" 18 14 }, 19 15 variants: { 16 + // the floor has to live on the textarea, not the root: the resize handle only ever 17 + // shrinks the textarea, so a min-height on the root would let the handle drag past the 18 + // box's own minimum and leave dead space under the field. `default` keeps 19 + // textareaMinHeight above in sync — it's 2px shorter because the root's border sits 20 + // outside it. `compact` is for short fields (an ssh key, a one-line note) where the 21 + // editor-sized floor would swallow the form. 22 + size: { 23 + default: { textarea: "min-h-63.5" }, 24 + compact: { textarea: "min-h-20" } 25 + }, 20 26 error: { 21 27 false: { 22 28 root: "border-border-default focus-within:border-border-strong focus-within:ring-1 focus-within:ring-border-strong" ··· 46 52 defaultVariants: { 47 53 error: false, 48 54 disabled: false, 49 - readonly: false 55 + readonly: false, 56 + size: "default" 50 57 } 51 58 }); 52 59 ··· 65 72 loading?: boolean; 66 73 readonly?: boolean; 67 74 resizeable?: boolean; 75 + size?: TextareaFieldVariants["size"]; 68 76 iconLeft?: Component<SvelteHTMLElements["svg"]>; 69 77 iconRight?: Component<SvelteHTMLElements["svg"]>; 70 78 class?: string; ··· 78 86 loading = false, 79 87 readonly = false, 80 88 resizeable = false, 89 + size = "default", 81 90 iconLeft, 82 91 iconRight, 83 92 class: className, ··· 85 94 ...rest 86 95 }: Props = $props(); 87 96 88 - const { root, textarea } = $derived(textareaField({ error, disabled, readonly, resizeable })); 97 + const { root, textarea } = $derived( 98 + textareaField({ error, disabled, readonly, resizeable, size }) 99 + ); 89 100 90 101 // The root no longer carries the text inset (so the scrollbar/resize handle can sit flush 91 102 // against the border) — the textarea itself owns its horizontal padding, collapsing to 0 on
+167
web/src/lib/components/welcome/FinishStep.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + // the finish bento: a two-column grid where one tile runs the full width and 5 + // another spans both rows (appview onboarding/fragments/finish.html) 6 + export const tile = tv({ 7 + slots: { 8 + root: "flex flex-col gap-2 rounded-sm border border-border-default p-4", 9 + heading: "flex items-center gap-2 typography-paragraph-regular text-foreground-default", 10 + body: "flex-1 typography-paragraph-small text-foreground-muted", 11 + action: 12 + "ml-auto flex w-fit items-center gap-1 typography-paragraph-small text-foreground-default no-underline hover:underline" 13 + }, 14 + variants: { 15 + span: { 16 + full: { root: "sm:col-span-2" }, 17 + tall: { root: "sm:row-span-2" }, 18 + single: { root: "" } 19 + }, 20 + // the community row's tiles are themselves the link, so they need the hover 21 + // surface and the tighter gap the four bento tiles don't want 22 + link: { 23 + true: { 24 + root: "flex-1 gap-1 no-underline transition-colors hover:bg-background-subtle hover:no-underline" 25 + }, 26 + false: { root: "" } 27 + } 28 + }, 29 + defaultVariants: { 30 + span: "single", 31 + link: false 32 + } 33 + }); 34 + 35 + export type TileVariants = VariantProps<typeof tile>; 36 + </script> 37 + 38 + <script lang="ts"> 39 + import type { Component } from "svelte"; 40 + import type { SvelteHTMLElements } from "svelte/elements"; 41 + import { resolve } from "$app/paths"; 42 + import ArrowRight from "$icon/arrow-right"; 43 + import BookMarked from "$icon/book-marked"; 44 + import GitPullRequest from "$icon/git-pull-request"; 45 + import Layers2 from "$icon/layers-2"; 46 + import MessageCircle from "$icon/message-circle"; 47 + import Server from "$icon/server"; 48 + import Twitter from "$icon/twitter"; 49 + import Bluesky from "$lib/components/icons/Bluesky.svelte"; 50 + import StepHeader from "./StepHeader.svelte"; 51 + 52 + const slot = tile(); 53 + 54 + // the appview links /core, a shortcut route it owns; the svelte app addresses 55 + // repositories as /[handle]/[repo] 56 + const coreHref = resolve("/tangled.org/core" as "/"); 57 + const newRepoHref = resolve("/repo/new"); 58 + 59 + const docs: { 60 + icon: Component<SvelteHTMLElements["svg"]>; 61 + title: string; 62 + body: string; 63 + href: string; 64 + }[] = [ 65 + { 66 + icon: Server, 67 + title: "Host your own knot", 68 + body: "Host repositories on your own infrastructure with knots.", 69 + href: "https://docs.tangled.org/knot-self-hosting-guide.html" 70 + }, 71 + { 72 + icon: Layers2, 73 + title: "Host your own spindle", 74 + body: "Run CI jobs on your own infrastructure using spindles.", 75 + href: "https://docs.tangled.org/spindles.html#self-hosting-guide" 76 + } 77 + ]; 78 + 79 + const community: { 80 + icon: Component<SvelteHTMLElements["svg"]>; 81 + title: string; 82 + body: string; 83 + href: string; 84 + }[] = [ 85 + { 86 + icon: Bluesky, 87 + title: "Bluesky", 88 + body: "Follow us @tangled.org on Bluesky for updates.", 89 + href: "https://bsky.app/profile/tangled.org" 90 + }, 91 + { 92 + icon: MessageCircle, 93 + title: "Discord", 94 + body: "Join the community on Discord.", 95 + href: "https://chat.tangled.org" 96 + }, 97 + { 98 + icon: Twitter, 99 + title: "X (Twitter)", 100 + body: "Follow us @tangled_org on X for updates.", 101 + href: "https://x.com/tangled_org" 102 + } 103 + ]; 104 + </script> 105 + 106 + <div class="flex flex-col gap-6"> 107 + <StepHeader title="You're all set!" subtitle="Here are a few things to explore next:" /> 108 + 109 + <div class="grid grid-cols-1 gap-3 sm:grid-cols-2"> 110 + <div class={tile({ span: "full" }).root()}> 111 + {@render heading(BookMarked, "Create a repository")} 112 + <p class={slot.body()}>Kick things off by creating your first repository.</p> 113 + <a href={newRepoHref} class={slot.action()}> 114 + Create 115 + <ArrowRight class="size-4 shrink-0" aria-hidden="true" /> 116 + </a> 117 + </div> 118 + 119 + <div class={tile({ span: "tall" }).root()}> 120 + {@render heading(GitPullRequest, "Contribute to the Tangled project")} 121 + <p class={slot.body()}> 122 + Tangled is fully open-source: Open a feature request, or submit a bug-fix! 123 + <br /><br /> 124 + All contributions are welcome. 125 + </p> 126 + <a href={coreHref} class={slot.action()}> 127 + Contribute 128 + <ArrowRight class="size-4 shrink-0" aria-hidden="true" /> 129 + </a> 130 + </div> 131 + 132 + {#each docs as doc (doc.title)} 133 + <div class={slot.root()}> 134 + {@render heading(doc.icon, doc.title)} 135 + <p class={slot.body()}>{doc.body}</p> 136 + <a href={doc.href} target="_blank" rel="external noopener noreferrer" class={slot.action()}> 137 + Read the docs 138 + <ArrowRight class="size-4 shrink-0" aria-hidden="true" /> 139 + </a> 140 + </div> 141 + {/each} 142 + </div> 143 + 144 + <ul class="grid grid-cols-1 gap-3 sm:grid-cols-3"> 145 + {#each community as link (link.title)} 146 + <li class="flex"> 147 + <a 148 + href={link.href} 149 + target="_blank" 150 + rel="external noopener noreferrer" 151 + class={tile({ link: true }).root()} 152 + > 153 + {@render heading(link.icon, link.title)} 154 + <span class={slot.body()}>{link.body}</span> 155 + </a> 156 + </li> 157 + {/each} 158 + </ul> 159 + </div> 160 + 161 + {#snippet heading(icon: Component<SvelteHTMLElements["svg"]>, title: string)} 162 + {@const Icon = icon} 163 + <span class={slot.heading()}> 164 + <Icon class="size-4 shrink-0" aria-hidden="true" /> 165 + {title} 166 + </span> 167 + {/snippet}
+68
web/src/lib/components/welcome/KeysStep.svelte
··· 1 + <script lang="ts"> 2 + import Plus from "$icon/plus"; 3 + import Button from "$lib/components/ui/Button.svelte"; 4 + import Field from "$lib/components/ui/Field.svelte"; 5 + import Input from "$lib/components/ui/Input.svelte"; 6 + import Textarea from "$lib/components/ui/Textarea.svelte"; 7 + import KeyCard from "$lib/components/settings/KeyCard.svelte"; 8 + import StepHeader from "./StepHeader.svelte"; 9 + import { addKey, draft, removeKey } from "./draft.svelte"; 10 + 11 + let name = $state(""); 12 + let key = $state(""); 13 + 14 + const canAdd = $derived(name.trim() !== "" && key.trim() !== ""); 15 + 16 + const add = (event: SubmitEvent) => { 17 + event.preventDefault(); 18 + if (!canAdd) return; 19 + addKey(name.trim(), key.trim()); 20 + name = ""; 21 + key = ""; 22 + }; 23 + </script> 24 + 25 + <div class="flex flex-col gap-6"> 26 + <StepHeader 27 + title="Add an SSH key" 28 + subtitle="Add a public SSH key to push to your repositories over SSH." 29 + /> 30 + 31 + {#if draft.keys.length > 0} 32 + <ul class="flex flex-col gap-3"> 33 + {#each draft.keys as pubkey (pubkey.id)} 34 + <li> 35 + <KeyCard 36 + name={pubkey.name} 37 + fingerprint={pubkey.fingerprint} 38 + createdAt={pubkey.createdAt} 39 + onDelete={() => removeKey(pubkey.id)} 40 + /> 41 + </li> 42 + {/each} 43 + </ul> 44 + {/if} 45 + 46 + <form class="flex flex-col gap-4" onsubmit={add}> 47 + <Field label="Key name" labelCase="normal"> 48 + {#snippet children({ id })} 49 + <Input {id} placeholder="my-laptop" required bind:value={name} /> 50 + {/snippet} 51 + </Field> 52 + 53 + <Field label="Public key" labelCase="normal"> 54 + {#snippet children({ id })} 55 + <Textarea 56 + {id} 57 + size="compact" 58 + rows={3} 59 + required 60 + placeholder="ssh-ed25519 AAAA..." 61 + bind:value={key} 62 + /> 63 + {/snippet} 64 + </Field> 65 + 66 + <Button type="submit" icon={Plus} disabled={!canAdd} class="ml-auto">Add key</Button> 67 + </form> 68 + </div>
+73
web/src/lib/components/welcome/ProfileStep.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const avatarPicker = tv({ 5 + slots: { 6 + label: 7 + "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", 8 + image: "size-full rounded-full object-cover", 9 + placeholder: "flex flex-col items-center justify-center gap-1" 10 + } 11 + }); 12 + 13 + export type AvatarPickerVariants = VariantProps<typeof avatarPicker>; 14 + </script> 15 + 16 + <script lang="ts"> 17 + import Camera from "$icon/camera"; 18 + import Field from "$lib/components/ui/Field.svelte"; 19 + import Input from "$lib/components/ui/Input.svelte"; 20 + import StepHeader from "./StepHeader.svelte"; 21 + import { draft } from "./draft.svelte"; 22 + 23 + const slot = avatarPicker(); 24 + 25 + // no upload endpoint wired up: the preview is a local object url, which is all 26 + // the appview shows before its hx-post to /profile/avatar comes back 27 + const pick = (event: Event) => { 28 + const file = (event.currentTarget as HTMLInputElement).files?.[0]; 29 + if (!file) return; 30 + if (draft.picture) URL.revokeObjectURL(draft.picture); 31 + draft.picture = URL.createObjectURL(file); 32 + }; 33 + </script> 34 + 35 + <div class="flex flex-col gap-6"> 36 + <StepHeader title="Welcome!" subtitle="Let's get your profile ready. All fields are optional." /> 37 + 38 + <div class="flex flex-col gap-6 md:flex-row"> 39 + <div class="flex shrink-0 justify-center md:block"> 40 + <label class={slot.label()}> 41 + {#if draft.picture} 42 + <img src={draft.picture} alt="Your new avatar" class={slot.image()} /> 43 + {:else} 44 + <span class={slot.placeholder()}> 45 + <Camera class="size-6" aria-hidden="true" /> 46 + <span class="typography-paragraph-small">Upload picture</span> 47 + </span> 48 + {/if} 49 + <input type="file" accept="image/png,image/jpeg" class="hidden" onchange={pick} /> 50 + </label> 51 + </div> 52 + 53 + <div class="flex min-w-0 flex-1 flex-col gap-4"> 54 + <Field label="Bio" labelCase="normal"> 55 + {#snippet children({ id })} 56 + <Input {id} placeholder="Tell us about yourself" bind:value={draft.description} /> 57 + {/snippet} 58 + </Field> 59 + 60 + <Field label="Pronouns" labelCase="normal"> 61 + {#snippet children({ id })} 62 + <Input {id} placeholder="they/them" bind:value={draft.pronouns} /> 63 + {/snippet} 64 + </Field> 65 + 66 + <Field label="Website" labelCase="normal"> 67 + {#snippet children({ id })} 68 + <Input {id} placeholder="example.com" bind:value={draft.website} /> 69 + {/snippet} 70 + </Field> 71 + </div> 72 + </div> 73 + </div>
+32
web/src/lib/components/welcome/SocialStep.svelte
··· 1 + <script lang="ts"> 2 + import EmptyState from "$lib/components/ui/EmptyState.svelte"; 3 + import FollowCard from "$lib/components/profile/FollowCard.svelte"; 4 + import RepoCard from "$lib/components/repo/RepoCard.svelte"; 5 + import { repoKey } from "$lib/components/profile/types"; 6 + import StepHeader from "./StepHeader.svelte"; 7 + import { people, trendingRepos } from "./mock"; 8 + </script> 9 + 10 + <div class="flex flex-col gap-6"> 11 + <StepHeader title="Discover" subtitle="Find users to follow and repositories to star." /> 12 + 13 + {#if people.length > 0 || trendingRepos.length > 0} 14 + {#if people.length > 0} 15 + <div class="flex flex-col gap-3"> 16 + {#each people as person (person.did)} 17 + <FollowCard {person} border /> 18 + {/each} 19 + </div> 20 + {/if} 21 + 22 + {#if trendingRepos.length > 0} 23 + <div class="flex flex-col gap-3"> 24 + {#each trendingRepos as repo (repoKey(repo))} 25 + <RepoCard {repo} border /> 26 + {/each} 27 + </div> 28 + {/if} 29 + {:else} 30 + <EmptyState message="Nothing to suggest right now — you can explore once you're set up." /> 31 + {/if} 32 + </div>
+32
web/src/lib/components/welcome/StepHeader.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + // every step opens with the same title/subtitle pair (appview onboarding/* fragments) 5 + export const stepHeader = tv({ 6 + slots: { 7 + root: "flex flex-col gap-2", 8 + title: "typography-heading-3 font-semibold text-foreground-default", 9 + subtitle: "typography-paragraph-regular text-foreground-muted" 10 + } 11 + }); 12 + 13 + export type StepHeaderVariants = VariantProps<typeof stepHeader>; 14 + </script> 15 + 16 + <script lang="ts"> 17 + interface Props { 18 + title: string; 19 + subtitle?: string; 20 + } 21 + 22 + let { title, subtitle }: Props = $props(); 23 + 24 + const slot = stepHeader(); 25 + </script> 26 + 27 + <header class={slot.root()}> 28 + <h1 class={slot.title()}>{title}</h1> 29 + {#if subtitle} 30 + <p class={slot.subtitle()}>{subtitle}</p> 31 + {/if} 32 + </header>
+79
web/src/lib/components/welcome/Steps.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + // the numbered rail from Figma node 191:6514 — same shape as the appview's 5 + // onboarding/fragments/steps, restyled onto the design-system tokens. the three 6 + // states differ only in the marker fill and the label colour, so they are one 7 + // variant across two slots rather than three hand-written class strings. 8 + export const steps = tv({ 9 + slots: { 10 + root: "flex w-full items-center gap-2", 11 + item: "flex shrink-0 items-center gap-2", 12 + marker: 13 + "flex size-5 shrink-0 items-center justify-center rounded-full typography-paragraph-small", 14 + // labels are desktop-only, but sr-only rather than hidden so the rail still 15 + // reads as a named list of steps on a narrow screen 16 + label: "sr-only typography-paragraph-small whitespace-nowrap sm:not-sr-only", 17 + connector: "h-px min-w-4 flex-1 bg-border-default" 18 + }, 19 + variants: { 20 + state: { 21 + done: { 22 + marker: "bg-background-inset text-foreground-default", 23 + label: "text-foreground-subtle" 24 + }, 25 + current: { 26 + marker: "bg-background-muted text-foreground-default", 27 + label: "text-foreground-default" 28 + }, 29 + upcoming: { 30 + marker: "bg-background-inset text-foreground-subtle", 31 + label: "text-foreground-subtle" 32 + } 33 + } 34 + }, 35 + defaultVariants: { 36 + state: "upcoming" 37 + } 38 + }); 39 + 40 + export type StepsVariants = VariantProps<typeof steps>; 41 + export type StepState = NonNullable<StepsVariants["state"]>; 42 + </script> 43 + 44 + <script lang="ts"> 45 + import Check from "$icon/check"; 46 + 47 + interface Props { 48 + items: readonly { key: string; label: string }[]; 49 + current: number; 50 + } 51 + 52 + let { items, current }: Props = $props(); 53 + 54 + // root and connector take no variant, so they resolve once 55 + const shell = steps(); 56 + 57 + const stateOf = (index: number): StepState => 58 + index < current ? "done" : index === current ? "current" : "upcoming"; 59 + </script> 60 + 61 + <ol class={shell.root()}> 62 + {#each items as item, index (item.key)} 63 + {#if index > 0} 64 + <li aria-hidden="true" class={shell.connector()}></li> 65 + {/if} 66 + {@const state = stateOf(index)} 67 + {@const slot = steps({ state })} 68 + <li class={slot.item()} aria-current={state === "current" ? "step" : undefined}> 69 + <span class={slot.marker()} aria-label={state === "done" ? "done" : undefined}> 70 + {#if state === "done"} 71 + <Check class="size-3.5" aria-hidden="true" /> 72 + {:else} 73 + {index + 1} 74 + {/if} 75 + </span> 76 + <span class={slot.label()}>{item.label}</span> 77 + </li> 78 + {/each} 79 + </ol>
+96
web/src/lib/components/welcome/WelcomeCard.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const welcomeCard = tv({ 5 + slots: { 6 + root: "overflow-hidden rounded-sm border border-border-default bg-background-default shadow-xs", 7 + header: "overflow-x-auto border-b border-border-default px-6 py-5", 8 + // grid so the outgoing and incoming panes share one cell and cross-fade in 9 + // place; overflow-hidden is what keeps the swipe inside the card 10 + body: "grid overflow-hidden p-6", 11 + pane: "col-start-1 row-start-1 min-w-0", 12 + footer: "flex items-center justify-between gap-4 border-t border-border-default px-6 py-4" 13 + } 14 + }); 15 + 16 + export type WelcomeCardVariants = VariantProps<typeof welcomeCard>; 17 + </script> 18 + 19 + <script lang="ts"> 20 + import type { Snippet } from "svelte"; 21 + import { untrack } from "svelte"; 22 + import { cubicIn, cubicOut } from "svelte/easing"; 23 + import { prefersReducedMotion } from "svelte/motion"; 24 + import { fly } from "svelte/transition"; 25 + import ArrowLeft from "$icon/arrow-left"; 26 + import ArrowRight from "$icon/arrow-right"; 27 + import Check from "$icon/check"; 28 + import Button from "$lib/components/ui/Button.svelte"; 29 + import Steps from "./Steps.svelte"; 30 + import { WELCOME_STEPS, LAST_STEP } from "./steps"; 31 + 32 + interface Props { 33 + step: number; 34 + /** +1 when advancing, -1 when going back — decides which way the pane slides */ 35 + direction: number; 36 + /** the appview greens Next once the step has something worth saving */ 37 + nextReady?: boolean; 38 + onBack: () => void; 39 + onNext: () => void; 40 + /** rendered per step index, frozen at pane creation so the outgoing pane keeps its own content */ 41 + children: Snippet<[number]>; 42 + } 43 + 44 + let { step, direction, nextReady = false, onBack, onNext, children }: Props = $props(); 45 + 46 + const slot = welcomeCard(); 47 + 48 + const DISTANCE = 40; 49 + const enter = $derived( 50 + prefersReducedMotion.current 51 + ? { duration: 0 } 52 + : { x: direction * DISTANCE, duration: 240, delay: 90, easing: cubicOut } 53 + ); 54 + const leave = $derived( 55 + prefersReducedMotion.current 56 + ? { duration: 0 } 57 + : { x: direction * -DISTANCE, duration: 150, easing: cubicIn } 58 + ); 59 + 60 + const isLast = $derived(step === LAST_STEP); 61 + </script> 62 + 63 + <div class={slot.root()}> 64 + <div class={slot.header()}> 65 + <Steps items={WELCOME_STEPS} current={step} /> 66 + </div> 67 + 68 + <div class={slot.body()}> 69 + {#key step} 70 + <!-- untrack freezes the index at creation: `children` is a live snippet, so 71 + without this the leaving pane would re-render with the new step's content 72 + halfway through its own exit transition --> 73 + {@const frozen = untrack(() => step)} 74 + <div class={slot.pane()} in:fly={enter} out:fly={leave}> 75 + {@render children(frozen)} 76 + </div> 77 + {/key} 78 + </div> 79 + 80 + <div class={slot.footer()}> 81 + {#if step > 0} 82 + <Button variant="ghost" icon={ArrowLeft} onclick={onBack}>Back</Button> 83 + {:else} 84 + <span></span> 85 + {/if} 86 + 87 + <Button 88 + variant={nextReady ? "primary" : "default"} 89 + icon={isLast ? Check : ArrowRight} 90 + iconSide={isLast ? "left" : "right"} 91 + onclick={onNext} 92 + > 93 + {isLast ? "Finish" : "Next"} 94 + </Button> 95 + </div> 96 + </div>
+33
web/src/lib/components/welcome/draft.svelte.ts
··· 1 + // what the user has entered so far. the step panes are destroyed and recreated on 2 + // every transition, so this cannot live inside them — the appview has the same 3 + // split for a different reason, keeping progress in the `onboarding` table and the 4 + // profile/keys on the record, so wiring this up later means replacing the module 5 + // rather than moving state around the components. 6 + 7 + import { initialKeys, newKey, type MockKey } from "./mock"; 8 + 9 + export const draft = $state({ 10 + description: "", 11 + pronouns: "", 12 + website: "", 13 + /** a local object url until there is an upload endpoint to post to */ 14 + picture: undefined as string | undefined, 15 + keys: [...initialKeys] as MockKey[] 16 + }); 17 + 18 + /** the appview greens Next once any profile field has something in it */ 19 + export const profileFilled = () => 20 + [draft.description, draft.pronouns, draft.website].some((value) => value.trim() !== ""); 21 + 22 + // the counter lives here rather than in KeysStep: the pane is recreated on every 23 + // transition, so a component-local counter would restart and collide with the ids 24 + // already in the list 25 + let nextKeyId = 1; 26 + 27 + export const addKey = (name: string, key: string) => { 28 + draft.keys = [...draft.keys, newKey(nextKeyId++, name, key)]; 29 + }; 30 + 31 + export const removeKey = (id: number) => { 32 + draft.keys = draft.keys.filter((key) => key.id !== id); 33 + };
+95
web/src/lib/components/welcome/mock.ts
··· 1 + // stand-in data for the welcome flow. the appview fills these from 2 + // OnboardingSocial / OnboardingKeys; nothing here is wired to an api yet, so the 3 + // step components read from this module and a real load later replaces it. 4 + 5 + import type { PersonData, RepoCardData } from "$lib/components/profile/types"; 6 + 7 + export interface MockKey { 8 + id: number; 9 + name: string; 10 + fingerprint: string; 11 + createdAt: string; 12 + } 13 + 14 + const did = (name: string) => `did:plc:${name.replace(/[^a-z0-9]/g, "")}welcome`; 15 + 16 + /** OnboardingSocial's People — suggested accounts to follow */ 17 + export const people: PersonData[] = [ 18 + { 19 + did: did("alphaone"), 20 + handle: "alpha.one", 21 + description: "Innovative coder at tangled.org, passionate about sustainable tech.", 22 + followers: 1100, 23 + following: 99 24 + }, 25 + { 26 + did: did("betaspace"), 27 + handle: "beta.space", 28 + description: "Rust and Go enthusiast, creating pixel art and exploring plant-based living.", 29 + followers: 987, 30 + following: 78 31 + }, 32 + { 33 + did: did("gammanetwork"), 34 + handle: "gamma.network", 35 + description: "Co-founder of EcoTech Labs, blending software with environmental solutions.", 36 + followers: 1700, 37 + following: 25 38 + } 39 + ]; 40 + 41 + /** OnboardingSocial's TrendingRepos */ 42 + export const trendingRepos: RepoCardData[] = [ 43 + { 44 + rkey: "welcome-1", 45 + name: "core", 46 + repoDid: did("tangledorg"), 47 + ownerHandle: "tangled.org", 48 + description: "A decentralized network for secure and scalable data storage.", 49 + createdAt: "2026-05-02T10:00:00Z", 50 + language: "Go", 51 + stars: 2200, 52 + issues: 31, 53 + pulls: 1 54 + }, 55 + { 56 + rkey: "welcome-2", 57 + name: "batman.vim", 58 + repoDid: did("072004xyz"), 59 + ownerHandle: "072004.xyz", 60 + description: "A Vim colourscheme that only works at night.", 61 + createdAt: "2026-04-18T10:00:00Z", 62 + language: "Vim Script", 63 + stars: 412, 64 + issues: 3, 65 + pulls: 2 66 + }, 67 + { 68 + rkey: "welcome-3", 69 + name: "knotserver", 70 + repoDid: did("coopppage"), 71 + ownerHandle: "coop.page", 72 + description: "Self-hostable git remote that speaks the Tangled protocol.", 73 + createdAt: "2026-03-09T10:00:00Z", 74 + language: "Rust", 75 + stars: 856, 76 + issues: 12, 77 + pulls: 4 78 + } 79 + ]; 80 + 81 + /** OnboardingKeys' PubKeys — starts empty, the step adds to it */ 82 + export const initialKeys: MockKey[] = []; 83 + 84 + /** 85 + * Builds the row the appview would get back from `PUT /settings/keys`. It lives 86 + * here rather than in draft.svelte.ts because svelte/prefer-svelte-reactivity 87 + * rejects a plain `Date` inside a rune module, and this one is a throwaway — the 88 + * timestamp is read once, never held. 89 + */ 90 + export const newKey = (id: number, name: string, key: string): MockKey => ({ 91 + id, 92 + name, 93 + fingerprint: `SHA256:${key.slice(-32)}`, 94 + createdAt: new Date().toISOString() 95 + });
+16
web/src/lib/components/welcome/steps.ts
··· 1 + // the onboarding steps, mirroring appview/models/onboarding.go. the appview 2 + // persists the index in the `onboarding` table, so these values are storage 3 + // keys, not just display order — keep them aligned with the Go constants. 4 + export const WELCOME_STEPS = [ 5 + { key: "profile", label: "Set up your profile" }, 6 + { key: "social", label: "Follow & star" }, 7 + { key: "keys", label: "Add SSH keys" }, 8 + { key: "finish", label: "Finish" } 9 + ] as const; 10 + 11 + export type WelcomeStepKey = (typeof WELCOME_STEPS)[number]["key"]; 12 + 13 + export const WELCOME_TOTAL = WELCOME_STEPS.length; 14 + 15 + /** the last step is the summary — there is nothing left to skip past it */ 16 + export const LAST_STEP = WELCOME_TOTAL - 1;
+4 -2
web/src/routes/+layout.svelte
··· 20 20 21 21 const signedIn = $derived(Boolean(auth.currentUser)); 22 22 const isAuthShell = $derived(page.url.pathname === "/login"); 23 + // the welcome flow runs its own steps chrome, so the bar and footer step aside 24 + const isWelcomeShell = $derived(page.url.pathname === "/welcome"); 23 25 const isMarketingShell = $derived( 24 26 !signedIn && (page.url.pathname === "/about" || page.url.pathname === "/") 25 27 ); ··· 37 39 ? "flex min-h-screen flex-col bg-linear-to-b from-background-default to-background-canvas text-foreground-default" 38 40 : "flex min-h-screen flex-col bg-background-canvas text-foreground-default"} 39 41 > 40 - {#if !isAuthShell} 42 + {#if !isAuthShell && !isWelcomeShell} 41 43 <header 42 44 class={isMarketingShell 43 45 ? "z-20 w-full bg-transparent pt-[env(safe-area-inset-top)]" ··· 56 58 {@render children()} 57 59 </main> 58 60 59 - {#if !isAuthShell} 61 + {#if !isAuthShell && !isWelcomeShell} 60 62 <Footer variant={isMarketingShell ? "full" : "minimal"} /> 61 63 {/if} 62 64 </div>
+69
web/src/routes/welcome/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from "$app/navigation"; 3 + import { resolve } from "$app/paths"; 4 + import X from "$icon/x"; 5 + import Button from "$lib/components/ui/Button.svelte"; 6 + import Logo from "$lib/components/ui/Logo.svelte"; 7 + import FinishStep from "$lib/components/welcome/FinishStep.svelte"; 8 + import KeysStep from "$lib/components/welcome/KeysStep.svelte"; 9 + import ProfileStep from "$lib/components/welcome/ProfileStep.svelte"; 10 + import SocialStep from "$lib/components/welcome/SocialStep.svelte"; 11 + import WelcomeCard from "$lib/components/welcome/WelcomeCard.svelte"; 12 + import { profileFilled } from "$lib/components/welcome/draft.svelte"; 13 + import { LAST_STEP, WELCOME_TOTAL } from "$lib/components/welcome/steps"; 14 + 15 + // the appview keeps the index in the onboarding table and gives each step its 16 + // own GET route; with nothing wired up yet this is local state, so wiring it 17 + // later means splitting this into /welcome/[step] and reading the record. 18 + let step = $state(0); 19 + let direction = $state(1); 20 + 21 + const go = (to: number) => { 22 + if (to < 0 || to >= WELCOME_TOTAL) return; 23 + direction = to > step ? 1 : -1; 24 + step = to; 25 + }; 26 + 27 + // the appview greens Next on every step past the profile, where it is earned 28 + const nextReady = $derived(step === 0 ? profileFilled() : true); 29 + 30 + const leave = () => goto(resolve("/")); 31 + </script> 32 + 33 + <svelte:head> 34 + <title>Welcome &middot; Tangled</title> 35 + </svelte:head> 36 + 37 + <div class="flex min-h-screen w-full items-start justify-center px-4 pt-16 pb-8"> 38 + <main class="w-full max-w-3xl"> 39 + <h1 class="mb-4 flex place-content-center"> 40 + <Logo wordmark class="h-10" /> 41 + </h1> 42 + 43 + <WelcomeCard 44 + {step} 45 + {direction} 46 + {nextReady} 47 + onBack={() => go(step - 1)} 48 + onNext={() => (step === LAST_STEP ? leave() : go(step + 1))} 49 + > 50 + {#snippet children(current)} 51 + {#if current === 0} 52 + <ProfileStep /> 53 + {:else if current === 1} 54 + <SocialStep /> 55 + {:else if current === 2} 56 + <KeysStep /> 57 + {:else} 58 + <FinishStep /> 59 + {/if} 60 + {/snippet} 61 + </WelcomeCard> 62 + 63 + {#if step < LAST_STEP} 64 + <div class="mt-4 flex justify-center"> 65 + <Button variant="ghost" icon={X} onclick={leave}>Skip onboarding</Button> 66 + </div> 67 + {/if} 68 + </main> 69 + </div>