Monorepo for Tangled
0

Configure Feed

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

web/components/ui: add ui primitives and stories

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

eti (Jul 14, 2026, 2:38 AM +0200) d0e7c31e f6761b38

+1919 -83
+17 -7
web/src/lib/components/ui/Avatar.svelte
··· 1 1 <script lang="ts"> 2 - import UserRound from '$icon/user-round'; 2 + import UserRound from "$icon/user-round"; 3 3 4 4 interface Props { 5 5 src?: string; ··· 8 8 class?: string; 9 9 } 10 10 11 - let { src, handle, size = 'size-10', class: className = '' }: Props = $props(); 11 + let { src, handle, size = "size-10", class: className = "" }: Props = $props(); 12 + 13 + let failed = $state(false); 14 + 15 + $effect(() => { 16 + src; 17 + failed = false; 18 + }); 12 19 </script> 13 20 14 - {#if src} 21 + {#if src && !failed} 15 22 <img 16 23 {src} 17 - alt={handle ? `${handle}'s avatar` : 'avatar'} 18 - class={`${size} shrink-0 rounded-full border border-border object-cover ${className}`} 24 + alt={handle ? `${handle}'s avatar` : "avatar"} 25 + class={`${size} shrink-0 rounded-full border border-border-default object-cover ${className}`} 26 + onerror={() => (failed = true)} 19 27 /> 20 28 {:else} 21 29 <span 22 - class={`${size} flex shrink-0 items-center justify-center rounded-full border border-border bg-surface-muted text-fg-subtle ${className}`} 23 - aria-hidden="true" 30 + class={`${size} flex shrink-0 items-center justify-center rounded-full border border-border-default bg-background-inset text-foreground-subtle ${className}`} 31 + role={handle ? "img" : undefined} 32 + aria-label={handle ? `${handle}'s avatar` : undefined} 33 + aria-hidden={handle ? undefined : "true"} 24 34 > 25 35 <UserRound class="size-1/2" /> 26 36 </span>
+47
web/src/lib/components/ui/Button.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Button from "./Button.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/Button", 7 + component: Button, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + variant: { 11 + control: { type: "select" }, 12 + options: ["default", "primary", "ghost", "flat", "danger", "success", "warning"] 13 + }, 14 + size: { 15 + control: { type: "inline-radio" }, 16 + options: ["sm", "md", "lg"] 17 + }, 18 + disabled: { control: "boolean" }, 19 + loading: { control: "boolean" } 20 + }, 21 + args: { 22 + variant: "default", 23 + size: "md", 24 + disabled: false, 25 + loading: false 26 + } 27 + }); 28 + </script> 29 + 30 + <!-- Button content comes through its `children` snippet, provided here as Story slot content. --> 31 + <Story name="Default">Button</Story> 32 + <Story name="Primary" args={{ variant: "primary" }}>Save</Story> 33 + <Story name="Ghost" args={{ variant: "ghost" }}>Cancel</Story> 34 + <Story name="Danger" args={{ variant: "danger" }}>Delete</Story> 35 + <Story name="Success" args={{ variant: "success" }}>Confirm</Story> 36 + <Story name="Warning" args={{ variant: "warning" }}>Careful</Story> 37 + <Story name="Loading" args={{ variant: "primary", loading: true }}>Saving</Story> 38 + <Story name="Disabled" args={{ disabled: true }}>Button</Story> 39 + <Story name="Link" args={{ href: "/" }}>Go home</Story> 40 + 41 + <Story name="Sizes"> 42 + <div class="flex items-center gap-3"> 43 + <Button size="sm">Small</Button> 44 + <Button size="md">Medium</Button> 45 + <Button size="lg">Large</Button> 46 + </div> 47 + </Story>
+37 -31
web/src/lib/components/ui/Button.svelte
··· 1 1 <script module lang="ts"> 2 - import { tv, type VariantProps } from 'tailwind-variants'; 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 3 4 4 export const button = tv({ 5 - base: 'inline-flex cursor-pointer select-none items-center justify-center overflow-hidden rounded no-underline transition-[background-color,border-color,color] duration-150 ease-[cubic-bezier(0.4,0,0.2,1)] hover:no-underline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50', 5 + base: "inline-flex cursor-pointer select-none items-center justify-center overflow-hidden rounded no-underline transition-[background-color,border-color,color] duration-150 ease-[cubic-bezier(0.4,0,0.2,1)] hover:no-underline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50", 6 6 variants: { 7 7 variant: { 8 8 default: 9 - 'border border-border bg-surface text-fg hover:bg-surface-hover focus-visible:outline-ring', 9 + "border border-border-default bg-background-default text-foreground-default hover:bg-background-subtle focus-visible:outline-border-focus", 10 10 primary: 11 - 'border border-primary-border bg-primary text-primary-fg hover:bg-primary-hover focus-visible:outline-primary', 12 - ghost: 'bg-transparent text-fg-muted hover:bg-surface-muted focus-visible:outline-ring', 13 - flat: 'bg-gray-50 text-fg hover:bg-gray-100 focus-visible:outline-ring dark:bg-gray-900 dark:hover:bg-gray-700', 11 + "border border-border-success bg-background-success-emphasis text-foreground-on-emphasis hover:bg-background-success-emphasis-hover focus-visible:outline-background-success-emphasis", 12 + ghost: 13 + "bg-transparent text-foreground-muted hover:bg-background-inset focus-visible:outline-border-focus", 14 + flat: "bg-gray-50 text-foreground-default hover:bg-gray-100 focus-visible:outline-border-focus dark:bg-gray-900 dark:hover:bg-gray-700", 15 + // danger / success / warning are outline buttons in Figma: default surface + 16 + // border, colored text only (bound to the fg-* tokens), no accent fill. 14 17 danger: 15 - 'border border-danger-border bg-danger text-danger-fg hover:bg-danger-hover focus-visible:outline-danger', 18 + "border border-border-default bg-background-default text-foreground-danger hover:bg-background-subtle focus-visible:outline-foreground-danger", 16 19 success: 17 - 'border border-success-border bg-success text-success-fg hover:bg-success-hover focus-visible:outline-success' 20 + "border border-border-default bg-background-default text-foreground-success hover:bg-background-subtle focus-visible:outline-foreground-success", 21 + warning: 22 + "border border-border-default bg-background-default text-foreground-warning hover:bg-background-subtle focus-visible:outline-foreground-warning" 18 23 }, 19 24 size: { 20 - sm: 'min-h-8 gap-1.5 px-2 py-1.5 text-sm', 21 - md: 'min-h-9 gap-2 px-4 py-2 text-sm', 22 - lg: 'min-h-10 gap-2.5 px-6 py-2 text-base' 25 + sm: "min-h-8 gap-1.5 px-2 py-1.5 text-sm", 26 + md: "min-h-9 gap-2 px-3 py-1.5 text-sm", 27 + lg: "min-h-10 gap-2.5 px-6 py-2 text-base" 23 28 } 24 29 }, 25 30 compoundVariants: [ 26 31 // raised variants draw inset depth via a ::before layer. 27 32 { 28 - variant: 'default', 33 + variant: "default", 29 34 class: 30 - "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:content-[''] before:shadow-[inset_0_-2px_0_0_var(--color-raised-depth)] before:transition-all before:duration-150 hover:before:shadow-[inset_0_-2px_0_0_var(--color-raised-depth-hover)] active:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_var(--color-raised-depth)] disabled:active:translate-y-0" 35 + "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:content-[''] before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-subtle)] before:transition-all before:duration-150 hover:before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-strong-hover)] active:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_var(--color-shadow-inner-subtle)] disabled:active:translate-y-0" 31 36 }, 32 37 { 33 - variant: ['primary', 'danger', 'success'], 38 + variant: "primary", 34 39 class: 35 - "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:content-[''] before:shadow-[inset_0_-2px_0_0_var(--color-raised-accent-depth)] before:transition-all before:duration-150 active:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_var(--color-raised-accent-depth-active)] disabled:active:translate-y-0" 40 + "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:content-[''] before:shadow-[inset_0_-2px_0_0_rgb(3_7_18/0.25)] before:transition-all before:duration-150 active:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_rgb(3_7_18/0.5)] disabled:active:translate-y-0" 36 41 } 37 42 ], 38 43 defaultVariants: { 39 - variant: 'default', 40 - size: 'md' 44 + variant: "default", 45 + size: "md" 41 46 } 42 47 }); 43 48 ··· 45 50 </script> 46 51 47 52 <script lang="ts"> 48 - import { resolve } from '$app/paths'; 49 - import type { Snippet } from 'svelte'; 50 - import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'; 51 - import Spinner from './Spinner.svelte'; 53 + import { resolve } from "$app/paths"; 54 + import type { Snippet } from "svelte"; 55 + import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements"; 56 + import Spinner from "./Spinner.svelte"; 52 57 53 - interface Props extends Omit<HTMLAnchorAttributes & HTMLButtonAttributes, 'class' | 'children'> { 54 - variant?: ButtonVariants['variant']; 55 - size?: ButtonVariants['size']; 58 + interface Props extends Omit<HTMLAnchorAttributes & HTMLButtonAttributes, "class" | "children"> { 59 + variant?: ButtonVariants["variant"]; 60 + size?: ButtonVariants["size"]; 56 61 href?: string; 57 - type?: 'button' | 'submit' | 'reset'; 62 + type?: "button" | "submit" | "reset"; 58 63 disabled?: boolean; 59 64 class?: string; 60 65 loading?: boolean; ··· 63 68 } 64 69 65 70 let { 66 - variant = 'default', 67 - size = 'md', 71 + variant = "default", 72 + size = "md", 68 73 href, 69 - type = 'button', 74 + type = "button", 70 75 disabled = false, 71 76 class: className, 72 77 loading = false, ··· 76 81 }: Props = $props(); 77 82 78 83 const classes = $derived(button({ variant, size, class: className })); 79 - const spinnerSize = $derived(spinnerClass ?? (size === 'lg' ? 'size-5' : 'size-4')); 84 + const spinnerSize = $derived(spinnerClass ?? (size === "lg" ? "size-5" : "size-4")); 80 85 </script> 81 86 82 87 {#if href} 88 + <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- internal hrefs go through resolve(), external ones must pass through untouched --> 83 89 <a 84 - href={resolve(href as '/')} 90 + href={href?.startsWith("/") ? resolve(href as "/") : href} 85 91 class={classes} 86 - aria-disabled={disabled || loading ? 'true' : undefined} 92 + aria-disabled={disabled || loading ? "true" : undefined} 87 93 tabindex={disabled ? -1 : undefined} 88 94 {...rest} 89 95 >
+39
web/src/lib/components/ui/ButtonCard.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Settings from "$icon/settings"; 4 + import ButtonCard from "./ButtonCard.svelte"; 5 + 6 + const { Story } = defineMeta({ 7 + title: "UI/ButtonCard", 8 + component: ButtonCard, 9 + tags: ["autodocs"], 10 + argTypes: { 11 + variant: { 12 + control: { type: "select" }, 13 + options: ["default", "ghost"] 14 + }, 15 + disabled: { control: "boolean" }, 16 + title: { control: "text" }, 17 + description: { control: "text" } 18 + }, 19 + args: { 20 + variant: "default", 21 + disabled: false, 22 + title: "Button", 23 + description: "Description" 24 + } 25 + }); 26 + </script> 27 + 28 + <Story name="Default" /> 29 + <Story name="Ghost" args={{ variant: "ghost" }} /> 30 + <Story name="WithIcon" args={{ icon: Settings }} /> 31 + <Story name="Disabled" args={{ disabled: true }} /> 32 + <Story name="Href" args={{ href: "/", icon: Settings }} /> 33 + 34 + <Story name="Variants"> 35 + <div class="grid grid-cols-2 gap-3"> 36 + <ButtonCard variant="default" icon={Settings} title="Default" description="Bordered card" /> 37 + <ButtonCard variant="ghost" icon={Settings} title="Ghost" description="Transparent card" /> 38 + </div> 39 + </Story>
+101
web/src/lib/components/ui/ButtonCard.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const buttonCard = tv({ 5 + base: "flex cursor-pointer select-none items-start gap-3 rounded p-3 text-left no-underline transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)] hover:no-underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50", 6 + variants: { 7 + variant: { 8 + default: "border border-border-default bg-background-default hover:bg-background-subtle", 9 + ghost: "bg-transparent hover:bg-background-inset" 10 + } 11 + }, 12 + defaultVariants: { 13 + variant: "default" 14 + } 15 + }); 16 + 17 + export type ButtonCardVariants = VariantProps<typeof buttonCard>; 18 + </script> 19 + 20 + <script lang="ts"> 21 + import { resolve } from "$app/paths"; 22 + import type { Component, Snippet } from "svelte"; 23 + import type { 24 + HTMLAnchorAttributes, 25 + HTMLButtonAttributes, 26 + SvelteHTMLElements 27 + } from "svelte/elements"; 28 + 29 + interface Props extends Omit<HTMLAnchorAttributes & HTMLButtonAttributes, "class" | "children"> { 30 + variant?: ButtonCardVariants["variant"]; 31 + href?: string; 32 + type?: "button" | "submit" | "reset"; 33 + disabled?: boolean; 34 + title?: string; 35 + description?: string; 36 + icon?: Component<SvelteHTMLElements["svg"]>; 37 + class?: string; 38 + children?: Snippet; 39 + } 40 + 41 + let { 42 + variant = "default", 43 + href, 44 + type = "button", 45 + disabled = false, 46 + title, 47 + description, 48 + icon, 49 + class: className, 50 + children, 51 + ...rest 52 + }: Props = $props(); 53 + 54 + const classes = $derived(buttonCard({ variant, class: className })); 55 + const Icon = $derived(icon); 56 + </script> 57 + 58 + {#if href} 59 + <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- internal hrefs go through resolve(), external ones must pass through untouched --> 60 + <a 61 + href={href?.startsWith("/") ? resolve(href as "/") : href} 62 + class={classes} 63 + aria-disabled={disabled ? "true" : undefined} 64 + tabindex={disabled ? -1 : undefined} 65 + {...rest} 66 + > 67 + {#if Icon} 68 + <Icon class="size-5 shrink-0" aria-hidden="true" /> 69 + {/if} 70 + {#if children} 71 + {@render children()} 72 + {:else} 73 + <div class="flex flex-col gap-0.5"> 74 + {#if title} 75 + <span class="text-sm font-medium text-foreground-default">{title}</span> 76 + {/if} 77 + {#if description} 78 + <span class="text-xs text-foreground-muted">{description}</span> 79 + {/if} 80 + </div> 81 + {/if} 82 + </a> 83 + {:else} 84 + <button {type} {disabled} class={classes} {...rest}> 85 + {#if Icon} 86 + <Icon class="size-5 shrink-0" aria-hidden="true" /> 87 + {/if} 88 + {#if children} 89 + {@render children()} 90 + {:else} 91 + <div class="flex flex-col gap-0.5"> 92 + {#if title} 93 + <span class="text-sm font-medium text-foreground-default">{title}</span> 94 + {/if} 95 + {#if description} 96 + <span class="text-xs text-foreground-muted">{description}</span> 97 + {/if} 98 + </div> 99 + {/if} 100 + </button> 101 + {/if}
+49
web/src/lib/components/ui/ButtonGroup.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Button from "./Button.svelte"; 4 + import ButtonGroup from "./ButtonGroup.svelte"; 5 + 6 + const { Story } = defineMeta({ 7 + title: "UI/ButtonGroup", 8 + component: ButtonGroup, 9 + tags: ["autodocs"], 10 + argTypes: { 11 + spaced: { control: "boolean" } 12 + }, 13 + args: { 14 + spaced: false 15 + } 16 + }); 17 + </script> 18 + 19 + <!-- ButtonGroup content comes through its `children` snippet, provided here as Story slot content. --> 20 + <Story name="Joined"> 21 + <ButtonGroup> 22 + <Button>Button</Button> 23 + <Button>Button</Button> 24 + <Button>Button</Button> 25 + </ButtonGroup> 26 + </Story> 27 + 28 + <Story name="Spaced" args={{ spaced: true }}> 29 + <ButtonGroup spaced> 30 + <Button>Button</Button> 31 + <Button>Button</Button> 32 + <Button>Button</Button> 33 + </ButtonGroup> 34 + </Story> 35 + 36 + <Story name="Comparison"> 37 + <div class="flex flex-col gap-4"> 38 + <ButtonGroup spaced> 39 + <Button>Button</Button> 40 + <Button>Button</Button> 41 + <Button>Button</Button> 42 + </ButtonGroup> 43 + <ButtonGroup> 44 + <Button>Button</Button> 45 + <Button>Button</Button> 46 + <Button>Button</Button> 47 + </ButtonGroup> 48 + </div> 49 + </Story>
+37
web/src/lib/components/ui/ButtonGroup.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const buttonGroup = tv({ 5 + base: "inline-flex items-center", 6 + variants: { 7 + spaced: { 8 + true: "gap-2", 9 + false: 10 + "gap-0 [&>*]:relative [&>*:not(:first-child)]:-ml-px [&>*:not(:first-child)]:rounded-l-none [&>*:not(:last-child)]:rounded-r-none [&>*:hover]:z-10 [&>*:focus-visible]:z-10" 11 + } 12 + }, 13 + defaultVariants: { 14 + spaced: false 15 + } 16 + }); 17 + 18 + export type ButtonGroupVariants = VariantProps<typeof buttonGroup>; 19 + </script> 20 + 21 + <script lang="ts"> 22 + import type { Snippet } from "svelte"; 23 + 24 + interface Props { 25 + spaced?: ButtonGroupVariants["spaced"]; 26 + class?: string; 27 + children: Snippet; 28 + } 29 + 30 + let { spaced = false, class: className, children }: Props = $props(); 31 + 32 + const classes = $derived(buttonGroup({ spaced, class: className })); 33 + </script> 34 + 35 + <div class={classes}> 36 + {@render children()} 37 + </div>
+37
web/src/lib/components/ui/Checkbox.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Checkbox from "./Checkbox.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/Checkbox", 7 + component: Checkbox, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + checked: { control: "boolean" }, 11 + indeterminate: { control: "boolean" }, 12 + disabled: { control: "boolean" } 13 + }, 14 + args: { 15 + checked: false, 16 + indeterminate: false, 17 + disabled: false 18 + } 19 + }); 20 + </script> 21 + 22 + <!-- Checkbox content comes through its `children` snippet, provided here as Story slot content. --> 23 + <Story name="Default">Item</Story> 24 + <Story name="Checked" args={{ checked: true }}>Item</Story> 25 + <Story name="Indeterminate" args={{ indeterminate: true }}>Item</Story> 26 + <Story name="Disabled" args={{ disabled: true }}>Item</Story> 27 + <Story name="Disabled checked" args={{ disabled: true, checked: true }}>Item</Story> 28 + 29 + <Story name="States"> 30 + <div class="flex flex-col gap-3"> 31 + <Checkbox>Unchecked</Checkbox> 32 + <Checkbox checked>Checked</Checkbox> 33 + <Checkbox indeterminate>Indeterminate</Checkbox> 34 + <Checkbox disabled>Disabled</Checkbox> 35 + <Checkbox disabled checked>Disabled checked</Checkbox> 36 + </div> 37 + </Story>
+51
web/src/lib/components/ui/Checkbox.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + import type { HTMLInputAttributes } from "svelte/elements"; 4 + import Check from "$icon/check"; 5 + import Minus from "$icon/minus"; 6 + 7 + interface Props extends Omit<HTMLInputAttributes, "type" | "class" | "checked" | "children"> { 8 + checked?: boolean; 9 + indeterminate?: boolean; 10 + disabled?: boolean; 11 + class?: string; 12 + children?: Snippet; 13 + } 14 + 15 + let { 16 + checked = $bindable(false), 17 + indeterminate = $bindable(false), 18 + disabled = false, 19 + class: className, 20 + children, 21 + ...rest 22 + }: Props = $props(); 23 + 24 + const labelClasses = $derived( 25 + `inline-flex items-center gap-2 cursor-pointer ${disabled ? "cursor-not-allowed text-foreground-disabled" : ""} ${className ?? ""}` 26 + ); 27 + </script> 28 + 29 + <label class={labelClasses}> 30 + <span class="relative inline-flex size-4 shrink-0"> 31 + <input 32 + type="checkbox" 33 + bind:checked 34 + bind:indeterminate 35 + {disabled} 36 + class="peer size-4 shrink-0 appearance-none rounded border border-border-strong bg-background-default transition-colors duration-150 hover:bg-background-subtle checked:border-foreground-default checked:bg-foreground-default indeterminate:border-foreground-default indeterminate:bg-foreground-default focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground-default disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50" 37 + {...rest} 38 + /> 39 + <Check 40 + class="pointer-events-none absolute inset-0 m-auto hidden size-3.5 text-background-default peer-checked:block peer-indeterminate:hidden peer-disabled:opacity-50" 41 + aria-hidden="true" 42 + /> 43 + <Minus 44 + class="pointer-events-none absolute inset-0 m-auto hidden size-3.5 text-background-default peer-indeterminate:block peer-disabled:opacity-50" 45 + aria-hidden="true" 46 + /> 47 + </span> 48 + {#if children} 49 + <span>{@render children()}</span> 50 + {/if} 51 + </label>
+118 -30
web/src/lib/components/ui/Dropdown.svelte
··· 1 1 <script module lang="ts"> 2 - import { SvelteMap, SvelteSet } from 'svelte/reactivity'; 3 - import { tv, type VariantProps } from 'tailwind-variants'; 2 + import { SvelteMap, SvelteSet } from "svelte/reactivity"; 3 + import { tv, type VariantProps } from "tailwind-variants"; 4 4 5 5 export const dropdown = tv({ 6 6 slots: { 7 - root: 'relative inline-block', 7 + root: "relative inline-block", 8 8 plainTrigger: 9 - 'flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-inherit', 10 - menu: 'absolute right-0 z-50 mt-2 w-56 overflow-hidden rounded border border-border bg-surface text-sm shadow-menu' 9 + "flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-inherit", 10 + menu: "absolute right-0 z-50 mt-2 w-56 overflow-hidden rounded border border-border-default bg-background-default text-sm shadow-menu" 11 11 }, 12 12 variants: { 13 13 open: { 14 14 true: { 15 - menu: 'block' 15 + menu: "block" 16 16 }, 17 17 false: { 18 - menu: 'hidden' 18 + menu: "hidden" 19 19 } 20 20 } 21 21 }, ··· 29 29 // opening one dropdown in a group closes its siblings. 30 30 const groups = new SvelteMap<string, SvelteSet<() => void>>(); 31 31 32 + let idCounter = 0; 33 + function nextId() { 34 + idCounter += 1; 35 + return `dropdown-menu-${idCounter}`; 36 + } 37 + 32 38 function registerDropdown(group: string | undefined, close: () => void) { 33 39 if (!group) return () => {}; 34 40 let set = groups.get(group); ··· 39 45 set.add(close); 40 46 return () => { 41 47 set.delete(close); 48 + if (set && set.size === 0) groups.delete(group); 42 49 }; 43 50 } 44 51 ··· 51 58 </script> 52 59 53 60 <script lang="ts"> 54 - import type { Snippet } from 'svelte'; 55 - import { onMount, setContext } from 'svelte'; 56 - import Button from './Button.svelte'; 61 + import type { Snippet } from "svelte"; 62 + import { onMount, setContext, tick } from "svelte"; 63 + import Button from "./Button.svelte"; 57 64 58 65 interface Props { 59 66 id?: string; 60 67 group?: string; 61 - variant?: 'plain' | 'button'; 68 + variant?: "plain" | "button"; 62 69 label?: string; 63 70 trigger: Snippet; 64 71 children: Snippet; 65 72 } 66 73 67 - let { id, group, variant = 'plain', label, trigger, children }: Props = $props(); 74 + let { id, group, variant = "plain", label, trigger, children }: Props = $props(); 68 75 69 76 let open = $state(false); 70 77 const classes = $derived(dropdown({ open })); 71 78 let root = $state<HTMLElement>(); 79 + let triggerWrapper = $state<HTMLElement>(); 80 + let triggerButtonEl = $state<HTMLElement>(); 81 + const fallbackId = nextId(); 82 + const menuId = $derived(id ?? fallbackId); 83 + 84 + const focusTrigger = () => { 85 + if (variant === "button") { 86 + triggerWrapper?.querySelector<HTMLElement>("button, a")?.focus(); 87 + } else { 88 + triggerButtonEl?.focus(); 89 + } 90 + }; 91 + let items: HTMLElement[] = $state([]); 72 92 73 93 const close = () => { 74 94 open = false; ··· 79 99 if (open) closeGroupExcept(group, close); 80 100 }; 81 101 82 - setContext('dropdown-close', close); 102 + const focusItem = (index: number) => { 103 + if (items.length === 0) return; 104 + const wrapped = ((index % items.length) + items.length) % items.length; 105 + items[wrapped]?.focus(); 106 + }; 107 + 108 + const openAndFocusFirst = () => { 109 + open = true; 110 + closeGroupExcept(group, close); 111 + }; 112 + 113 + const onTriggerKeydown = (event: KeyboardEvent) => { 114 + if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { 115 + event.preventDefault(); 116 + openAndFocusFirst(); 117 + } 118 + }; 119 + 120 + const onMenuKeydown = (event: KeyboardEvent) => { 121 + const currentIndex = items.indexOf(event.target as HTMLElement); 122 + switch (event.key) { 123 + case "ArrowDown": 124 + event.preventDefault(); 125 + focusItem(currentIndex + 1); 126 + break; 127 + case "ArrowUp": 128 + event.preventDefault(); 129 + focusItem(currentIndex - 1); 130 + break; 131 + case "Home": 132 + event.preventDefault(); 133 + focusItem(0); 134 + break; 135 + case "End": 136 + event.preventDefault(); 137 + focusItem(items.length - 1); 138 + break; 139 + case "Escape": 140 + event.preventDefault(); 141 + close(); 142 + focusTrigger(); 143 + break; 144 + } 145 + }; 146 + 147 + $effect(() => { 148 + if (open) { 149 + tick().then(() => items[0]?.focus()); 150 + } 151 + }); 152 + 153 + setContext("dropdown-close", close); 154 + setContext("dropdown-register", (el: HTMLElement) => { 155 + items.push(el); 156 + return () => { 157 + items = items.filter((item) => item !== el); 158 + }; 159 + }); 83 160 84 161 onMount(() => { 85 162 const unregister = registerDropdown(group, close); ··· 88 165 if (open && event.target instanceof Node && root && !root.contains(event.target)) close(); 89 166 }; 90 167 const onKeydown = (event: KeyboardEvent) => { 91 - if (event.key === 'Escape') close(); 168 + if (!open) return; 169 + if (event.key === "Escape") { 170 + close(); 171 + focusTrigger(); 172 + } 92 173 }; 93 174 94 - document.addEventListener('click', onDocumentClick); 95 - document.addEventListener('keydown', onKeydown); 175 + document.addEventListener("click", onDocumentClick); 176 + document.addEventListener("keydown", onKeydown); 96 177 97 178 return () => { 98 179 unregister(); 99 - document.removeEventListener('click', onDocumentClick); 100 - document.removeEventListener('keydown', onKeydown); 180 + document.removeEventListener("click", onDocumentClick); 181 + document.removeEventListener("keydown", onKeydown); 101 182 }; 102 183 }); 103 184 </script> 104 185 105 186 <div bind:this={root} class={classes.root()}> 106 - {#if variant === 'button'} 107 - <Button 108 - size="sm" 109 - class="gap-2 !py-0 text-sm" 110 - onclick={toggle} 111 - aria-haspopup="menu" 112 - aria-expanded={open} 113 - aria-label={label} 114 - > 115 - {@render trigger()} 116 - </Button> 187 + {#if variant === "button"} 188 + <span bind:this={triggerWrapper} class="contents"> 189 + <Button 190 + size="sm" 191 + class="px-3" 192 + onclick={toggle} 193 + onkeydown={onTriggerKeydown} 194 + aria-haspopup="menu" 195 + aria-expanded={open} 196 + aria-controls={menuId} 197 + aria-label={label} 198 + > 199 + {@render trigger()} 200 + </Button> 201 + </span> 117 202 {:else} 118 203 <button 204 + bind:this={triggerButtonEl} 119 205 type="button" 120 206 class={classes.plainTrigger()} 121 207 onclick={toggle} 208 + onkeydown={onTriggerKeydown} 122 209 aria-haspopup="menu" 123 210 aria-expanded={open} 211 + aria-controls={menuId} 124 212 aria-label={label} 125 213 > 126 214 {@render trigger()} 127 215 </button> 128 216 {/if} 129 217 130 - <div {id} class={classes.menu()} role="menu"> 218 + <div id={menuId} class={classes.menu()} role="menu" tabindex={-1} onkeydown={onMenuKeydown}> 131 219 {@render children()} 132 220 </div> 133 221 </div>
+37 -13
web/src/lib/components/ui/DropdownItem.svelte
··· 1 1 <script module lang="ts"> 2 - import { tv, type VariantProps } from 'tailwind-variants'; 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 3 4 4 export const dropdownItem = tv({ 5 - base: 'flex w-full cursor-pointer items-center gap-2 border-0 bg-transparent px-[0.7rem] py-[0.55rem] text-left text-inherit no-underline transition-colors duration-150 hover:no-underline', 5 + base: "flex w-full cursor-pointer items-center gap-2 border-0 bg-transparent px-[0.7rem] py-[0.55rem] text-left text-inherit no-underline transition-colors duration-150 hover:no-underline", 6 6 variants: { 7 7 danger: { 8 - false: 'hover:bg-surface-hover dark:hover:bg-[rgb(55_65_81_/_0.5)]', 9 - true: 'text-danger hover:bg-danger-soft' 8 + false: "hover:bg-background-subtle dark:hover:bg-[rgb(55_65_81_/_0.5)]", 9 + true: "text-foreground-danger hover:bg-background-danger-subtle" 10 10 } 11 11 }, 12 12 defaultVariants: { ··· 18 18 </script> 19 19 20 20 <script lang="ts"> 21 - import { resolve } from '$app/paths'; 22 - import type { Component, Snippet } from 'svelte'; 23 - import type { SvelteHTMLElements } from 'svelte/elements'; 24 - import { getContext } from 'svelte'; 21 + import { resolve } from "$app/paths"; 22 + import type { Component, Snippet } from "svelte"; 23 + import type { SvelteHTMLElements } from "svelte/elements"; 24 + import { getContext } from "svelte"; 25 25 interface Props { 26 26 href?: string; 27 - icon?: Component<SvelteHTMLElements['svg']>; 28 - danger?: DropdownItemVariants['danger']; 27 + icon?: Component<SvelteHTMLElements["svg"]>; 28 + danger?: DropdownItemVariants["danger"]; 29 29 onclick?: (event: MouseEvent) => void; 30 30 children: Snippet; 31 31 } ··· 33 33 let { href, icon, danger = false, onclick, children }: Props = $props(); 34 34 const classes = $derived(dropdownItem({ danger })); 35 35 36 - const closeDropdown = getContext<(() => void) | undefined>('dropdown-close'); 36 + const closeDropdown = getContext<(() => void) | undefined>("dropdown-close"); 37 + const registerItem = getContext<((el: HTMLElement) => () => void) | undefined>( 38 + "dropdown-register" 39 + ); 40 + 41 + let el = $state<HTMLElement>(); 42 + 43 + $effect(() => { 44 + if (!el || !registerItem) return; 45 + return registerItem(el); 46 + }); 37 47 38 48 const handleClick = (event: MouseEvent) => { 39 49 closeDropdown?.(); ··· 42 52 </script> 43 53 44 54 {#if href} 45 - <a href={resolve(href as '/')} class={classes} onclick={handleClick}> 55 + <a 56 + bind:this={el} 57 + href={resolve(href as "/")} 58 + class={classes} 59 + role="menuitem" 60 + tabindex={-1} 61 + onclick={handleClick} 62 + > 46 63 {#if icon} 47 64 {@const Glyph = icon} 48 65 <Glyph class="size-4" aria-hidden="true" /> ··· 50 67 {@render children()} 51 68 </a> 52 69 {:else} 53 - <button type="button" class={classes} onclick={handleClick}> 70 + <button 71 + bind:this={el} 72 + type="button" 73 + class={classes} 74 + role="menuitem" 75 + tabindex={-1} 76 + onclick={handleClick} 77 + > 54 78 {#if icon} 55 79 {@const Glyph = icon} 56 80 <Glyph class="size-4" aria-hidden="true" />
+76
web/src/lib/components/ui/Field.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Field from "./Field.svelte"; 4 + import Select from "./Select.svelte"; 5 + 6 + const { Story } = defineMeta({ 7 + title: "UI/Field", 8 + component: Field, 9 + tags: ["autodocs"], 10 + argTypes: { 11 + label: { control: "text" }, 12 + subtitle: { control: "text" }, 13 + required: { control: "boolean" } 14 + }, 15 + args: { 16 + label: "Label", 17 + subtitle: "Subtitle", 18 + required: false 19 + } 20 + }); 21 + </script> 22 + 23 + <!-- Field wraps any form control via its `children` snippet. --> 24 + <Story name="Default"> 25 + <Field label="Label" subtitle="Subtitle" for="field-default"> 26 + <input 27 + id="field-default" 28 + type="text" 29 + placeholder="Placeholder" 30 + class="w-full rounded border border-border-default bg-background-default px-3 py-2 text-sm outline-none placeholder:text-foreground-placeholder focus:border-border-strong focus:ring-1 focus:ring-border-strong" 31 + /> 32 + </Field> 33 + </Story> 34 + 35 + <Story name="LabelOnly"> 36 + <Field label="Label" for="field-label-only"> 37 + <input 38 + id="field-label-only" 39 + type="text" 40 + placeholder="Placeholder" 41 + class="w-full rounded border border-border-default bg-background-default px-3 py-2 text-sm outline-none placeholder:text-foreground-placeholder focus:border-border-strong focus:ring-1 focus:ring-border-strong" 42 + /> 43 + </Field> 44 + </Story> 45 + 46 + <Story name="Required"> 47 + <Field label="Label" subtitle="Subtitle" required for="field-required"> 48 + <input 49 + id="field-required" 50 + type="text" 51 + placeholder="Placeholder" 52 + class="w-full rounded border border-border-default bg-background-default px-3 py-2 text-sm outline-none placeholder:text-foreground-placeholder focus:border-border-strong focus:ring-1 focus:ring-border-strong" 53 + /> 54 + </Field> 55 + </Story> 56 + 57 + <Story name="WithSelect"> 58 + <Field label="Label" subtitle="Subtitle" for="field-select"> 59 + <Select id="field-select"> 60 + <option value="" disabled selected>Select</option> 61 + <option value="one">One</option> 62 + <option value="two">Two</option> 63 + </Select> 64 + </Field> 65 + </Story> 66 + 67 + <Story name="WithInput"> 68 + <Field label="Label" subtitle="Subtitle" for="field-input"> 69 + <input 70 + id="field-input" 71 + type="text" 72 + placeholder="Placeholder" 73 + class="w-full rounded border border-border-default bg-background-default px-3 py-2 text-sm outline-none placeholder:text-foreground-placeholder focus:border-border-strong focus:ring-1 focus:ring-border-strong" 74 + /> 75 + </Field> 76 + </Story>
+41
web/src/lib/components/ui/Field.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + import type { HTMLAttributes } from "svelte/elements"; 4 + 5 + interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "class" | "children"> { 6 + label?: string; 7 + subtitle?: string; 8 + for?: string; 9 + required?: boolean; 10 + class?: string; 11 + children: Snippet<[{ id: string }]>; 12 + } 13 + 14 + let { 15 + label, 16 + subtitle, 17 + for: forId, 18 + required = false, 19 + class: className = "", 20 + children, 21 + ...rest 22 + }: Props = $props(); 23 + 24 + const generatedId = $props.id(); 25 + const id = $derived(forId ?? generatedId); 26 + </script> 27 + 28 + <div class={`flex flex-col gap-1.5 ${className}`} {...rest}> 29 + {#if label} 30 + <label for={id} class="text-xs font-medium tracking-wide text-foreground-muted uppercase"> 31 + {label} 32 + {#if required} 33 + <span class="text-foreground-subtle">*</span> 34 + {/if} 35 + </label> 36 + {/if} 37 + {@render children({ id })} 38 + {#if subtitle} 39 + <p class="text-xs text-foreground-subtle">{subtitle}</p> 40 + {/if} 41 + </div>
+45
web/src/lib/components/ui/Input.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Search from "$icon/search"; 4 + import Mail from "$icon/mail"; 5 + import Input from "./Input.svelte"; 6 + 7 + const { Story } = defineMeta({ 8 + title: "UI/Input", 9 + component: Input, 10 + tags: ["autodocs"], 11 + argTypes: { 12 + error: { control: "boolean" }, 13 + disabled: { control: "boolean" }, 14 + loading: { control: "boolean" }, 15 + placeholder: { control: "text" } 16 + }, 17 + args: { 18 + error: false, 19 + disabled: false, 20 + loading: false, 21 + placeholder: "Placeholder" 22 + } 23 + }); 24 + </script> 25 + 26 + <Story name="Default" /> 27 + <Story name="Focus" args={{ autofocus: true }} /> 28 + <Story name="Filled" args={{ value: "Filled value" }} /> 29 + <Story name="Error" args={{ error: true, value: "Invalid value" }} /> 30 + <Story name="Disabled" args={{ disabled: true, value: "Can't edit this" }} /> 31 + <Story name="Loading" args={{ loading: true, value: "Checking..." }} /> 32 + 33 + <Story name="With left icon" args={{ iconLeft: Search, placeholder: "Search" }} /> 34 + <Story name="With right icon" args={{ iconRight: Mail, placeholder: "you@example.com" }} /> 35 + 36 + <Story name="States"> 37 + <div class="flex w-64 flex-col gap-3"> 38 + <Input placeholder="Default" /> 39 + <Input placeholder="Filled" value="Filled value" /> 40 + <Input placeholder="Error" error value="Invalid value" /> 41 + <Input placeholder="Disabled" disabled value="Can't edit this" /> 42 + <Input placeholder="Loading" loading value="Checking..." /> 43 + <Input placeholder="Search" iconLeft={Search} /> 44 + </div> 45 + </Story>
+74
web/src/lib/components/ui/Input.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const inputField = tv({ 5 + base: "flex items-center gap-2 rounded border bg-background-default px-3 py-2 text-sm transition-colors", 6 + variants: { 7 + error: { 8 + false: 9 + "border-border-default focus-within:border-border-strong focus-within:ring-1 focus-within:ring-border-strong", 10 + true: "border-border-danger bg-background-danger-subtle text-foreground-danger focus-within:border-border-focus-danger focus-within:ring-1 focus-within:ring-border-focus-danger" 11 + }, 12 + disabled: { 13 + false: "", 14 + true: "cursor-not-allowed bg-background-inset text-foreground-disabled" 15 + } 16 + }, 17 + defaultVariants: { 18 + error: false, 19 + disabled: false 20 + } 21 + }); 22 + 23 + export type InputFieldVariants = VariantProps<typeof inputField>; 24 + </script> 25 + 26 + <script lang="ts"> 27 + import type { Component } from "svelte"; 28 + import type { HTMLInputAttributes, SvelteHTMLElements } from "svelte/elements"; 29 + import Spinner from "./Spinner.svelte"; 30 + 31 + interface Props extends Omit<HTMLInputAttributes, "class" | "value"> { 32 + value?: string; 33 + error?: boolean; 34 + disabled?: boolean; 35 + loading?: boolean; 36 + iconLeft?: Component<SvelteHTMLElements["svg"]>; 37 + iconRight?: Component<SvelteHTMLElements["svg"]>; 38 + class?: string; 39 + } 40 + 41 + let { 42 + value = $bindable(""), 43 + error = false, 44 + disabled = false, 45 + loading = false, 46 + iconLeft, 47 + iconRight, 48 + class: className, 49 + ...rest 50 + }: Props = $props(); 51 + 52 + const classes = $derived(inputField({ error, disabled, class: className })); 53 + </script> 54 + 55 + <div class={classes}> 56 + {#if iconLeft} 57 + {@const IconLeft = iconLeft} 58 + <IconLeft class="size-4 shrink-0 text-foreground-subtle" aria-hidden="true" /> 59 + {/if} 60 + <input 61 + bind:value 62 + {disabled} 63 + aria-invalid={error} 64 + aria-busy={loading} 65 + class="flex-1 bg-transparent text-sm outline-none placeholder:text-foreground-placeholder disabled:cursor-not-allowed" 66 + {...rest} 67 + /> 68 + {#if loading} 69 + <Spinner class="size-4 shrink-0" /> 70 + {:else if iconRight} 71 + {@const IconRight = iconRight} 72 + <IconRight class="size-4 shrink-0 text-foreground-subtle" aria-hidden="true" /> 73 + {/if} 74 + </div>
+57
web/src/lib/components/ui/Link.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import ArrowRight from "$icon/arrow-right"; 4 + import ExternalLink from "$icon/external-link"; 5 + import Link from "./Link.svelte"; 6 + 7 + const { Story } = defineMeta({ 8 + title: "UI/Link", 9 + component: Link, 10 + tags: ["autodocs"], 11 + argTypes: { 12 + variant: { 13 + control: { type: "select" }, 14 + options: ["default", "success", "warning", "danger"] 15 + }, 16 + size: { 17 + control: { type: "inline-radio" }, 18 + options: ["regular", "small"] 19 + }, 20 + disabled: { control: "boolean" } 21 + }, 22 + args: { 23 + variant: "default", 24 + size: "regular", 25 + disabled: false 26 + } 27 + }); 28 + </script> 29 + 30 + <!-- Link content comes through its `children` snippet, provided here as Story slot content. --> 31 + <Story name="Default">Link</Story> 32 + <Story name="Success" args={{ variant: "success" }}>Confirmed</Story> 33 + <Story name="Warning" args={{ variant: "warning" }}>Careful</Story> 34 + <Story name="Danger" args={{ variant: "danger" }}>Delete</Story> 35 + <Story name="Small" args={{ size: "small" }}>Small link</Story> 36 + <Story name="Disabled" args={{ disabled: true }}>Link</Story> 37 + 38 + <Story name="IconLeft" args={{ iconLeft: ExternalLink }}>Open in new tab</Story> 39 + <Story name="IconRight" args={{ iconRight: ArrowRight }}>Continue</Story> 40 + 41 + <Story name="Href" args={{ href: "/" }}>Go home</Story> 42 + 43 + <Story name="Variants"> 44 + <div class="flex items-center gap-4"> 45 + <Link variant="default">Default</Link> 46 + <Link variant="success">Success</Link> 47 + <Link variant="warning">Warning</Link> 48 + <Link variant="danger">Danger</Link> 49 + </div> 50 + </Story> 51 + 52 + <Story name="Sizes"> 53 + <div class="flex items-center gap-4"> 54 + <Link size="regular">Regular</Link> 55 + <Link size="small">Small</Link> 56 + </div> 57 + </Story>
+109
web/src/lib/components/ui/Link.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const link = tv({ 5 + base: "inline-flex cursor-pointer select-none items-center gap-1 no-underline transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)] hover:underline focus-visible:outline-2 focus-visible:outline-offset-2", 6 + variants: { 7 + variant: { 8 + default: 9 + "text-foreground-link-default hover:text-foreground-link-hover focus-visible:outline-border-focus", 10 + success: 11 + "text-foreground-link-success hover:text-foreground-link-success-hover focus-visible:outline-foreground-link-success", 12 + warning: 13 + "text-foreground-link-warning hover:text-foreground-link-warning-hover focus-visible:outline-foreground-link-warning", 14 + danger: 15 + "text-foreground-link-danger hover:text-foreground-link-danger-hover focus-visible:outline-foreground-link-danger" 16 + }, 17 + size: { 18 + regular: "text-sm", 19 + small: "text-xs" 20 + }, 21 + disabled: { 22 + true: "pointer-events-none opacity-50", 23 + false: "" 24 + } 25 + }, 26 + defaultVariants: { 27 + variant: "default", 28 + size: "regular", 29 + disabled: false 30 + } 31 + }); 32 + 33 + export type LinkVariants = VariantProps<typeof link>; 34 + </script> 35 + 36 + <script lang="ts"> 37 + import { resolve } from "$app/paths"; 38 + import type { Component, Snippet } from "svelte"; 39 + import type { 40 + HTMLAnchorAttributes, 41 + HTMLButtonAttributes, 42 + SvelteHTMLElements 43 + } from "svelte/elements"; 44 + 45 + interface Props extends Omit<HTMLAnchorAttributes & HTMLButtonAttributes, "class" | "children"> { 46 + variant?: LinkVariants["variant"]; 47 + size?: LinkVariants["size"]; 48 + href?: string; 49 + type?: "button" | "submit" | "reset"; 50 + disabled?: boolean; 51 + iconLeft?: Component<SvelteHTMLElements["svg"]>; 52 + iconRight?: Component<SvelteHTMLElements["svg"]>; 53 + class?: string; 54 + children?: Snippet; 55 + } 56 + 57 + let { 58 + variant = "default", 59 + size = "regular", 60 + href, 61 + type = "button", 62 + disabled = false, 63 + iconLeft, 64 + iconRight, 65 + class: className, 66 + children, 67 + ...rest 68 + }: Props = $props(); 69 + 70 + const classes = $derived(link({ variant, size, disabled, class: className })); 71 + const iconSize = $derived(size === "small" ? "size-3" : "size-4"); 72 + </script> 73 + 74 + {#if href} 75 + <!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- internal hrefs go through resolve(), external ones must pass through untouched --> 76 + <a 77 + href={href?.startsWith("/") ? resolve(href as "/") : href} 78 + class={classes} 79 + aria-disabled={disabled ? "true" : undefined} 80 + tabindex={disabled ? -1 : undefined} 81 + {...rest} 82 + > 83 + {#if iconLeft} 84 + {@const IconLeft = iconLeft} 85 + <IconLeft class={iconSize} aria-hidden="true" /> 86 + {/if} 87 + {#if children} 88 + {@render children()} 89 + {/if} 90 + {#if iconRight} 91 + {@const IconRight = iconRight} 92 + <IconRight class={iconSize} aria-hidden="true" /> 93 + {/if} 94 + </a> 95 + {:else} 96 + <button {type} {disabled} class={classes} {...rest}> 97 + {#if iconLeft} 98 + {@const IconLeft = iconLeft} 99 + <IconLeft class={iconSize} aria-hidden="true" /> 100 + {/if} 101 + {#if children} 102 + {@render children()} 103 + {/if} 104 + {#if iconRight} 105 + {@const IconRight = iconRight} 106 + <IconRight class={iconSize} aria-hidden="true" /> 107 + {/if} 108 + </button> 109 + {/if}
+26
web/src/lib/components/ui/Logo.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Logo from "./Logo.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/Logo", 7 + component: Logo, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + wordmark: { control: "boolean" } 11 + }, 12 + args: { 13 + wordmark: false 14 + } 15 + }); 16 + </script> 17 + 18 + <Story name="Icon" /> 19 + <Story name="Wordmark" args={{ wordmark: true }} /> 20 + 21 + <Story name="Both"> 22 + <div class="flex flex-col items-start gap-4"> 23 + <Logo /> 24 + <Logo wordmark /> 25 + </div> 26 + </Story>
+14
web/src/lib/components/ui/Logo.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + wordmark?: boolean; 4 + class?: string; 5 + } 6 + 7 + let { wordmark = false, class: className = "" }: Props = $props(); 8 + </script> 9 + 10 + {#if wordmark} 11 + <img src="/logos/logotype.svg" alt="tangled" class={`h-8 dark:invert ${className}`} /> 12 + {:else} 13 + <img src="/logos/dolly.svg" alt="tangled" class={`size-8 ${className}`} /> 14 + {/if}
+6 -2
web/src/lib/components/ui/LogotypeSmall.svelte
··· 1 1 <span class="inline-flex items-center gap-2 select-none"> 2 - <img src="/logos/logotype.svg" alt="tangled" class="h-8 dark:invert" /> 3 - <span class="rounded bg-surface-muted px-1 text-xs font-normal not-italic"> alpha </span> 2 + <img src="/logos/logotype.svg" alt="tangled" class="h-[26px] dark:invert" /> 3 + <span 4 + class="rounded bg-background-subtle px-1 text-[10px] leading-4 font-normal not-italic text-foreground-default" 5 + > 6 + alpha 7 + </span> 4 8 </span>
+25
web/src/lib/components/ui/Pagination.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Pagination from "./Pagination.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/Pagination", 7 + component: Pagination, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + page: { control: { type: "number" } }, 11 + total: { control: { type: "number" } } 12 + }, 13 + args: { 14 + page: 1, 15 + total: 10 16 + } 17 + }); 18 + </script> 19 + 20 + <Story name="FirstPage" args={{ page: 1, total: 10 }} /> 21 + <Story name="MiddlePage" args={{ page: 5, total: 10 }} /> 22 + <Story name="LastPage" args={{ page: 10, total: 10 }} /> 23 + <Story name="FewPages" args={{ page: 2, total: 3 }} /> 24 + <Story name="SinglePage" args={{ page: 1, total: 1 }} /> 25 + <Story name="ManyPages" args={{ page: 12, total: 40 }} />
+135
web/src/lib/components/ui/Pagination.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const pagination = tv({ 5 + base: "flex items-center gap-1" 6 + }); 7 + 8 + export const paginationNav = tv({ 9 + base: "flex size-8 items-center justify-center rounded text-foreground-muted transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)] hover:bg-background-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus disabled:pointer-events-none disabled:opacity-50" 10 + }); 11 + 12 + export const paginationItem = tv({ 13 + base: "flex size-8 items-center justify-center rounded border text-sm transition-colors duration-150 ease-[cubic-bezier(0.4,0,0.2,1)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus", 14 + variants: { 15 + active: { 16 + true: "border-border-strong bg-background-inset font-medium text-foreground-default", 17 + false: "border-border-default text-foreground-muted hover:bg-background-subtle" 18 + } 19 + }, 20 + defaultVariants: { 21 + active: false 22 + } 23 + }); 24 + 25 + export type PaginationItemVariants = VariantProps<typeof paginationItem>; 26 + 27 + type Item = number | "start-ellipsis" | "end-ellipsis"; 28 + 29 + function range(start: number, end: number): number[] { 30 + return Array.from({ length: Math.max(end - start + 1, 0) }, (_, i) => start + i); 31 + } 32 + 33 + // Windowing per MUI's usePagination: always show the boundary pages 34 + // (first/last) plus a sibling window around the current page, collapsing 35 + // any gap into a single ellipsis. 36 + function getPageItems(current: number, count: number): Item[] { 37 + const siblingCount = 1; 38 + const boundaryCount = 1; 39 + 40 + const startPages = range(1, Math.min(boundaryCount, count)); 41 + const endPages = range(Math.max(count - boundaryCount + 1, boundaryCount + 1), count); 42 + 43 + const siblingsStart = Math.max( 44 + Math.min(current - siblingCount, count - boundaryCount - siblingCount * 2 - 1), 45 + boundaryCount + 2 46 + ); 47 + const siblingsEnd = Math.min( 48 + Math.max(current + siblingCount, boundaryCount + siblingCount * 2 + 2), 49 + endPages.length > 0 ? endPages[0] - 2 : count - 1 50 + ); 51 + 52 + return [ 53 + ...startPages, 54 + ...(siblingsStart > boundaryCount + 2 55 + ? (["start-ellipsis"] as const) 56 + : boundaryCount + 1 < count - boundaryCount 57 + ? [boundaryCount + 1] 58 + : []), 59 + ...range(siblingsStart, siblingsEnd), 60 + ...(siblingsEnd < count - boundaryCount - 1 61 + ? (["end-ellipsis"] as const) 62 + : count - boundaryCount > boundaryCount 63 + ? [count - boundaryCount] 64 + : []), 65 + ...endPages 66 + ]; 67 + } 68 + </script> 69 + 70 + <script lang="ts"> 71 + import ChevronLeft from "$icon/chevron-left"; 72 + import ChevronRight from "$icon/chevron-right"; 73 + 74 + interface Props { 75 + page: number; 76 + total: number; 77 + onchange?: (page: number) => void; 78 + class?: string; 79 + } 80 + 81 + let { page = $bindable(1), total, onchange, class: className }: Props = $props(); 82 + 83 + const count = $derived(Math.max(total, 1)); 84 + const items = $derived(getPageItems(Math.min(Math.max(page, 1), count), count)); 85 + 86 + function goTo(target: number) { 87 + const next = Math.min(Math.max(target, 1), count); 88 + if (next === page) return; 89 + page = next; 90 + onchange?.(next); 91 + } 92 + </script> 93 + 94 + <nav aria-label="Pagination" class={pagination({ class: className })}> 95 + <button 96 + type="button" 97 + class={paginationNav()} 98 + disabled={page <= 1} 99 + aria-label="Previous page" 100 + onclick={() => goTo(page - 1)} 101 + > 102 + <ChevronLeft class="size-4" aria-hidden="true" /> 103 + </button> 104 + 105 + {#each items as item, i (typeof item === "number" ? item : `${item}-${i}`)} 106 + {#if typeof item === "number"} 107 + <button 108 + type="button" 109 + class={paginationItem({ active: item === page })} 110 + aria-current={item === page ? "page" : undefined} 111 + aria-label={`Page ${item}`} 112 + onclick={() => goTo(item)} 113 + > 114 + {item} 115 + </button> 116 + {:else} 117 + <span 118 + class="flex size-8 items-center justify-center text-foreground-subtle" 119 + aria-hidden="true" 120 + > 121 + &hellip; 122 + </span> 123 + {/if} 124 + {/each} 125 + 126 + <button 127 + type="button" 128 + class={paginationNav()} 129 + disabled={page >= count} 130 + aria-label="Next page" 131 + onclick={() => goTo(page + 1)} 132 + > 133 + <ChevronRight class="size-4" aria-hidden="true" /> 134 + </button> 135 + </nav>
+34
web/src/lib/components/ui/Radio.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Radio from "./Radio.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/Radio", 7 + component: Radio, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + value: { control: "text" }, 11 + group: { control: "text" }, 12 + disabled: { control: "boolean" } 13 + }, 14 + args: { 15 + value: "a", 16 + group: "b", 17 + disabled: false 18 + } 19 + }); 20 + </script> 21 + 22 + <!-- Radio content comes through its `children` snippet, provided here as Story slot content. --> 23 + <Story name="Default">Option</Story> 24 + <Story name="Checked" args={{ group: "a" }}>Option</Story> 25 + <Story name="Disabled" args={{ disabled: true }}>Option</Story> 26 + <Story name="Disabled checked" args={{ group: "a", disabled: true }}>Option</Story> 27 + 28 + <Story name="Group"> 29 + <div class="flex items-center gap-4"> 30 + <Radio value="a" group="a">Option</Radio> 31 + <Radio value="b" group="a">Option</Radio> 32 + <Radio value="c" group="a" disabled>Option</Radio> 33 + </div> 34 + </Story>
+45
web/src/lib/components/ui/Radio.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + import type { HTMLInputAttributes } from "svelte/elements"; 4 + 5 + interface Props extends Omit<HTMLInputAttributes, "type" | "class" | "value" | "children"> { 6 + value: string; 7 + group?: string; 8 + disabled?: boolean; 9 + class?: string; 10 + children?: Snippet; 11 + } 12 + 13 + let { 14 + value, 15 + group = $bindable(undefined), 16 + disabled = false, 17 + class: className, 18 + children, 19 + ...rest 20 + }: Props = $props(); 21 + 22 + const labelClasses = $derived( 23 + `inline-flex items-center gap-2 cursor-pointer ${disabled ? "cursor-not-allowed text-foreground-disabled" : ""} ${className ?? ""}` 24 + ); 25 + </script> 26 + 27 + <label class={labelClasses}> 28 + <span class="relative inline-flex size-4 shrink-0"> 29 + <input 30 + type="radio" 31 + {value} 32 + bind:group 33 + {disabled} 34 + class="peer size-4 shrink-0 appearance-none rounded-full border border-border-strong bg-background-default transition-colors duration-150 hover:bg-background-subtle checked:border-foreground-default focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground-default disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50" 35 + {...rest} 36 + /> 37 + <span 38 + class="pointer-events-none absolute inset-0 m-auto hidden size-2 rounded-full bg-foreground-default peer-checked:block peer-disabled:opacity-50" 39 + aria-hidden="true" 40 + ></span> 41 + </span> 42 + {#if children} 43 + <span>{@render children()}</span> 44 + {/if} 45 + </label>
+38
web/src/lib/components/ui/Scaffold.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Scaffold from "./Scaffold.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/Scaffold", 7 + component: Scaffold, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + width: { 11 + control: { type: "inline-radio" }, 12 + options: ["lg", "xl", "full"] 13 + } 14 + }, 15 + args: { 16 + width: "lg" 17 + } 18 + }); 19 + </script> 20 + 21 + <!-- Scaffold content comes through its `children` snippet, provided here as Story slot content. --> 22 + <Story name="Default"> 23 + <Scaffold> 24 + <div class="rounded border border-border-default bg-background-default p-4">Content</div> 25 + </Scaffold> 26 + </Story> 27 + 28 + <Story name="Wide" args={{ width: "xl" }}> 29 + <Scaffold width="xl"> 30 + <div class="rounded border border-border-default bg-background-default p-4">Content</div> 31 + </Scaffold> 32 + </Story> 33 + 34 + <Story name="Full" args={{ width: "full" }}> 35 + <Scaffold width="full"> 36 + <div class="rounded border border-border-default bg-background-default p-4">Content</div> 37 + </Scaffold> 38 + </Story>
+39
web/src/lib/components/ui/Scaffold.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + // Echoes the +layout.svelte container idiom: a centered, padded section 5 + // clamped to a max width (matches Figma node 35:791's single Content slot). 6 + export const scaffold = tv({ 7 + base: "mx-auto w-full px-4 py-6", 8 + variants: { 9 + width: { 10 + lg: "max-w-screen-lg", 11 + xl: "max-w-screen-xl", 12 + full: "max-w-full" 13 + } 14 + }, 15 + defaultVariants: { 16 + width: "lg" 17 + } 18 + }); 19 + 20 + export type ScaffoldVariants = VariantProps<typeof scaffold>; 21 + </script> 22 + 23 + <script lang="ts"> 24 + import type { Snippet } from "svelte"; 25 + 26 + interface Props { 27 + width?: ScaffoldVariants["width"]; 28 + class?: string; 29 + children: Snippet; 30 + } 31 + 32 + let { width = "lg", class: className, children }: Props = $props(); 33 + 34 + const classes = $derived(scaffold({ width, class: className })); 35 + </script> 36 + 37 + <div class={classes}> 38 + {@render children()} 39 + </div>
+65
web/src/lib/components/ui/Select.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Select from "./Select.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/Select", 7 + component: Select, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + error: { control: "boolean" }, 11 + disabled: { control: "boolean" } 12 + }, 13 + args: { 14 + error: false, 15 + disabled: false 16 + } 17 + }); 18 + </script> 19 + 20 + <!-- Select's content comes through its `children` snippet, provided here as Story slot content. --> 21 + <Story name="Default"> 22 + <option value="" disabled selected>Select</option> 23 + <option value="one">One</option> 24 + <option value="two">Two</option> 25 + <option value="three">Three</option> 26 + </Story> 27 + 28 + <Story name="Filled" args={{ value: "two" }}> 29 + <option value="one">One</option> 30 + <option value="two">Two</option> 31 + <option value="three">Three</option> 32 + </Story> 33 + 34 + <Story name="Error" args={{ error: true, value: "two" }}> 35 + <option value="one">One</option> 36 + <option value="two">Two</option> 37 + <option value="three">Three</option> 38 + </Story> 39 + 40 + <Story name="Disabled" args={{ disabled: true, value: "one" }}> 41 + <option value="one">One</option> 42 + <option value="two">Two</option> 43 + <option value="three">Three</option> 44 + </Story> 45 + 46 + <Story name="States"> 47 + <div class="flex flex-col gap-3"> 48 + <Select> 49 + <option value="" disabled selected>Select</option> 50 + <option value="one">One</option> 51 + </Select> 52 + <Select value="one"> 53 + <option value="one">One</option> 54 + <option value="two">Two</option> 55 + </Select> 56 + <Select error value="one"> 57 + <option value="one">One</option> 58 + <option value="two">Two</option> 59 + </Select> 60 + <Select disabled value="one"> 61 + <option value="one">One</option> 62 + <option value="two">Two</option> 63 + </Select> 64 + </div> 65 + </Story>
+61
web/src/lib/components/ui/Select.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const select = tv({ 5 + base: "w-full appearance-none rounded border bg-background-default px-3 py-2 pr-9 text-sm outline-none transition-colors disabled:cursor-not-allowed", 6 + variants: { 7 + error: { 8 + false: 9 + "border-border-default focus:border-border-strong focus:ring-1 focus:ring-border-strong", 10 + true: "border-border-danger bg-background-danger-subtle text-foreground-danger focus:border-border-focus-danger focus:ring-1 focus:ring-border-focus-danger" 11 + }, 12 + disabled: { 13 + false: "", 14 + true: "bg-background-inset text-foreground-disabled" 15 + } 16 + }, 17 + defaultVariants: { 18 + error: false, 19 + disabled: false 20 + } 21 + }); 22 + 23 + export type SelectVariants = VariantProps<typeof select>; 24 + </script> 25 + 26 + <script lang="ts"> 27 + import ChevronDown from "$icon/chevron-down"; 28 + import type { HTMLSelectAttributes } from "svelte/elements"; 29 + import type { Snippet } from "svelte"; 30 + 31 + interface Props extends Omit<HTMLSelectAttributes, "class" | "value"> { 32 + value?: string; 33 + error?: boolean; 34 + disabled?: boolean; 35 + class?: string; 36 + children?: Snippet; 37 + } 38 + 39 + let { 40 + value = $bindable(""), 41 + error = false, 42 + disabled = false, 43 + class: className, 44 + children, 45 + ...rest 46 + }: Props = $props(); 47 + 48 + const classes = $derived(select({ error, disabled, class: className })); 49 + </script> 50 + 51 + <div class="relative"> 52 + <select bind:value {disabled} aria-invalid={error} class={classes} {...rest}> 53 + {#if children} 54 + {@render children()} 55 + {/if} 56 + </select> 57 + <ChevronDown 58 + class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-foreground-subtle" 59 + aria-hidden="true" 60 + /> 61 + </div>
+24
web/src/lib/components/ui/Tag.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Hash from "$icon/hash"; 4 + import Tag from "./Tag.svelte"; 5 + 6 + const { Story } = defineMeta({ 7 + title: "UI/Tag", 8 + component: Tag, 9 + tags: ["autodocs"], 10 + argTypes: { 11 + color: { 12 + control: { type: "inline-radio" }, 13 + options: ["default", "gray"] 14 + } 15 + }, 16 + args: { 17 + color: "default" 18 + } 19 + }); 20 + </script> 21 + 22 + <Story name="Default">Tag</Story> 23 + <Story name="Gray" args={{ color: "gray" }}>Tag</Story> 24 + <Story name="WithIcon" args={{ icon: Hash }}>topic</Story>
+46
web/src/lib/components/ui/Tag.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const tag = tv({ 5 + base: "inline-flex select-none items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium leading-none", 6 + variants: { 7 + color: { 8 + default: "border border-border-default bg-background-default text-foreground-default", 9 + gray: "bg-background-inset text-foreground-muted" 10 + } 11 + }, 12 + defaultVariants: { 13 + color: "default" 14 + } 15 + }); 16 + 17 + export type TagVariants = VariantProps<typeof tag>; 18 + </script> 19 + 20 + <script lang="ts"> 21 + import type { Component, Snippet } from "svelte"; 22 + import type { SvelteHTMLElements } from "svelte/elements"; 23 + import type { HTMLAttributes } from "svelte/elements"; 24 + 25 + interface Props extends Omit<HTMLAttributes<HTMLSpanElement>, "class" | "children"> { 26 + color?: TagVariants["color"]; 27 + /** Optional leading icon component (e.g. `import Hash from "$icon/hash"`). */ 28 + icon?: Component<SvelteHTMLElements["svg"]>; 29 + class?: string; 30 + children?: Snippet; 31 + } 32 + 33 + let { color = "default", icon, class: className, children, ...rest }: Props = $props(); 34 + 35 + const classes = $derived(tag({ color, class: className })); 36 + const Icon = $derived(icon); 37 + </script> 38 + 39 + <span class={classes} {...rest}> 40 + {#if Icon} 41 + <Icon class="size-3" aria-hidden="true" /> 42 + {/if} 43 + {#if children} 44 + {@render children()} 45 + {/if} 46 + </span>
+44
web/src/lib/components/ui/Textarea.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Textarea from "./Textarea.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/Textarea", 7 + component: Textarea, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + error: { control: "boolean" }, 11 + disabled: { control: "boolean" }, 12 + loading: { control: "boolean" }, 13 + readonly: { control: "boolean" }, 14 + placeholder: { control: "text" }, 15 + rows: { control: "number" } 16 + }, 17 + args: { 18 + error: false, 19 + disabled: false, 20 + loading: false, 21 + readonly: false, 22 + placeholder: "Placeholder" 23 + } 24 + }); 25 + </script> 26 + 27 + <Story name="Default" /> 28 + <Story name="Focus" args={{ autofocus: true }} /> 29 + <Story name="Filled" args={{ value: "Some notes go here" }} /> 30 + <Story name="Error" args={{ error: true, value: "Invalid value" }} /> 31 + <Story name="Disabled" args={{ disabled: true, value: "Can't edit this" }} /> 32 + <Story name="Read-only" args={{ readonly: true, value: "Read-only content" }} /> 33 + <Story name="Loading" args={{ loading: true, value: "Checking..." }} /> 34 + 35 + <Story name="States"> 36 + <div class="flex w-64 flex-col gap-3"> 37 + <Textarea placeholder="Default" /> 38 + <Textarea placeholder="Filled" value="Some notes go here" /> 39 + <Textarea placeholder="Error" error value="Invalid value" /> 40 + <Textarea placeholder="Disabled" disabled value="Can't edit this" /> 41 + <Textarea placeholder="Read-only" readonly value="Read-only content" /> 42 + <Textarea placeholder="Loading" loading value="Checking..." /> 43 + </div> 44 + </Story>
+69
web/src/lib/components/ui/Textarea.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const textareaField = tv({ 5 + base: "min-h-20 w-full resize-y rounded border bg-background-default px-3 py-2 text-sm transition-colors placeholder:text-foreground-placeholder focus:outline-none", 6 + variants: { 7 + error: { 8 + false: 9 + "border-border-default focus:border-border-strong focus:ring-1 focus:ring-border-strong", 10 + true: "border-border-danger bg-background-danger-subtle text-foreground-danger focus:border-border-focus-danger focus:ring-1 focus:ring-border-focus-danger" 11 + }, 12 + disabled: { 13 + false: "", 14 + true: "cursor-not-allowed resize-none bg-background-inset text-foreground-disabled" 15 + }, 16 + readonly: { 17 + false: "", 18 + true: "cursor-default resize-none bg-background-subtle" 19 + } 20 + }, 21 + defaultVariants: { 22 + error: false, 23 + disabled: false, 24 + readonly: false 25 + } 26 + }); 27 + 28 + export type TextareaFieldVariants = VariantProps<typeof textareaField>; 29 + </script> 30 + 31 + <script lang="ts"> 32 + import type { HTMLTextareaAttributes } from "svelte/elements"; 33 + import Spinner from "./Spinner.svelte"; 34 + 35 + interface Props extends Omit<HTMLTextareaAttributes, "class" | "value"> { 36 + value?: string; 37 + error?: boolean; 38 + disabled?: boolean; 39 + loading?: boolean; 40 + readonly?: boolean; 41 + class?: string; 42 + } 43 + 44 + let { 45 + value = $bindable(""), 46 + error = false, 47 + disabled = false, 48 + loading = false, 49 + readonly = false, 50 + class: className, 51 + ...rest 52 + }: Props = $props(); 53 + 54 + const classes = $derived(textareaField({ error, disabled, readonly, class: className })); 55 + </script> 56 + 57 + <div class="relative"> 58 + <textarea 59 + bind:value 60 + {disabled} 61 + {readonly} 62 + aria-invalid={error} 63 + aria-busy={loading} 64 + class={classes} 65 + {...rest}></textarea> 66 + {#if loading} 67 + <Spinner class="absolute right-2 top-2 size-4" /> 68 + {/if} 69 + </div>
+30
web/src/lib/components/ui/Thumbnail.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Thumbnail from "./Thumbnail.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/Thumbnail", 7 + component: Thumbnail, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + title: { control: "text" } 11 + }, 12 + args: { 13 + title: "Design System" 14 + } 15 + }); 16 + </script> 17 + 18 + <Story name="Default"> 19 + <div class="w-96"> 20 + <Thumbnail /> 21 + </div> 22 + </Story> 23 + 24 + <Story name="Custom content"> 25 + <div class="w-96"> 26 + <Thumbnail> 27 + <span class="text-2xl font-bold text-foreground-default sm:text-3xl">Thumbnail</span> 28 + </Thumbnail> 29 + </div> 30 + </Story>
+29
web/src/lib/components/ui/Thumbnail.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + import Logo from "./Logo.svelte"; 4 + 5 + interface Props { 6 + title?: string; 7 + class?: string; 8 + children?: Snippet; 9 + } 10 + 11 + let { title, class: className, children }: Props = $props(); 12 + </script> 13 + 14 + <div 15 + class={`relative flex aspect-video flex-col gap-6 overflow-hidden rounded-lg border border-border-default bg-background-subtle p-8 ${className ?? ""}`} 16 + > 17 + <div 18 + class="pointer-events-none absolute inset-0 bg-[radial-gradient(var(--color-border-strong)_1px,transparent_1px)] bg-[length:12px_12px] [mask-image:linear-gradient(to_bottom,transparent,black)] opacity-60" 19 + aria-hidden="true" 20 + ></div> 21 + <div class="relative z-10 flex flex-col gap-6"> 22 + <Logo wordmark /> 23 + {#if children} 24 + {@render children()} 25 + {:else if title} 26 + <span class="text-2xl font-bold text-foreground-default sm:text-3xl">{title}</span> 27 + {/if} 28 + </div> 29 + </div>
+52
web/src/lib/components/ui/Typography.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import Typography from "./Typography.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/Typography", 7 + component: Typography, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + variant: { 11 + control: { type: "select" }, 12 + options: ["h1", "h2", "h3", "h4", "body", "body-strong", "small", "caption", "mono"] 13 + }, 14 + as: { control: "text" } 15 + }, 16 + args: { 17 + variant: "body" 18 + } 19 + }); 20 + </script> 21 + 22 + <!-- Typography content comes through its `children` snippet, provided here as Story slot content. --> 23 + <Story name="H1" args={{ variant: "h1" }}>Design that ships</Story> 24 + <Story name="H2" args={{ variant: "h2" }}>Design that ships</Story> 25 + <Story name="H3" args={{ variant: "h3" }}>Design that ships</Story> 26 + <Story name="H4" args={{ variant: "h4" }}>Design that ships</Story> 27 + <Story name="Body" args={{ variant: "body" }}> 28 + Tangled is a social-enabled, decentralized code collaboration platform built on the AT Protocol. 29 + </Story> 30 + <Story name="BodyStrong" args={{ variant: "body-strong" }}> 31 + Tangled is a social-enabled, decentralized code collaboration platform. 32 + </Story> 33 + <Story name="Small" args={{ variant: "small" }}>Last updated 2 hours ago.</Story> 34 + <Story name="Caption" args={{ variant: "caption" }}>Caption text for supporting detail.</Story> 35 + <Story name="Mono" args={{ variant: "mono" }}>const tangled = true;</Story> 36 + <Story name="CustomTag" args={{ variant: "h2", as: "div" }}> 37 + Renders as a div while keeping h2 styling 38 + </Story> 39 + 40 + <Story name="Scale"> 41 + <div class="flex flex-col gap-3"> 42 + <Typography variant="h1">Heading 1</Typography> 43 + <Typography variant="h2">Heading 2</Typography> 44 + <Typography variant="h3">Heading 3</Typography> 45 + <Typography variant="h4">Heading 4</Typography> 46 + <Typography variant="body">Body paragraph text.</Typography> 47 + <Typography variant="body-strong">Body strong paragraph text.</Typography> 48 + <Typography variant="small">Small supporting text.</Typography> 49 + <Typography variant="caption">Caption text.</Typography> 50 + <Typography variant="mono">const scale = "type";</Typography> 51 + </div> 52 + </Story>
+62
web/src/lib/components/ui/Typography.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + import type { SvelteHTMLElements } from "svelte/elements"; 4 + 5 + // Scale sourced from Figma "Font Styles" (node 1:134): Heading 1-4 map to 6 + // h1-h4, the "Paragraph Regular" size maps to body/body-strong (weight 7 + // axis only), "Paragraph Small"/"Paragraph Mini" map to small/caption, and 8 + // the monospace specimen maps to mono. 9 + export const typography = tv({ 10 + base: "font-sans text-foreground-default", 11 + variants: { 12 + variant: { 13 + h1: "text-2xl font-bold leading-tight", 14 + h2: "text-xl font-bold leading-tight", 15 + h3: "text-lg font-semibold leading-snug", 16 + h4: "text-base font-semibold leading-snug", 17 + body: "text-base font-normal leading-normal", 18 + "body-strong": "text-base font-semibold leading-normal", 19 + small: "text-sm font-normal leading-normal text-foreground-muted", 20 + caption: "text-xs font-normal leading-normal text-foreground-subtle", 21 + mono: "font-mono text-sm font-normal leading-normal" 22 + } 23 + }, 24 + defaultVariants: { 25 + variant: "body" 26 + } 27 + }); 28 + 29 + export type TypographyVariants = VariantProps<typeof typography>; 30 + 31 + const defaultTag = { 32 + h1: "h1", 33 + h2: "h2", 34 + h3: "h3", 35 + h4: "h4", 36 + body: "p", 37 + "body-strong": "p", 38 + small: "span", 39 + caption: "span", 40 + mono: "code" 41 + } as const satisfies Record<NonNullable<TypographyVariants["variant"]>, keyof SvelteHTMLElements>; 42 + </script> 43 + 44 + <script lang="ts"> 45 + import type { Snippet } from "svelte"; 46 + 47 + interface Props { 48 + variant?: TypographyVariants["variant"]; 49 + as?: keyof SvelteHTMLElements; 50 + class?: string; 51 + children: Snippet; 52 + } 53 + 54 + let { variant = "body", as, class: className, children }: Props = $props(); 55 + 56 + const tag = $derived(as ?? defaultTag[variant ?? "body"]); 57 + const classes = $derived(typography({ variant, class: className })); 58 + </script> 59 + 60 + <svelte:element this={tag} class={classes}> 61 + {@render children()} 62 + </svelte:element>
+39
web/src/lib/components/ui/User.stories.svelte
··· 1 + <script module lang="ts"> 2 + import { defineMeta } from "@storybook/addon-svelte-csf"; 3 + import User from "./User.svelte"; 4 + 5 + const { Story } = defineMeta({ 6 + title: "UI/User", 7 + component: User, 8 + tags: ["autodocs"], 9 + argTypes: { 10 + handle: { control: "text" }, 11 + src: { control: "text" }, 12 + size: { 13 + control: { type: "inline-radio" }, 14 + options: ["sm", "md", "lg"] 15 + }, 16 + showImage: { control: "boolean" }, 17 + showText: { control: "boolean" } 18 + }, 19 + args: { 20 + handle: "user.tld", 21 + size: "md", 22 + showImage: true, 23 + showText: true 24 + } 25 + }); 26 + </script> 27 + 28 + <Story name="Default" /> 29 + <Story name="With avatar" args={{ src: "https://avatars.githubusercontent.com/u/1" }} /> 30 + <Story name="Image only" args={{ showText: false }} /> 31 + <Story name="Text only" args={{ showImage: false }} /> 32 + 33 + <Story name="Sizes"> 34 + <div class="flex flex-col items-start gap-3"> 35 + <User size="lg" /> 36 + <User size="md" /> 37 + <User size="sm" /> 38 + </div> 39 + </Story>
+64
web/src/lib/components/ui/User.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const user = tv({ 5 + slots: { 6 + avatar: "", 7 + handle: "truncate text-foreground-default" 8 + }, 9 + variants: { 10 + size: { 11 + sm: { 12 + avatar: "size-5", 13 + handle: "text-xs" 14 + }, 15 + md: { 16 + avatar: "size-6", 17 + handle: "text-sm" 18 + }, 19 + lg: { 20 + avatar: "size-8", 21 + handle: "text-base" 22 + } 23 + } 24 + }, 25 + defaultVariants: { 26 + size: "md" 27 + } 28 + }); 29 + 30 + export type UserVariants = VariantProps<typeof user>; 31 + </script> 32 + 33 + <script lang="ts"> 34 + import Avatar from "./Avatar.svelte"; 35 + 36 + interface Props { 37 + handle?: string; 38 + src?: string; 39 + size?: UserVariants["size"]; 40 + showImage?: boolean; 41 + showText?: boolean; 42 + class?: string; 43 + } 44 + 45 + let { 46 + handle, 47 + src, 48 + size = "md", 49 + showImage = true, 50 + showText = true, 51 + class: className 52 + }: Props = $props(); 53 + 54 + const slots = $derived(user({ size })); 55 + </script> 56 + 57 + <span class={`inline-flex items-center gap-2 ${className ?? ""}`}> 58 + {#if showImage} 59 + <Avatar {src} {handle} size={slots.avatar()} /> 60 + {/if} 61 + {#if showText && handle} 62 + <span class={slots.handle()}>{handle}</span> 63 + {/if} 64 + </span>