This repository has no description
0

Configure Feed

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

web: give Avatar a named size scale

Avatar used to take a tailwind class for its size, and a separate `tiny` flag
that picked which image size to download. Nothing kept those two in agreement,
and they drifted apart: comment avatars draw at 32px but downloaded a 32px
image, so they looked blurry on retina screens, while the small 16px avatars in
settings downloaded a full-size one.

Now there's one prop. Pick mini, small, regular or large — the names the design
system already uses — and Avatar works out the image size itself, always asking
for twice what it draws so it stays sharp. `full` is for the cases where the
surrounding layout sets the size instead; it fills whatever box you put it in
and uses the original image.

The sizes go through tv() so you can still pass your own size class and have it
win. Two size classes in one string is a coin flip otherwise, because tailwind
decides by where the rules land in the stylesheet, not the order you wrote them.

A few avatars shift slightly to land on the scale: comments 32 to 26, settings
lists 16 to 17, access and last commit 20 to 21, repo header and commit rows 24
to 26.

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

+93 -41
+2 -2
web/src/lib/avatar.ts
··· 1 1 // the service wants signed urls and the secret is server side, so this points 2 2 // at the route that signs 3 - export const avatarUrl = (did: string, tiny = false): string => 4 - `/avatar/${encodeURIComponent(did)}${tiny ? "?size=tiny" : ""}`; 3 + export const avatarUrl = (did: string, width?: number): string => 4 + `/avatar/${encodeURIComponent(did)}${width ? `?size=${width}` : ""}`;
+1 -1
web/src/lib/components/comment/Comment.svelte
··· 45 45 class={`flex gap-2 ${variant === "top" ? "border-b border-border-default bg-background-default px-6 py-4" : "py-4 pr-4"}`} 46 46 > 47 47 <div class="shrink-0"> 48 - <Avatar did={authorDid} handle={authorHandle} size="size-8" tiny /> 48 + <Avatar did={authorDid} handle={authorHandle} size="large" /> 49 49 </div> 50 50 <div class="min-w-0 flex-1"> 51 51 <div class="flex flex-wrap items-center gap-1 typography-paragraph-small text-foreground-subtle">
+1 -1
web/src/lib/components/comment/CommentBox.svelte
··· 64 64 <!-- two-column, mirroring Comment: avatar left, handle + editor right --> 65 65 <div class="flex gap-2 bg-background-default/50 px-6 py-3"> 66 66 <div class="shrink-0"> 67 - <Avatar did={authorDid} handle={authorHandle} size="size-8" tiny /> 67 + <Avatar did={authorDid} handle={authorHandle} size="large" /> 68 68 </div> 69 69 <div class="flex min-w-0 flex-1 flex-col gap-2"> 70 70 <div class="flex flex-wrap items-center gap-1 typography-paragraph-small text-foreground-subtle">
+1 -1
web/src/lib/components/profile/FollowCardContent.svelte
··· 19 19 <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> 20 20 <div class="flex min-w-0 flex-1 items-center gap-4"> 21 21 <div class="flex size-24 shrink-0 items-center justify-center"> 22 - <Avatar did={person.did} handle={person.handle} size="size-24" class="p-2" /> 22 + <Avatar did={person.did} handle={person.handle} size="full" class="p-2" /> 23 23 </div> 24 24 25 25 <div class="flex min-w-0 flex-1 flex-col justify-around">
+1 -6
web/src/lib/components/profile/ProfileCard.svelte
··· 40 40 <div class="grid grid-cols-3 items-center gap-1 md:grid-cols-1"> 41 41 <div class="col-span-1 flex items-center justify-center"> 42 42 <div class="relative aspect-square w-3/4"> 43 - <Avatar 44 - did={identity.did} 45 - handle={identity.handle} 46 - size="h-full w-full" 47 - class="absolute inset-0" 48 - /> 43 + <Avatar did={identity.did} handle={identity.handle} size="full" class="absolute inset-0" /> 49 44 </div> 50 45 </div> 51 46
+1 -1
web/src/lib/components/repo/CommitHeader.svelte
··· 42 42 <span class="w-24 text-foreground-subtle select-none">{label}</span> 43 43 <!-- no email -> did mapping here, always the fallback form --> 44 44 <span class="flex items-center gap-1"> 45 - <Avatar size="size-6" /> 45 + <Avatar size="large" /> 46 46 {#if email} 47 47 <a href="mailto:{email}" class="no-underline hover:underline">{name}</a> 48 48 {:else}
+1 -1
web/src/lib/components/repo/CommitLogView.svelte
··· 42 42 {#snippet authorCell(commit: CommitSummary)} 43 43 <span class="flex items-center gap-1"> 44 44 <!-- no did/handle on a commit summary, always the placeholder --> 45 - <Avatar size="size-6" /> 45 + <Avatar size="large" /> 46 46 {#if commit.authorEmail} 47 47 <a href="mailto:{commit.authorEmail}" class="no-underline hover:underline"> 48 48 {commit.authorName}
+1 -1
web/src/lib/components/repo/LastCommitPanel.svelte
··· 21 21 <div class="flex min-w-0 flex-wrap items-center gap-2"> 22 22 {#if commit.authorName} 23 23 <!-- no email→did mapping yet, always the placeholder --> 24 - <Avatar size="size-5" /> 24 + <Avatar size="regular" /> 25 25 <span class="truncate text-foreground-default">{commit.authorName}</span> 26 26 <span class="text-foreground-subtle" aria-hidden="true">&middot;</span> 27 27 {/if}
+1 -1
web/src/lib/components/repo/RepoHeader.svelte
··· 74 74 href={resolve(`/${repo.ownerHandle}` as "/")} 75 75 class="flex items-center gap-2 text-foreground-default no-underline hover:underline" 76 76 > 77 - <Avatar did={repo.ownerDid} handle={repo.ownerHandle} size="size-6" tiny /> 77 + <Avatar did={repo.ownerDid} handle={repo.ownerHandle} size="large" /> 78 78 {repo.ownerHandle} 79 79 </a> 80 80 <span class="text-foreground-subtle select-none">/</span>
+1 -1
web/src/lib/components/settings/tabs/ProfileTab.svelte
··· 60 60 <div class="flex w-full flex-col gap-10"> 61 61 <SettingsSection> 62 62 <div class="flex w-full flex-col items-center gap-2 pb-4"> 63 - <Avatar did={user?.did} handle={user?.handle} size="size-[158px]" /> 63 + <Avatar did={user?.did} handle={user?.handle} size="full" class="size-39.5" /> 64 64 <Button icon={Pencil} href="/settings/profile/avatar">Edit</Button> 65 65 </div> 66 66 <SettingsRow title="Bio" stack>
+12 -7
web/src/lib/components/ui/Avatar.stories.svelte
··· 1 1 <script module lang="ts"> 2 2 import { defineMeta } from "@storybook/addon-svelte-csf"; 3 - import Avatar from "./Avatar.svelte"; 3 + import Avatar, { avatarSizeNames } from "./Avatar.svelte"; 4 + 5 + // `full` takes its size from the layout, so it has nothing to show in a ramp 6 + const scale = avatarSizeNames.filter((size) => size !== "full"); 4 7 5 8 const { Story } = defineMeta({ 6 9 title: "UI/Avatar", ··· 10 13 did: { control: "text" }, 11 14 src: { control: "text" }, 12 15 handle: { control: "text" }, 13 - tiny: { control: "boolean" }, 14 - size: { control: "text" } 16 + size: { control: "select", options: avatarSizeNames } 15 17 }, 16 18 args: { 17 19 handle: "user.tld" ··· 25 27 <Story name="Broken image" args={{ src: "https://example.invalid/broken.png" }} /> 26 28 27 29 <Story name="Sizes" asChild> 28 - <div class="flex items-center gap-3"> 29 - <Avatar handle="user.tld" size="size-6" /> 30 - <Avatar handle="user.tld" size="size-10" /> 31 - <Avatar handle="user.tld" size="size-16" /> 30 + <div class="flex items-end gap-3"> 31 + {#each scale as size (size)} 32 + <div class="flex flex-col items-center gap-1"> 33 + <Avatar handle="user.tld" {size} /> 34 + <span class="typography-paragraph-mini text-foreground-muted">{size}</span> 35 + </div> 36 + {/each} 32 37 </div> 33 38 </Story>
+53 -6
web/src/lib/components/ui/Avatar.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + // the sizes the design system names, from Figma's User component (node 723:254). 5 + // Size=Header is not a rung here: it draws the same 26px circle as Size=Large 6 + // and differs only in the text style beside it. 7 + export const avatar = tv({ 8 + base: "shrink-0 rounded-full border border-border-default", 9 + variants: { 10 + size: { 11 + mini: "size-3.25", 12 + small: "size-4.25", 13 + regular: "size-5.25", 14 + large: "size-6.5", 15 + // for a size off the scale: `full` fills the parent, and a size class 16 + // passed by the caller replaces it, since tv() merges the two rather 17 + // than leaving both to fight in the class string 18 + full: "size-full" 19 + } 20 + }, 21 + defaultVariants: { 22 + size: "regular" 23 + } 24 + }); 25 + 26 + export type AvatarSize = NonNullable<VariantProps<typeof avatar>["size"]>; 27 + 28 + // what each rung above renders at, in css pixels — the spacing unit is 4px, so 29 + // these are the class numbers times four. the avatar is fetched at twice this so 30 + // that it stays sharp at 2x. `full` has no size of its own, the layout decides 31 + // it, so there is nothing to fetch at. 32 + const sizePx = { 33 + mini: 13, 34 + small: 17, 35 + regular: 21, 36 + large: 26, 37 + full: undefined 38 + } satisfies Record<AvatarSize, number | undefined>; 39 + 40 + export const avatarSizeNames = Object.keys(sizePx) as AvatarSize[]; 41 + </script> 42 + 1 43 <script lang="ts"> 2 44 import UserRound from "$icon/user-round"; 3 45 import { avatarUrl } from "$lib/avatar"; ··· 6 48 did?: string; 7 49 src?: string; 8 50 handle?: string; 9 - tiny?: boolean; 10 - size?: string; 51 + size?: AvatarSize; 11 52 class?: string; 12 53 } 13 54 14 - let { did, src, handle, tiny = false, size = "size-10", class: className = "" }: Props = $props(); 55 + let { did, src, handle, size = "regular", class: className = "" }: Props = $props(); 15 56 16 - const source = $derived(src ?? (did ? avatarUrl(did, tiny) : undefined)); 57 + const px = $derived(sizePx[size]); 58 + const source = $derived( 59 + src ?? (did ? avatarUrl(did, px === undefined ? undefined : px * 2) : undefined) 60 + ); 17 61 18 62 // an unconfigured avatar service 404s, same as a broken image 19 63 let failed = $state(false); ··· 27 71 <img 28 72 src={source} 29 73 alt={handle ? `${handle}'s avatar` : "avatar"} 30 - class={`${size} shrink-0 rounded-full border border-border-default object-cover ${className}`} 74 + class={avatar({ size, class: `object-cover ${className}` })} 31 75 onerror={() => (failed = true)} 32 76 /> 33 77 {:else} 34 78 <span 35 - class={`${size} flex shrink-0 items-center justify-center rounded-full border border-border-default bg-background-inset text-foreground-default ${className}`} 79 + class={avatar({ 80 + size, 81 + class: `flex items-center justify-center bg-background-inset text-foreground-default ${className}` 82 + })} 36 83 role={handle ? "img" : undefined} 37 84 aria-label={handle ? `${handle}'s avatar` : undefined} 38 85 aria-hidden={handle ? undefined : "true"}
+13 -8
web/src/lib/components/ui/User.svelte
··· 1 1 <script module lang="ts"> 2 2 import { tv, type VariantProps } from "tailwind-variants"; 3 + import type { AvatarSize } from "./Avatar.svelte"; 3 4 4 5 export const user = tv({ 5 6 slots: { 6 7 root: "inline-flex items-center", 7 - avatar: "", 8 8 handle: "truncate" 9 9 }, 10 10 variants: { 11 11 size: { 12 12 header: { 13 13 root: "gap-2", 14 - avatar: "size-6.5", 15 14 handle: "typography-heading-4" 16 15 }, 17 16 large: { 18 17 root: "gap-1.5", 19 - avatar: "size-6.5", 20 18 handle: "typography-paragraph-large" 21 19 }, 22 20 regular: { 23 21 root: "gap-1", 24 - avatar: "size-5.25", 25 22 handle: "typography-paragraph-regular" 26 23 }, 27 24 small: { 28 25 root: "gap-1", 29 - avatar: "size-4.25", 30 26 handle: "typography-paragraph-small" 31 27 }, 32 28 mini: { 33 29 root: "gap-1", 34 - avatar: "size-3.25", 35 30 handle: "typography-paragraph-mini" 36 31 } 37 32 } ··· 42 37 }); 43 38 44 39 export type UserVariants = VariantProps<typeof user>; 40 + 41 + // the avatar names its own size rather than taking a class through a slot, so 42 + // that it can pick the image width it needs. header and large share a rung 43 + // because in Figma they differ only in the text style beside the circle. 44 + const avatarSize = { 45 + header: "large", 46 + large: "large", 47 + regular: "regular", 48 + small: "small", 49 + mini: "mini" 50 + } satisfies Record<NonNullable<UserVariants["size"]>, AvatarSize>; 45 51 </script> 46 52 47 53 <script lang="ts"> ··· 74 80 75 81 <span class={slots.root({ class: className })}> 76 82 {#if showImage} 77 - <!-- every variant here is size-8 or under, so the small image always fits --> 78 - <Avatar {did} {handle} size={slots.avatar({ class: avatarClass })} tiny /> 83 + <Avatar {did} {handle} size={avatarSize[size]} class={avatarClass} /> 79 84 {/if} 80 85 {#if showText && handle} 81 86 <span class={slots.handle({ class: handleClass })}>{handle}</span>
+1 -1
web/src/routes/[handle]/[repo]/settings/access/+page.svelte
··· 51 51 <SettingsRow stack> 52 52 {#snippet label()} 53 53 <span class="flex min-w-0 items-center gap-2"> 54 - <Avatar did={person.did} handle={person.handle} size="size-5" /> 54 + <Avatar did={person.did} handle={person.handle} size="regular" /> 55 55 <span class="typography-paragraph-regular text-foreground-default"> 56 56 {person.handle} 57 57 </span>
+1 -1
web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte
··· 79 79 class="flex items-center gap-1.5 typography-paragraph-regular text-foreground-muted" 80 80 > 81 81 {hook.added} 82 - <Avatar handle={hook.by} size="size-4" /> 82 + <Avatar handle={hook.by} size="small" /> 83 83 {hook.by} 84 84 </span> 85 85 </div>
+1 -1
web/src/routes/[handle]/[repo]/settings/pipelines/+page.svelte
··· 85 85 class="flex items-center gap-1.5 typography-paragraph-regular text-foreground-muted" 86 86 > 87 87 {secret.added} 88 - <Avatar handle={secret.by} size="size-4" /> 88 + <Avatar handle={secret.by} size="small" /> 89 89 {secret.by} 90 90 </span> 91 91 </span>
+1 -1
web/src/routes/repo/new/+page.svelte
··· 54 54 <div 55 55 class="hidden shrink-0 items-center gap-1 p-2 typography-paragraph-small text-foreground-muted md:flex md:rounded-l md:border md:border-r-0 md:border-border-default md:bg-background-inset" 56 56 > 57 - <Avatar did={user?.did} handle={user?.handle} size="size-5" tiny /> 57 + <Avatar did={user?.did} handle={user?.handle} size="regular" /> 58 58 <span>{user?.handle ?? "…"}</span> 59 59 </div> 60 60 <input