Monorepo for Tangled
0

Configure Feed

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

web/ui: use tailwind variants

Signed-off-by: dawn <dawn@tangled.org>

dawn (Jul 10, 2026, 8:09 PM +0300) bd6c66d5 7f86fe07

+133 -250
+3 -1
web/package.json
··· 49 49 "@atcute/client": "^5.1.1", 50 50 "@atcute/identity-resolver": "^2.0.1", 51 51 "@atcute/lexicons": "^2.0.2", 52 - "@atcute/oauth-browser-client": "^4.0.1" 52 + "@atcute/oauth-browser-client": "^4.0.1", 53 + "tailwind-merge": "^3.6.0", 54 + "tailwind-variants": "^3.2.2" 53 55 } 54 56 }
+27
web/pnpm-lock.yaml
··· 23 23 '@atcute/oauth-browser-client': 24 24 specifier: ^4.0.1 25 25 version: 4.0.1(@atcute/identity-resolver@2.0.1(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3) 26 + tailwind-merge: 27 + specifier: ^3.6.0 28 + version: 3.6.0 29 + tailwind-variants: 30 + specifier: ^3.2.2 31 + version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.2) 26 32 devDependencies: 27 33 '@eslint/js': 28 34 specifier: ^10.0.1 ··· 1496 1502 resolution: {integrity: sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==} 1497 1503 engines: {node: '>=18'} 1498 1504 1505 + tailwind-merge@3.6.0: 1506 + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} 1507 + 1508 + tailwind-variants@3.2.2: 1509 + resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} 1510 + engines: {node: '>=16.x', pnpm: '>=7.x'} 1511 + peerDependencies: 1512 + tailwind-merge: '>=3.0.0' 1513 + tailwindcss: '*' 1514 + peerDependenciesMeta: 1515 + tailwind-merge: 1516 + optional: true 1517 + 1499 1518 tailwindcss@4.3.2: 1500 1519 resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} 1501 1520 ··· 2980 2999 zimmerframe: 1.1.4 2981 3000 transitivePeerDependencies: 2982 3001 - '@typescript-eslint/types' 3002 + 3003 + tailwind-merge@3.6.0: {} 3004 + 3005 + tailwind-variants@3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.2): 3006 + dependencies: 3007 + tailwindcss: 4.3.2 3008 + optionalDependencies: 3009 + tailwind-merge: 3.6.0 2983 3010 2984 3011 tailwindcss@4.3.2: {} 2985 3012
+51 -170
web/src/lib/components/ui/Button.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from 'tailwind-variants'; 3 + 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', 6 + variants: { 7 + variant: { 8 + default: 9 + 'border border-border bg-surface text-fg hover:bg-surface-hover focus-visible:outline-ring', 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 + danger: 14 + 'border border-danger-border bg-danger text-danger-fg hover:bg-danger-hover focus-visible:outline-danger', 15 + success: 16 + 'border border-success-border bg-success text-success-fg hover:bg-success-hover focus-visible:outline-success' 17 + }, 18 + size: { 19 + sm: 'min-h-8 gap-1.5 px-2 py-1.5 text-sm', 20 + md: 'min-h-9 gap-2 px-4 py-2 text-sm', 21 + lg: 'min-h-10 gap-2.5 px-6 py-2 text-base' 22 + } 23 + }, 24 + compoundVariants: [ 25 + // raised variants draw inset depth via a ::before layer. 26 + { 27 + variant: 'default', 28 + class: 29 + "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" 30 + }, 31 + { 32 + variant: ['primary', 'danger', 'success'], 33 + class: 34 + "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" 35 + } 36 + ], 37 + defaultVariants: { 38 + variant: 'default', 39 + size: 'md' 40 + } 41 + }); 42 + 43 + export type ButtonVariants = VariantProps<typeof button>; 44 + </script> 45 + 1 46 <script lang="ts"> 2 47 import { resolve } from '$app/paths'; 3 48 import type { Snippet } from 'svelte'; ··· 5 50 import Spinner from './Spinner.svelte'; 6 51 7 52 interface Props extends Omit<HTMLAnchorAttributes & HTMLButtonAttributes, 'class' | 'children'> { 8 - variant?: 'default' | 'primary' | 'ghost' | 'danger' | 'success'; 9 - size?: 'sm' | 'md' | 'lg'; 53 + variant?: ButtonVariants['variant']; 54 + size?: ButtonVariants['size']; 10 55 href?: string; 11 56 type?: 'button' | 'submit' | 'reset'; 12 57 disabled?: boolean; ··· 22 67 href, 23 68 type = 'button', 24 69 disabled = false, 25 - class: className = '', 70 + class: className, 26 71 loading = false, 27 72 spinnerClass, 28 73 children, 29 74 ...rest 30 75 }: Props = $props(); 31 76 32 - const raised = $derived(variant !== 'ghost'); 33 - const accentRaised = $derived(variant !== 'default' && variant !== 'ghost'); 77 + const classes = $derived(button({ variant, size, class: className })); 34 78 const spinnerSize = $derived(spinnerClass ?? (size === 'lg' ? 'size-5' : 'size-4')); 35 79 </script> 36 80 37 81 {#if href} 38 82 <a 39 83 href={resolve(href as '/')} 40 - class="btn {className}" 41 - data-variant={variant} 42 - data-size={size} 43 - class:raised 44 - class:accentRaised 84 + class={classes} 45 85 aria-disabled={disabled || loading ? 'true' : undefined} 46 86 tabindex={disabled ? -1 : undefined} 47 87 {...rest} ··· 53 93 {/if} 54 94 </a> 55 95 {:else} 56 - <button 57 - {type} 58 - disabled={disabled || loading} 59 - class="btn {className}" 60 - data-variant={variant} 61 - data-size={size} 62 - class:raised 63 - class:accentRaised 64 - aria-busy={loading} 65 - {...rest} 66 - > 96 + <button {type} disabled={disabled || loading} class={classes} aria-busy={loading} {...rest}> 67 97 {#if loading} 68 98 <Spinner class={spinnerSize} /> 69 99 {:else if children} ··· 71 101 {/if} 72 102 </button> 73 103 {/if} 74 - 75 - <style> 76 - .btn { 77 - display: inline-flex; 78 - cursor: pointer; 79 - align-items: center; 80 - justify-content: center; 81 - overflow: hidden; 82 - border-radius: 0.25rem; 83 - text-decoration: none; 84 - transition: 85 - background-color 150ms cubic-bezier(0.4, 0, 0.2, 1), 86 - border-color 150ms cubic-bezier(0.4, 0, 0.2, 1), 87 - color 150ms cubic-bezier(0.4, 0, 0.2, 1); 88 - user-select: none; 89 - } 90 - 91 - .btn:hover { 92 - text-decoration: none; 93 - } 94 - 95 - .btn:focus-visible { 96 - outline: 2px solid var(--color-ring); 97 - outline-offset: 2px; 98 - } 99 - 100 - .btn:disabled { 101 - pointer-events: none; 102 - opacity: 0.5; 103 - } 104 - 105 - .btn[data-variant='default'] { 106 - background-color: var(--color-surface); 107 - border: 1px solid var(--color-border); 108 - color: var(--color-fg); 109 - } 110 - .btn[data-variant='default']:hover { 111 - background-color: var(--color-surface-hover); 112 - } 113 - .btn[data-variant='default']:focus-visible { 114 - outline-color: var(--color-ring); 115 - } 116 - 117 - .btn[data-variant='primary'] { 118 - background-color: var(--color-primary); 119 - border: 1px solid var(--color-primary-border); 120 - color: var(--color-primary-fg); 121 - } 122 - .btn[data-variant='primary']:hover { 123 - background-color: var(--color-primary-hover); 124 - } 125 - .btn[data-variant='primary']:focus-visible { 126 - outline-color: var(--color-primary); 127 - } 128 - 129 - .btn[data-variant='ghost'] { 130 - color: var(--color-fg-muted); 131 - background-color: transparent; 132 - } 133 - .btn[data-variant='ghost']:hover { 134 - background-color: var(--color-surface-muted); 135 - } 136 - .btn[data-variant='ghost']:focus-visible { 137 - outline-color: var(--color-ring); 138 - } 139 - 140 - .btn[data-variant='danger'] { 141 - background-color: var(--color-danger); 142 - border: 1px solid var(--color-danger-border); 143 - color: var(--color-danger-fg); 144 - } 145 - .btn[data-variant='danger']:hover { 146 - background-color: var(--color-danger-hover); 147 - } 148 - .btn[data-variant='danger']:focus-visible { 149 - outline-color: var(--color-danger); 150 - } 151 - 152 - .btn[data-variant='success'] { 153 - background-color: var(--color-success); 154 - border: 1px solid var(--color-success-border); 155 - color: var(--color-success-fg); 156 - } 157 - .btn[data-variant='success']:hover { 158 - background-color: var(--color-success-hover); 159 - } 160 - .btn[data-variant='success']:focus-visible { 161 - outline-color: var(--color-success); 162 - } 163 - 164 - .btn[data-size='sm'] { 165 - min-height: 2rem; 166 - padding: 0.375rem 0.5rem; 167 - font-size: 0.875rem; 168 - gap: 0.375rem; 169 - } 170 - .btn[data-size='md'] { 171 - min-height: 2.25rem; 172 - padding: 0.5rem 1rem; 173 - font-size: 0.875rem; 174 - gap: 0.5rem; 175 - } 176 - .btn[data-size='lg'] { 177 - min-height: 2.5rem; 178 - padding: 0.5rem 1.5rem; 179 - font-size: 1rem; 180 - gap: 0.625rem; 181 - } 182 - 183 - .raised { 184 - position: relative; 185 - z-index: 0; 186 - } 187 - 188 - .raised::before { 189 - content: ''; 190 - position: absolute; 191 - inset: 0; 192 - z-index: -10; 193 - border-radius: inherit; 194 - box-shadow: inset 0 -2px 0 0 var(--color-raised-depth); 195 - transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); 196 - } 197 - 198 - .raised:hover::before { 199 - box-shadow: inset 0 -2px 0 0 var(--color-raised-depth-hover); 200 - } 201 - 202 - .raised:active { 203 - transform: translateY(0.125rem); 204 - } 205 - 206 - .raised:active::before { 207 - box-shadow: inset 0 2px 2px 0 var(--color-raised-depth); 208 - } 209 - 210 - .accentRaised::before, 211 - .accentRaised:hover::before { 212 - box-shadow: inset 0 -2px 0 0 var(--color-raised-accent-depth); 213 - } 214 - 215 - .accentRaised:active::before { 216 - box-shadow: inset 0 2px 2px 0 var(--color-raised-accent-depth-active); 217 - } 218 - 219 - .raised:disabled:active { 220 - transform: none; 221 - } 222 - </style>
+29 -39
web/src/lib/components/ui/Dropdown.svelte
··· 1 1 <script module lang="ts"> 2 2 import { SvelteMap, SvelteSet } from 'svelte/reactivity'; 3 + import { tv, type VariantProps } from 'tailwind-variants'; 4 + 5 + export const dropdown = tv({ 6 + slots: { 7 + root: 'relative inline-block', 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' 11 + }, 12 + variants: { 13 + open: { 14 + true: { 15 + menu: 'block' 16 + }, 17 + false: { 18 + menu: 'hidden' 19 + } 20 + } 21 + }, 22 + defaultVariants: { 23 + open: false 24 + } 25 + }); 26 + 27 + export type DropdownVariants = VariantProps<typeof dropdown>; 3 28 4 29 // opening one dropdown in a group closes its siblings. 5 30 const groups = new SvelteMap<string, SvelteSet<() => void>>(); ··· 42 67 let { id, group, variant = 'plain', label, trigger, children }: Props = $props(); 43 68 44 69 let open = $state(false); 70 + const classes = $derived(dropdown({ open })); 45 71 let root = $state<HTMLElement>(); 46 72 47 73 const close = () => { ··· 76 102 }); 77 103 </script> 78 104 79 - <div bind:this={root} class="dropdown"> 105 + <div bind:this={root} class={classes.root()}> 80 106 {#if variant === 'button'} 81 107 <Button 82 108 size="sm" ··· 91 117 {:else} 92 118 <button 93 119 type="button" 94 - class="plain" 120 + class={classes.plainTrigger()} 95 121 onclick={toggle} 96 122 aria-haspopup="menu" 97 123 aria-expanded={open} ··· 101 127 </button> 102 128 {/if} 103 129 104 - <div {id} class="menu" class:open role="menu"> 130 + <div {id} class={classes.menu()} role="menu"> 105 131 {@render children()} 106 132 </div> 107 133 </div> 108 - 109 - <style> 110 - .dropdown { 111 - position: relative; 112 - display: inline-block; 113 - } 114 - 115 - .plain { 116 - display: flex; 117 - align-items: center; 118 - gap: 0.25rem; 119 - border: 0; 120 - background: transparent; 121 - padding: 0; 122 - color: inherit; 123 - cursor: pointer; 124 - } 125 - 126 - .menu { 127 - position: absolute; 128 - right: 0; 129 - z-index: 50; 130 - margin-top: 0.5rem; 131 - width: 14rem; 132 - overflow: hidden; 133 - border: 1px solid var(--color-border); 134 - border-radius: 0.25rem; 135 - background-color: var(--color-surface); 136 - box-shadow: var(--shadow-menu); 137 - font-size: 0.875rem; 138 - } 139 - 140 - .menu:not(.open) { 141 - display: none; 142 - } 143 - </style>
+23 -40
web/src/lib/components/ui/DropdownItem.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from 'tailwind-variants'; 3 + 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', 6 + variants: { 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' 10 + } 11 + }, 12 + defaultVariants: { 13 + danger: false 14 + } 15 + }); 16 + 17 + export type DropdownItemVariants = VariantProps<typeof dropdownItem>; 18 + </script> 19 + 1 20 <script lang="ts"> 2 21 import { resolve } from '$app/paths'; 3 22 import type { Component, Snippet } from 'svelte'; ··· 6 25 interface Props { 7 26 href?: string; 8 27 icon?: Component<SvelteHTMLElements['svg']>; 9 - danger?: boolean; 28 + danger?: DropdownItemVariants['danger']; 10 29 onclick?: (event: MouseEvent) => void; 11 30 children: Snippet; 12 31 } 13 32 14 33 let { href, icon, danger = false, onclick, children }: Props = $props(); 34 + const classes = $derived(dropdownItem({ danger })); 15 35 16 36 const closeDropdown = getContext<(() => void) | undefined>('dropdown-close'); 17 37 ··· 22 42 </script> 23 43 24 44 {#if href} 25 - <a href={resolve(href as '/')} class="item" class:danger onclick={handleClick}> 45 + <a href={resolve(href as '/')} class={classes} onclick={handleClick}> 26 46 {#if icon} 27 47 {@const Glyph = icon} 28 48 <Glyph class="size-4" aria-hidden="true" /> ··· 30 50 {@render children()} 31 51 </a> 32 52 {:else} 33 - <button type="button" class="item" class:danger onclick={handleClick}> 53 + <button type="button" class={classes} onclick={handleClick}> 34 54 {#if icon} 35 55 {@const Glyph = icon} 36 56 <Glyph class="size-4" aria-hidden="true" /> ··· 38 58 {@render children()} 39 59 </button> 40 60 {/if} 41 - 42 - <style> 43 - .item { 44 - --dropdown-item-hover-bg: #f9fafb; 45 - display: flex; 46 - width: 100%; 47 - align-items: center; 48 - gap: 0.5rem; 49 - padding: 0.55rem 0.7rem; 50 - border: 0; 51 - background: transparent; 52 - text-align: left; 53 - text-decoration: none; 54 - color: inherit; 55 - cursor: pointer; 56 - transition: background-color 150ms; 57 - } 58 - 59 - .item:hover { 60 - background-color: var(--dropdown-item-hover-bg); 61 - text-decoration: none; 62 - } 63 - 64 - @media (prefers-color-scheme: dark) { 65 - .item { 66 - --dropdown-item-hover-bg: rgb(55 65 81 / 0.5); 67 - } 68 - } 69 - 70 - .danger { 71 - color: var(--color-danger); 72 - } 73 - 74 - .danger:hover { 75 - background-color: var(--color-danger-soft); 76 - } 77 - </style>