Monorepo for Tangled
0

Configure Feed

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

web: adopt semantic tokens and ui primitives

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

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

+591 -569
+1
.gitignore
··· 37 37 *.pem 38 38 *.key 39 39 .claude/ 40 + .playwright-mcp/
+160 -80
web/src/app.css
··· 1 - @import 'tailwindcss'; 1 + @import "tailwindcss"; 2 2 3 3 @theme { 4 - --font-sans: 'InterVariable', 'system-ui', sans-serif, ui-sans-serif; 4 + --font-sans: "InterVariable", "system-ui", sans-serif, ui-sans-serif; 5 5 --font-mono: 6 - 'IBMPlexMono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 7 - 'Courier New', monospace; 8 - --color-bg: #f1f5f9; 9 - --color-bg-auth: #ffffff; 10 - --color-surface: #ffffff; 11 - --color-surface-hover: #f9fafb; 12 - --color-surface-muted: #f3f4f6; 13 - --color-fg: #111827; 14 - --color-fg-strong: #030712; 15 - --color-fg-muted: #4b5563; 16 - --color-fg-subtle: #6b7280; 17 - --color-fg-faint: #9ca3af; 18 - --color-border: #e5e7eb; 6 + "IBMPlexMono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", 7 + "Courier New", monospace; 8 + 9 + /* ========================================================================= 10 + Semantic color tokens — synced from Figma "Design System" › semantic colors 11 + Names mirror the Figma variables verbatim (`/` → `-`). Light-mode values 12 + here; dark-mode overrides live in the prefers-color-scheme block below. 13 + ========================================================================= */ 14 + 15 + /* --- background --- */ 16 + --color-background-default: #ffffff; 17 + --color-background-subtle: #f9fafb; 18 + --color-background-inset: #f3f4f6; 19 + --color-background-muted: #e5e7eb; 20 + --color-background-canvas: #f3f4f6; 21 + --color-background-highlight: #fef08a; 22 + --color-background-success-subtle: #dcfce7; 23 + --color-background-success-subtle-hover: #bbf7d0; 24 + --color-background-success-emphasis: #16a34a; 25 + --color-background-success-emphasis-hover: #15803d; 26 + --color-background-danger-subtle: #ffe2e2; 27 + --color-background-danger-subtle-hover: #fecaca; 28 + --color-background-danger-emphasis: #fca5a5; 29 + --color-background-danger-emphasis-hover: #f87171; 30 + --color-background-warning-subtle: #fffbeb; 31 + --color-background-warning-subtle-hover: #fef3c7; 32 + --color-background-warning-emphasis: #f59e0b; 33 + --color-background-info-subtle: #eef2ff; 34 + --color-background-info-subtle-hover: #e0e7ff; 35 + --color-background-info-emphasis: #4f46e5; 36 + 37 + /* --- foreground --- */ 38 + --color-foreground-default: #111827; 39 + --color-foreground-muted: #4b5563; 40 + --color-foreground-subtle: #6b7280; 41 + --color-foreground-placeholder: #9ca3af; 42 + --color-foreground-disabled: #d1d5db; 43 + --color-foreground-on-emphasis: #ffffff; 44 + --color-foreground-highlight: #854d0e; 45 + --color-foreground-success: #16a34a; 46 + --color-foreground-success-strong: #166534; 47 + --color-foreground-success-disabled: #16a34a80; 48 + --color-foreground-danger: #dc2626; 49 + --color-foreground-danger-strong: #991b1b; 50 + --color-foreground-warning: #d97706; 51 + --color-foreground-warning-strong: #92400e; 52 + --color-foreground-info: #4338ca; 53 + --color-foreground-info-strong: #3730a3; 54 + --color-foreground-link-default: #111827; 55 + --color-foreground-link-hover: #374151; 56 + --color-foreground-link-disabled: #9ca3af; 57 + --color-foreground-link-danger: #dc2626; 58 + --color-foreground-link-danger-hover: #ef4444; 59 + --color-foreground-link-success: #16a34a; 60 + --color-foreground-link-success-hover: #22c55e; 61 + --color-foreground-link-warning: #d97706; 62 + --color-foreground-link-warning-hover: #f59e0b; 63 + --color-foreground-link-info: #4f46e5; 64 + --color-foreground-link-info-hover: #6366f1; 65 + 66 + /* --- border --- */ 67 + --color-border-default: #e5e7eb; 19 68 --color-border-subtle: #f3f4f6; 20 69 --color-border-strong: #d1d5db; 21 - --color-ring: #d1d5db; 22 - --color-raised-depth: #e5e7eb; 23 - --color-raised-accent-depth: rgb(17 24 39 / 0.25); 24 - --color-raised-accent-depth-active: rgb(17 24 39 / 0.5); 25 - --color-raised-depth-hover: #d1d5db; 26 - --color-success: #16a34a; 27 - --color-success-hover: #15803d; 28 - --color-success-border: #15803d; 29 - --color-success-fg: #ffffff; 30 - --color-primary: #4f46e5; 31 - --color-primary-hover: #4338ca; 32 - --color-primary-border: #4338ca; 33 - --color-primary-fg: #ffffff; 34 - --color-info: #3b82f6; 35 - --color-info-soft: #dbeafe; 36 - --color-danger: #dc2626; 37 - --color-danger-hover: #b91c1c; 38 - --color-danger-border: #b91c1c; 39 - --color-danger-fg: #ffffff; 40 - --color-danger-soft: #fef2f2; 70 + --color-border-disabled: #e5e7eb; 71 + --color-border-focus: #9ca3af; 72 + --color-border-focus-success: #22c55e; 73 + --color-border-focus-danger: #ef4444; 74 + --color-border-success: #15803d; 75 + --color-border-danger: #fca5a5; 76 + 77 + /* --- shadow (inner depth) --- */ 78 + --color-shadow-inner-strong: #d1d5db; 79 + --color-shadow-inner-strong-hover: #d1d5db; 80 + --color-shadow-inner-subtle: #e5e7eb; 81 + 41 82 --shadow-menu: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 42 83 } 43 84 44 85 @layer base { 45 86 @font-face { 46 - font-family: 'InterVariable'; 47 - src: url('/fonts/InterVariable.woff2') format('woff2-variations'); 87 + font-family: "InterVariable"; 88 + src: url("/fonts/InterVariable.woff2") format("woff2-variations"); 48 89 font-weight: 100 600; 49 90 font-style: normal; 50 91 font-display: swap; 51 92 } 52 93 53 94 @font-face { 54 - font-family: 'InterVariable'; 55 - src: url('/fonts/InterVariable-Italic.woff2') format('woff2-variations'); 95 + font-family: "InterVariable"; 96 + src: url("/fonts/InterVariable-Italic.woff2") format("woff2-variations"); 56 97 font-weight: 100 600; 57 98 font-style: italic; 58 99 font-display: swap; 59 100 } 60 101 61 102 @font-face { 62 - font-family: 'InterVariable'; 63 - src: url('/fonts/InterDisplay-Bold.woff2') format('woff2'); 103 + font-family: "InterVariable"; 104 + src: url("/fonts/InterDisplay-Bold.woff2") format("woff2"); 64 105 font-weight: bold; 65 106 font-style: normal; 66 107 font-display: swap; 67 108 } 68 109 69 110 @font-face { 70 - font-family: 'InterVariable'; 71 - src: url('/fonts/InterDisplay-BoldItalic.woff2') format('woff2'); 111 + font-family: "InterVariable"; 112 + src: url("/fonts/InterDisplay-BoldItalic.woff2") format("woff2"); 72 113 font-weight: bold; 73 114 font-style: italic; 74 115 font-display: swap; 75 116 } 76 117 77 118 @font-face { 78 - font-family: 'IBMPlexMono'; 79 - src: url('/fonts/IBMPlexMono-Regular.woff2') format('woff2'); 119 + font-family: "IBMPlexMono"; 120 + src: url("/fonts/IBMPlexMono-Regular.woff2") format("woff2"); 80 121 font-weight: normal; 81 122 font-style: normal; 82 123 font-display: swap; 83 124 } 84 125 85 126 @font-face { 86 - font-family: 'IBMPlexMono'; 87 - src: url('/fonts/IBMPlexMono-Italic.woff2') format('woff2'); 127 + font-family: "IBMPlexMono"; 128 + src: url("/fonts/IBMPlexMono-Italic.woff2") format("woff2"); 88 129 font-weight: normal; 89 130 font-style: italic; 90 131 font-display: swap; 91 132 } 92 133 93 134 @font-face { 94 - font-family: 'IBMPlexMono'; 95 - src: url('/fonts/IBMPlexMono-Bold.woff2') format('woff2'); 135 + font-family: "IBMPlexMono"; 136 + src: url("/fonts/IBMPlexMono-Bold.woff2") format("woff2"); 96 137 font-weight: bold; 97 138 font-style: normal; 98 139 font-display: swap; 99 140 } 100 141 101 142 @font-face { 102 - font-family: 'IBMPlexMono'; 103 - src: url('/fonts/IBMPlexMono-BoldItalic.woff2') format('woff2'); 143 + font-family: "IBMPlexMono"; 144 + src: url("/fonts/IBMPlexMono-BoldItalic.woff2") format("woff2"); 104 145 font-weight: bold; 105 146 font-style: italic; 106 147 font-display: swap; ··· 113 154 114 155 @media (prefers-color-scheme: dark) { 115 156 :root { 116 - --color-bg: #111827; 117 - --color-bg-auth: #111827; 118 - --color-surface: #1f2937; 119 - --color-surface-hover: #374151; 120 - --color-surface-muted: #374151; 121 - --color-fg: #f3f4f6; 122 - --color-fg-strong: #ffffff; 123 - --color-fg-muted: #d1d5db; 124 - --color-fg-subtle: #9ca3af; 125 - --color-border: #374151; 126 - --color-border-subtle: #374151; 127 - --color-border-strong: #4b5563; 128 - --color-ring: #4b5563; 129 - --color-raised-depth: #111827; 130 - --color-raised-depth-hover: #1f2937; 131 - --color-success: #15803d; 132 - --color-success-hover: #166534; 133 - --color-success-border: #16a34a; 134 - --color-primary: #6366f1; 135 - --color-primary-hover: #4f46e5; 136 - --color-primary-border: #4f46e5; 137 - --color-info: #60a5fa; 138 - --color-info-soft: rgb(30 58 138 / 0.3); 139 - --color-danger: #b91c1c; 140 - --color-danger-hover: #991b1b; 141 - --color-danger-border: #dc2626; 142 - --color-danger-soft: rgb(153 27 27 / 0.2); 157 + /* --- background (Dark) --- */ 158 + --color-background-default: #1f2937; 159 + --color-background-subtle: #374151; 160 + --color-background-inset: #374151; 161 + --color-background-muted: #4b5563; 162 + --color-background-canvas: #111827; 163 + --color-background-highlight: #713f12; 164 + --color-background-success-subtle: #14532d; 165 + --color-background-success-subtle-hover: #166534; 166 + --color-background-success-emphasis: #15803d; 167 + --color-background-success-emphasis-hover: #166534; 168 + --color-background-danger-subtle: #7f1d1d; 169 + --color-background-danger-subtle-hover: #991b1b; 170 + --color-background-danger-emphasis: #b91c1c; 171 + --color-background-danger-emphasis-hover: #991b1b; 172 + --color-background-warning-subtle: #78350f; 173 + --color-background-warning-subtle-hover: #92400e; 174 + --color-background-warning-emphasis: #d97706; 175 + --color-background-info-subtle: #312e81; 176 + --color-background-info-subtle-hover: #3730a3; 177 + --color-background-info-emphasis: #4f46e5; 178 + 179 + /* --- foreground (Dark) --- */ 180 + --color-foreground-default: #f3f4f6; 181 + --color-foreground-muted: #9ca3af; 182 + --color-foreground-subtle: #6b7280; 183 + --color-foreground-placeholder: #9ca3af; 184 + --color-foreground-disabled: #6b7280; 185 + --color-foreground-on-emphasis: #ffffff; 186 + --color-foreground-highlight: #fef08a; 187 + --color-foreground-success: #4ade80; 188 + --color-foreground-success-strong: #bbf7d0; 189 + --color-foreground-success-disabled: #15803d; 190 + --color-foreground-danger: #f87171; 191 + --color-foreground-danger-strong: #fecaca; 192 + --color-foreground-warning: #fbbf24; 193 + --color-foreground-warning-strong: #fde68a; 194 + --color-foreground-info: #818cf8; 195 + --color-foreground-info-strong: #c7d2fe; 196 + --color-foreground-link-default: #f3f4f6; 197 + --color-foreground-link-hover: #d1d5db; 198 + --color-foreground-link-disabled: #6b7280; 199 + --color-foreground-link-danger: #ef4444; 200 + --color-foreground-link-danger-hover: #f87171; 201 + --color-foreground-link-success: #22c55e; 202 + --color-foreground-link-success-hover: #4ade80; 203 + --color-foreground-link-warning: #f59e0b; 204 + --color-foreground-link-warning-hover: #fbbf24; 205 + --color-foreground-link-info: #6366f1; 206 + --color-foreground-link-info-hover: #818cf8; 207 + 208 + /* --- border (Dark) --- */ 209 + --color-border-default: #374151; 210 + --color-border-subtle: #1f2937; 211 + --color-border-strong: #6b7280; 212 + --color-border-disabled: #4b5563; 213 + --color-border-focus: #d1d5db; 214 + --color-border-focus-success: #22c55e; 215 + --color-border-focus-danger: #ef4444; 216 + --color-border-success: #15803d; 217 + --color-border-danger: #991b1b; 218 + 219 + /* --- shadow (Dark) --- */ 220 + --color-shadow-inner-strong: #030712; 221 + --color-shadow-inner-strong-hover: #1f2937; 222 + --color-shadow-inner-subtle: #111827; 143 223 } 144 224 } 145 225 146 226 body { 147 - @apply flex min-h-screen flex-col bg-bg text-fg transition-colors duration-200; 227 + @apply flex min-h-screen flex-col bg-background-canvas text-foreground-default transition-colors duration-200; 148 228 font-size: 14px; 149 229 } 150 230 151 231 a { 152 - @apply text-fg no-underline hover:text-fg-muted; 232 + @apply text-foreground-default no-underline hover:text-foreground-muted; 153 233 } 154 234 }
+22 -22
web/src/lib/components/home/MarketingHome.svelte
··· 1 1 <script lang="ts"> 2 - import ArrowRight from '$icon/arrow-right'; 2 + import ArrowRight from "$icon/arrow-right"; 3 3 4 4 const activityCards = [ 5 - { left: 'created', right: 'new-knot', x: 'left-[8%]', y: 'top-[28%]', width: 'w-48' }, 6 - { left: 'starred', right: 'tiny-compiler', x: 'left-[22%]', y: 'top-[62%]', width: 'w-72' }, 7 - { left: 'opened', right: 'stacked-prs', x: 'left-[48%]', y: 'top-[45%]', width: 'w-80' }, 8 - { left: 'merged', right: 'microvm-ci', x: 'left-[66%]', y: 'top-[20%]', width: 'w-72' }, 9 - { left: 'followed', right: 'self-hosted', x: 'left-[75%]', y: 'top-[58%]', width: 'w-64' } 5 + { left: "created", right: "new-knot", x: "left-[8%]", y: "top-[28%]", width: "w-48" }, 6 + { left: "starred", right: "tiny-compiler", x: "left-[22%]", y: "top-[62%]", width: "w-72" }, 7 + { left: "opened", right: "stacked-prs", x: "left-[48%]", y: "top-[45%]", width: "w-80" }, 8 + { left: "merged", right: "microvm-ci", x: "left-[66%]", y: "top-[20%]", width: "w-72" }, 9 + { left: "followed", right: "self-hosted", x: "left-[75%]", y: "top-[58%]", width: "w-64" } 10 10 ]; 11 11 12 12 const ticks = Array.from({ length: 160 }, (_, index) => index); ··· 14 14 15 15 {#snippet marketingButton(label: string, showArrow: boolean, className: string)} 16 16 <button 17 - class="inline-flex cursor-pointer items-center justify-center gap-2 rounded border border-success-border bg-success px-4 py-2 text-base font-medium text-success-fg hover:bg-success-hover {className}" 17 + class="inline-flex cursor-pointer items-center justify-center gap-2 rounded border border-border-success bg-background-success-emphasis px-4 py-2 text-base font-medium text-foreground-on-emphasis hover:bg-background-success-emphasis-hover {className}" 18 18 type="submit" 19 19 > 20 20 {label} ··· 39 39 name="id" 40 40 required 41 41 placeholder="Enter your email" 42 - class="min-w-0 flex-1 rounded border border-border-strong bg-surface px-3 py-2 text-base text-fg placeholder:text-fg-faint focus:border-fg-subtle focus:outline-none" 42 + class="min-w-0 flex-1 rounded border border-border-strong bg-background-default px-3 py-2 text-base text-foreground-default placeholder:text-foreground-placeholder focus:border-foreground-subtle focus:outline-none" 43 43 /> 44 44 {@render marketingButton(label, showArrow, buttonClass)} 45 45 </form> ··· 48 48 <section class="overflow-hidden px-6 pb-24 pt-28 md:pb-32 md:pt-36"> 49 49 <div class="mx-auto grid max-w-screen-xl grid-cols-1 items-center gap-14 md:grid-cols-2"> 50 50 <h1 51 - class="text-6xl font-bold leading-[0.98] tracking-[-0.05em] text-fg-strong md:text-7xl lg:text-8xl" 51 + class="text-6xl font-bold leading-[0.98] tracking-[-0.05em] text-foreground-default md:text-7xl lg:text-8xl" 52 52 > 53 53 tightly-knit<br />social coding. 54 54 </h1> 55 55 56 - <div class="max-w-2xl space-y-6 text-xl leading-8 text-fg"> 56 + <div class="max-w-2xl space-y-6 text-xl leading-8 text-foreground-default"> 57 57 <p>The next-generation social coding platform.</p> 58 58 <p> 59 59 We envision a place where developers have ownership of their code, communities can freely 60 60 self-govern and most importantly, coding can be social and fun again. 61 61 </p> 62 - {@render signupForm('hero-email', 'Join now', true, 'flex max-w-lg gap-2', '')} 62 + {@render signupForm("hero-email", "Join now", true, "flex max-w-lg gap-2", "")} 63 63 </div> 64 64 </div> 65 65 ··· 69 69 <span class="h-4 w-px bg-border-strong" class:h-7={tick % 24 === 0}></span> 70 70 {/each} 71 71 </div> 72 - <div class="absolute inset-x-0 bottom-14 h-px bg-border"></div> 72 + <div class="absolute inset-x-0 bottom-14 h-px bg-border-default"></div> 73 73 {#each activityCards as card, index (card.right)} 74 74 <div 75 - class={`activity-card absolute ${card.x} ${card.y} ${card.width} overflow-hidden rounded border border-border-strong bg-surface/95 text-sm text-fg-muted shadow-2xl`} 75 + class={`activity-card absolute ${card.x} ${card.y} ${card.width} overflow-hidden rounded border border-border-strong bg-background-default/95 text-sm text-foreground-muted shadow-2xl`} 76 76 > 77 77 <div class="flex items-center gap-2 border-l border-border-strong px-4 py-3"> 78 78 <span class="size-5 rounded-full bg-orange-300/90"></span> 79 - <span class="text-fg-subtle">{card.left}</span> 80 - <span class="font-medium text-fg">{card.right}</span> 79 + <span class="text-foreground-subtle">{card.left}</span> 80 + <span class="font-medium text-foreground-default">{card.right}</span> 81 81 </div> 82 - <div class="mx-auto h-20 w-px bg-border" class:hidden={index === 0}></div> 82 + <div class="mx-auto h-20 w-px bg-border-default" class:hidden={index === 0}></div> 83 83 </div> 84 84 {/each} 85 85 </div> ··· 87 87 88 88 <section class="px-6 py-28 md:py-40"> 89 89 <div class="mx-auto flex max-w-3xl flex-col items-center text-center"> 90 - <h2 class="text-5xl font-bold tracking-[-0.04em] text-fg-strong md:text-6xl"> 90 + <h2 class="text-5xl font-bold tracking-[-0.04em] text-foreground-default md:text-6xl"> 91 91 Stay in the loop 92 92 </h2> 93 - <p class="mt-6 max-w-2xl text-xl leading-8 text-fg-muted"> 93 + <p class="mt-6 max-w-2xl text-xl leading-8 text-foreground-muted"> 94 94 We've got a newsletter. Punch in your email to stay updated on what we're shipping at Tangled. 95 95 </p> 96 96 {@render signupForm( 97 - 'newsletter-email', 98 - 'Subscribe', 97 + "newsletter-email", 98 + "Subscribe", 99 99 false, 100 - 'mt-8 flex w-full max-w-lg rounded border border-border-strong bg-surface p-2', 101 - 'ml-2' 100 + "mt-8 flex w-full max-w-lg rounded border border-border-strong bg-background-default p-2", 101 + "ml-2" 102 102 )} 103 103 </div> 104 104 </section>
+1 -1
web/src/lib/components/profile/ActivityStub.svelte
··· 1 - <div class="flex min-h-32 items-center justify-center text-base text-fg-subtle italic"> 1 + <div class="flex min-h-32 items-center justify-center text-base text-foreground-subtle italic"> 2 2 Activity is not available yet. 3 3 </div>
+2 -2
web/src/lib/components/profile/EmptyState.svelte
··· 1 1 <script lang="ts"> 2 - import type { Snippet } from 'svelte'; 2 + import type { Snippet } from "svelte"; 3 3 4 4 interface Props { 5 5 message: string; ··· 10 10 </script> 11 11 12 12 <div 13 - class="flex items-center justify-center border border-border p-12 text-base text-fg-subtle italic" 13 + class="flex items-center justify-center border border-border-default p-12 text-base text-foreground-subtle italic" 14 14 > 15 15 <span class="flex items-center gap-2">{message}{@render children?.()}</span> 16 16 </div>
+15 -15
web/src/lib/components/profile/FollowButton.svelte
··· 1 1 <script lang="ts"> 2 - import Button from '$lib/components/ui/Button.svelte'; 3 - import UserRoundPlus from '$icon/user-round-plus'; 4 - import UserRoundMinus from '$icon/user-round-minus'; 5 - import { getAuth } from '$lib/auth.svelte'; 6 - import { createFollow, deleteFollow } from '$lib/api/graph'; 7 - import { getProfileCounts } from './counts.svelte'; 8 - import type { FollowChange } from './types'; 9 - import { createOptimisticRelation } from '$lib/optimistic.svelte'; 2 + import Button from "$lib/components/ui/Button.svelte"; 3 + import UserRoundPlus from "$icon/user-round-plus"; 4 + import UserRoundMinus from "$icon/user-round-minus"; 5 + import { getAuth } from "$lib/auth.svelte"; 6 + import { createFollow, deleteFollow } from "$lib/api/graph"; 7 + import { getProfileCounts } from "./counts.svelte"; 8 + import type { FollowChange } from "./types"; 9 + import { createOptimisticRelation } from "$lib/optimistic.svelte"; 10 10 11 11 interface Props { 12 12 profileDid: string; 13 13 initialRkey?: string | null; 14 - size?: 'sm' | 'md'; 14 + size?: "sm" | "md"; 15 15 onCommit?: (change: FollowChange) => void; 16 16 } 17 17 18 - let { profileDid, initialRkey, size = 'sm', onCommit }: Props = $props(); 18 + let { profileDid, initialRkey, size = "sm", onCommit }: Props = $props(); 19 19 20 20 const auth = getAuth(); 21 21 const profileCounts = getProfileCounts(); 22 22 const signedIn = $derived(Boolean(auth.currentDid)); 23 23 const isSelf = $derived(auth.currentDid === profileDid); 24 24 const relation = createOptimisticRelation({ 25 - key: () => `${auth.currentDid ?? ''}:${profileDid}`, 25 + key: () => `${auth.currentDid ?? ""}:${profileDid}`, 26 26 loadedRkey: () => initialRkey 27 27 }); 28 28 ··· 30 30 const following = $derived(relation.active); 31 31 32 32 const commit = (change: FollowChange) => { 33 - profileCounts?.adjust(change.subjectDid, 'followers', change.delta); 34 - profileCounts?.adjust(change.viewerDid, 'following', change.delta); 33 + profileCounts?.adjust(change.subjectDid, "followers", change.delta); 34 + profileCounts?.adjust(change.viewerDid, "following", change.delta); 35 35 onCommit?.(change); 36 36 }; 37 37 ··· 90 90 {:else} 91 91 <UserRoundPlus class="size-4" aria-hidden="true" /> 92 92 {/if} 93 - <span>{following ? 'Unfollow' : 'Follow'}</span> 93 + <span>{following ? "Unfollow" : "Follow"}</span> 94 94 </Button> 95 95 {#if relation.failed} 96 - <p class="mt-1 text-xs text-danger">Something went wrong. Try again.</p> 96 + <p class="mt-1 text-xs text-foreground-danger">Something went wrong. Try again.</p> 97 97 {/if} 98 98 </div> 99 99 {/if}
+14 -14
web/src/lib/components/profile/FollowCard.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from '$app/paths'; 3 - import Avatar from '$lib/components/ui/Avatar.svelte'; 4 - import Users from '$icon/users'; 5 - import FollowButton from './FollowButton.svelte'; 6 - import type { FollowChange, PersonData } from './types'; 7 - import { createOptimisticCount } from '$lib/optimistic.svelte'; 2 + import { resolve } from "$app/paths"; 3 + import Avatar from "$lib/components/ui/Avatar.svelte"; 4 + import Users from "$icon/users"; 5 + import FollowButton from "./FollowButton.svelte"; 6 + import type { FollowChange, PersonData } from "./types"; 7 + import { createOptimisticCount } from "$lib/optimistic.svelte"; 8 8 9 9 let { person }: { person: PersonData } = $props(); 10 10 ··· 24 24 <div class="flex min-w-0 flex-1 flex-col gap-2 md:flex-row md:items-center md:justify-between"> 25 25 <div class="flex min-h-0 flex-1 flex-col justify-around"> 26 26 <a 27 - href={resolve(`/${person.handle}` as '/')} 28 - class="max-w-full overflow-hidden text-ellipsis whitespace-nowrap font-bold text-fg-strong hover:underline" 27 + href={resolve(`/${person.handle}` as "/")} 28 + class="max-w-full overflow-hidden text-ellipsis whitespace-nowrap font-bold text-foreground-default hover:underline" 29 29 > 30 30 {person.handle} 31 31 </a> 32 32 {#if person.description} 33 - <p class="break-words pb-2 text-sm text-fg-muted md:pb-2">{person.description}</p> 33 + <p class="break-words pb-2 text-sm text-foreground-muted md:pb-2">{person.description}</p> 34 34 {/if} 35 35 <div 36 - class="my-2 flex max-w-full items-center gap-2 overflow-hidden text-ellipsis whitespace-nowrap text-sm text-fg-subtle" 36 + class="my-2 flex max-w-full items-center gap-2 overflow-hidden text-ellipsis whitespace-nowrap text-sm text-foreground-subtle" 37 37 > 38 38 <Users class="size-4 shrink-0" /> 39 39 <span 40 40 ><a 41 - href={resolve(`/${person.handle}?tab=followers` as '/')} 42 - class="hover:text-fg hover:underline">{followerCount.value} followers</a 41 + href={resolve(`/${person.handle}?tab=followers` as "/")} 42 + class="hover:text-foreground-default hover:underline">{followerCount.value} followers</a 43 43 ></span 44 44 > 45 45 <span class="select-none after:content-['·']"></span> 46 46 <span 47 47 ><a 48 - href={resolve(`/${person.handle}?tab=following` as '/')} 49 - class="hover:text-fg hover:underline">{person.following} following</a 48 + href={resolve(`/${person.handle}?tab=following` as "/")} 49 + class="hover:text-foreground-default hover:underline">{person.following} following</a 50 50 ></span 51 51 > 52 52 </div>
+13 -13
web/src/lib/components/profile/ProfileCard.svelte
··· 1 1 <script lang="ts"> 2 - import FollowButton from './FollowButton.svelte'; 3 - import ProfileEditForm from './ProfileEditForm.svelte'; 4 - import FollowerFollowing from './FollowerFollowing.svelte'; 5 - import MapPin from '$icon/map-pin'; 6 - import Link from '$icon/link'; 7 - import Pencil from '$icon/pencil'; 8 - import Rss from '$icon/rss'; 9 - import UserRound from '$icon/user-round'; 10 - import Button from '$lib/components/ui/Button.svelte'; 11 - import { getAuth } from '$lib/auth.svelte'; 12 - import type { ProfileRecord } from '$lib/api/records'; 2 + import FollowButton from "./FollowButton.svelte"; 3 + import ProfileEditForm from "./ProfileEditForm.svelte"; 4 + import FollowerFollowing from "./FollowerFollowing.svelte"; 5 + import MapPin from "$icon/map-pin"; 6 + import Link from "$icon/link"; 7 + import Pencil from "$icon/pencil"; 8 + import Rss from "$icon/rss"; 9 + import UserRound from "$icon/user-round"; 10 + import Button from "$lib/components/ui/Button.svelte"; 11 + import { getAuth } from "$lib/auth.svelte"; 12 + import type { ProfileRecord } from "$lib/api/records"; 13 13 14 14 interface Identity { 15 15 did: string; ··· 50 50 {:else} 51 51 <span class="absolute inset-0 h-full w-full p-2" aria-hidden="true"> 52 52 <span 53 - class="flex h-full w-full items-center justify-center rounded-full bg-surface-muted text-fg-faint" 53 + class="flex h-full w-full items-center justify-center rounded-full bg-background-inset text-foreground-placeholder" 54 54 > 55 55 <UserRound class="size-1/2" /> 56 56 </span> ··· 68 68 {identity.handle} 69 69 </h1> 70 70 {#if shown?.pronouns} 71 - <p class="text-fg-subtle">{shown.pronouns}</p> 71 + <p class="text-foreground-subtle">{shown.pronouns}</p> 72 72 {/if} 73 73 </div> 74 74 <div class="md:hidden">
+18 -18
web/src/lib/components/profile/ProfileEditForm.svelte
··· 1 1 <script lang="ts"> 2 - import { untrack } from 'svelte'; 3 - import Check from '$icon/check'; 4 - import Link from '$icon/link'; 5 - import MapPin from '$icon/map-pin'; 6 - import X from '$icon/x'; 7 - import Button from '$lib/components/ui/Button.svelte'; 8 - import { getAuth } from '$lib/auth.svelte'; 9 - import { putProfile } from '$lib/api/profile'; 10 - import type { ProfileRecord } from '$lib/api/records'; 2 + import { untrack } from "svelte"; 3 + import Check from "$icon/check"; 4 + import Link from "$icon/link"; 5 + import MapPin from "$icon/map-pin"; 6 + import X from "$icon/x"; 7 + import Button from "$lib/components/ui/Button.svelte"; 8 + import { getAuth } from "$lib/auth.svelte"; 9 + import { putProfile } from "$lib/api/profile"; 10 + import type { ProfileRecord } from "$lib/api/records"; 11 11 12 12 interface Props { 13 13 profile: ProfileRecord | null; ··· 21 21 22 22 // seed once; the profile prop can't change while the form is open. 23 23 const seed = untrack(() => profile); 24 - let description = $state(seed?.description ?? ''); 25 - let pronouns = $state(seed?.pronouns ?? ''); 26 - let location = $state(seed?.location ?? ''); 24 + let description = $state(seed?.description ?? ""); 25 + let pronouns = $state(seed?.pronouns ?? ""); 26 + let location = $state(seed?.location ?? ""); 27 27 let bluesky = $state(seed?.bluesky ?? false); 28 28 let links = $state( 29 29 Array.from({ length: 5 }, (_, index) => ({ 30 - value: (seed?.links?.[index] as string | undefined) ?? '' 30 + value: (seed?.links?.[index] as string | undefined) ?? "" 31 31 })) 32 32 ); 33 33 let busy = $state(false); 34 34 let error = $state<string | null>(null); 35 35 36 36 const inputClass = 37 - 'w-full rounded border border-border bg-surface px-2 py-1 outline-none focus:border-border-strong focus:ring-1 focus:ring-border-strong'; 37 + "w-full rounded border border-border-default bg-background-default px-2 py-1 outline-none focus:border-border-strong focus:ring-1 focus:ring-border-strong"; 38 38 39 39 const save = async (event: SubmitEvent) => { 40 40 event.preventDefault(); ··· 46 46 // put replaces the whole record, so carry unedited fields (avatar, pins, stats). 47 47 const record: ProfileRecord = { 48 48 ...profile, 49 - $type: 'sh.tangled.actor.profile', 49 + $type: "sh.tangled.actor.profile", 50 50 bluesky, 51 51 description: description.trim() || undefined, 52 52 pronouns: pronouns.trim() || undefined, 53 53 location: location.trim() || undefined, 54 - links: cleanLinks.length > 0 ? (cleanLinks as ProfileRecord['links']) : undefined 54 + links: cleanLinks.length > 0 ? (cleanLinks as ProfileRecord["links"]) : undefined 55 55 }; 56 56 try { 57 57 await putProfile(agent, record); 58 58 onSaved(record); 59 59 } catch { 60 - error = 'Could not save profile. Try again.'; 60 + error = "Could not save profile. Try again."; 61 61 } finally { 62 62 busy = false; 63 63 } ··· 114 114 </div> 115 115 116 116 {#if error} 117 - <p class="text-xs text-danger">{error}</p> 117 + <p class="text-xs text-foreground-danger">{error}</p> 118 118 {/if} 119 119 120 120 <div class="flex items-center justify-between gap-2">
+22 -20
web/src/lib/components/profile/ProfileTabs.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from '$app/paths'; 3 - import SquareChartGantt from '$icon/square-chart-gantt'; 4 - import BookMarked from '$icon/book-marked'; 5 - import Star from '$icon/star'; 6 - import LineSquiggle from '$icon/line-squiggle'; 7 - import Shield from '$icon/shield'; 8 - import type { Component } from 'svelte'; 9 - import type { SvelteHTMLElements } from 'svelte/elements'; 10 - import type { ProfileCounts } from './types'; 2 + import { resolve } from "$app/paths"; 3 + import SquareChartGantt from "$icon/square-chart-gantt"; 4 + import BookMarked from "$icon/book-marked"; 5 + import Star from "$icon/star"; 6 + import LineSquiggle from "$icon/line-squiggle"; 7 + import Shield from "$icon/shield"; 8 + import type { Component } from "svelte"; 9 + import type { SvelteHTMLElements } from "svelte/elements"; 10 + import type { ProfileCounts } from "./types"; 11 11 12 12 interface Props { 13 13 handle: string; ··· 20 20 interface TabDef { 21 21 id: string; 22 22 label: string; 23 - icon: Component<SvelteHTMLElements['svg']>; 23 + icon: Component<SvelteHTMLElements["svg"]>; 24 24 count?: number; 25 25 } 26 26 27 27 const tabs = $derived<TabDef[]>([ 28 - { id: 'overview', label: 'Overview', icon: SquareChartGantt }, 29 - { id: 'repos', label: 'Repositories', icon: BookMarked, count: counts.repos }, 30 - { id: 'starred', label: 'Starred', icon: Star, count: counts.stars }, 31 - { id: 'strings', label: 'Strings', icon: LineSquiggle, count: counts.strings }, 32 - { id: 'vouches', label: 'Vouches', icon: Shield, count: counts.vouches } 28 + { id: "overview", label: "Overview", icon: SquareChartGantt }, 29 + { id: "repos", label: "Repositories", icon: BookMarked, count: counts.repos }, 30 + { id: "starred", label: "Starred", icon: Star, count: counts.stars }, 31 + { id: "strings", label: "Strings", icon: LineSquiggle, count: counts.strings }, 32 + { id: "vouches", label: "Vouches", icon: Shield, count: counts.vouches } 33 33 ]); 34 34 35 35 const hrefFor = (id: string) => 36 - resolve((id === 'overview' ? `/${handle}` : `/${handle}?tab=${id}`) as '/'); 36 + resolve((id === "overview" ? `/${handle}` : `/${handle}?tab=${id}`) as "/"); 37 37 </script> 38 38 39 39 <nav class="w-full overflow-x-auto overflow-y-hidden pl-4" aria-label="profile sections"> ··· 44 44 <a 45 45 href={hrefFor(tab.id)} 46 46 class="group relative -mr-px no-underline hover:no-underline" 47 - aria-current={isActive ? 'page' : undefined} 47 + aria-current={isActive ? "page" : undefined} 48 48 > 49 49 <div 50 - class={`relative mr-1 min-w-[80px] rounded-t px-4 py-1 text-center whitespace-nowrap text-fg-strong ${ 51 - isActive ? '-mb-px bg-surface font-medium' : 'group-hover:bg-surface-muted/25' 50 + class={`relative mr-1 min-w-[80px] rounded-t px-4 py-1 text-center whitespace-nowrap text-foreground-default ${ 51 + isActive 52 + ? "-mb-px bg-background-default font-medium" 53 + : "group-hover:bg-background-inset/25" 52 54 }`} 53 55 > 54 56 <span class="flex items-center justify-center"> 55 57 <Glyph class="mr-2 h-4 w-4" aria-hidden="true" /> 56 58 {tab.label} 57 59 {#if tab.count} 58 - <span class="ml-1 rounded bg-surface-muted px-1 text-sm">{tab.count}</span> 60 + <span class="ml-1 rounded bg-background-inset px-1 text-sm">{tab.count}</span> 59 61 {/if} 60 62 </span> 61 63 </div>
+12 -10
web/src/lib/components/profile/StringCard.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from '$app/paths'; 3 - import { compactRelativeTime } from '$lib/format'; 4 - import type { StringCardData } from './types'; 2 + import { resolve } from "$app/paths"; 3 + import { compactRelativeTime } from "$lib/format"; 4 + import type { StringCardData } from "./types"; 5 5 6 6 let { entry }: { entry: StringCardData } = $props(); 7 7 </script> 8 8 9 9 <div 10 - class="flex min-h-32 flex-col gap-1 rounded border border-border bg-surface px-6 py-4 shadow-sm" 10 + class="flex min-h-32 flex-col gap-1 rounded border border-border-default bg-background-default px-6 py-4 shadow-sm" 11 11 > 12 - <div class="flex items-center justify-between font-medium text-fg-strong"> 12 + <div class="flex items-center justify-between font-medium text-foreground-default"> 13 13 <div class="mr-2 flex min-w-0 flex-1 items-center"> 14 14 <a 15 - href={resolve(`/${entry.ownerHandle}/strings/${entry.rkey}` as '/')} 16 - class="min-w-0 truncate font-bold text-fg-strong no-underline hover:underline" 15 + href={resolve(`/${entry.ownerHandle}/strings/${entry.rkey}` as "/")} 16 + class="min-w-0 truncate font-bold text-foreground-default no-underline hover:underline" 17 17 > 18 18 {entry.filename} 19 19 </a> ··· 21 21 </div> 22 22 23 23 {#if entry.description} 24 - <p class="line-clamp-2 text-sm text-fg-muted">{entry.description}</p> 24 + <p class="line-clamp-2 text-sm text-foreground-muted">{entry.description}</p> 25 25 {/if} 26 26 27 - <div class="mt-auto flex flex-wrap items-center gap-4 pt-2 font-mono text-xs text-fg-subtle"> 27 + <div 28 + class="mt-auto flex flex-wrap items-center gap-4 pt-2 font-mono text-xs text-foreground-subtle" 29 + > 28 30 <span class="shrink-0" 29 - >{entry.lines} line{entry.lines === 1 ? '' : 's'} &middot; {compactRelativeTime( 31 + >{entry.lines} line{entry.lines === 1 ? "" : "s"} &middot; {compactRelativeTime( 30 32 entry.createdAt 31 33 )}</span 32 34 >
+15 -13
web/src/lib/components/profile/VouchCard.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from '$app/paths'; 3 - import Avatar from '$lib/components/ui/Avatar.svelte'; 4 - import ShieldCheck from '$icon/shield-check'; 5 - import ShieldAlert from '$icon/shield-alert'; 6 - import { relativeTime } from '$lib/format'; 7 - import type { VouchData } from './types'; 2 + import { resolve } from "$app/paths"; 3 + import Avatar from "$lib/components/ui/Avatar.svelte"; 4 + import ShieldCheck from "$icon/shield-check"; 5 + import ShieldAlert from "$icon/shield-alert"; 6 + import { relativeTime } from "$lib/format"; 7 + import type { VouchData } from "./types"; 8 8 9 9 let { vouch }: { vouch: VouchData } = $props(); 10 - const denounce = $derived(vouch.kind === 'denounce'); 10 + const denounce = $derived(vouch.kind === "denounce"); 11 11 </script> 12 12 13 - <article class="rounded border border-border bg-surface px-6 py-4 shadow-sm"> 13 + <article class="rounded border border-border-default bg-background-default px-6 py-4 shadow-sm"> 14 14 <div class="flex items-center gap-4"> 15 15 <Avatar src={vouch.avatar} handle={vouch.handle} size="size-12" /> 16 16 <div class="min-w-0 flex-grow"> 17 17 <a 18 - href={resolve(`/${vouch.handle}` as '/')} 19 - class="block truncate font-medium text-fg-strong" 18 + href={resolve(`/${vouch.handle}` as "/")} 19 + class="block truncate font-medium text-foreground-default" 20 20 > 21 21 {vouch.handle} 22 22 </a> 23 - <span class={`flex items-center gap-1 text-sm ${denounce ? 'text-danger' : 'text-success'}`}> 23 + <span 24 + class={`flex items-center gap-1 text-sm ${denounce ? "text-foreground-danger" : "text-foreground-success"}`} 25 + > 24 26 {#if denounce} 25 27 <ShieldAlert class="size-3.5" aria-hidden="true" />denounced 26 28 {:else} 27 29 <ShieldCheck class="size-3.5" aria-hidden="true" />vouched 28 30 {/if} 29 - <span class="text-fg-subtle">&middot; {relativeTime(vouch.createdAt)}</span> 31 + <span class="text-foreground-subtle">&middot; {relativeTime(vouch.createdAt)}</span> 30 32 </span> 31 33 </div> 32 34 </div> 33 35 34 36 {#if vouch.reason} 35 - <p class="mt-3 text-sm text-fg-muted">{vouch.reason}</p> 37 + <p class="mt-3 text-sm text-foreground-muted">{vouch.reason}</p> 36 38 {/if} 37 39 </article>
+13 -13
web/src/lib/components/profile/tabs/RepoListTab.svelte
··· 1 1 <script lang="ts"> 2 - import { page } from '$app/state'; 3 - import { resolve } from '$app/paths'; 4 - import RepoCard from '$lib/components/repo/RepoCard.svelte'; 5 - import EmptyState from '../EmptyState.svelte'; 6 - import type { RepoCardData } from '../types'; 7 - import Search from '$icon/search'; 8 - import X from '$icon/x'; 2 + import { page } from "$app/state"; 3 + import { resolve } from "$app/paths"; 4 + import RepoCard from "$lib/components/repo/RepoCard.svelte"; 5 + import EmptyState from "../EmptyState.svelte"; 6 + import type { RepoCardData } from "../types"; 7 + import Search from "$icon/search"; 8 + import X from "$icon/x"; 9 9 10 10 let { repos }: { repos: RepoCardData[] } = $props(); 11 - let searchQuery = $derived(page.url.searchParams.get('q') ?? ''); 11 + let searchQuery = $derived(page.url.searchParams.get("q") ?? ""); 12 12 </script> 13 13 14 14 <section> ··· 17 17 <input type="hidden" name="tab" value="repos" /> 18 18 <div class="relative flex flex-1"> 19 19 <input 20 - class="peer flex-1 rounded-l border border-border bg-surface py-1.5 pl-3 pr-10 outline-none focus:border-border-strong focus:ring-1 focus:ring-border-strong" 20 + class="peer flex-1 rounded-l border border-border-default bg-background-default py-1.5 pl-3 pr-10 outline-none focus:border-border-strong focus:ring-1 focus:ring-border-strong" 21 21 type="text" 22 22 name="q" 23 23 value={searchQuery} 24 24 placeholder="Search repositories..." 25 25 /> 26 26 <a 27 - href={resolve(`${page.url.pathname}?tab=repos` as '/')} 28 - class="absolute right-3 top-1/2 hidden -translate-y-1/2 text-fg-subtle hover:text-fg peer-[&:not(:placeholder-shown)]:block" 27 + href={resolve(`${page.url.pathname}?tab=repos` as "/")} 28 + class="absolute right-3 top-1/2 hidden -translate-y-1/2 text-foreground-subtle hover:text-foreground-default peer-[&:not(:placeholder-shown)]:block" 29 29 > 30 30 <X class="size-4" /> 31 31 </a> 32 32 </div> 33 33 <button 34 34 type="submit" 35 - class="rounded-r border border-l-0 border-border bg-surface p-2 text-fg-subtle hover:bg-surface-hover hover:text-fg" 35 + class="rounded-r border border-l-0 border-border-default bg-background-default p-2 text-foreground-subtle hover:bg-background-subtle hover:text-foreground-default" 36 36 > 37 37 <Search class="size-4" /> 38 38 </button> ··· 41 41 42 42 {#if repos.length === 0} 43 43 <EmptyState 44 - message={searchQuery ? `No repositories match "${searchQuery}".` : 'No repositories found.'} 44 + message={searchQuery ? `No repositories match "${searchQuery}".` : "No repositories found."} 45 45 /> 46 46 {:else} 47 47 <div class="flex flex-col gap-4">
+8 -8
web/src/lib/components/profile/tabs/StarredTab.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from '$app/paths'; 3 - import RepoCard from '$lib/components/repo/RepoCard.svelte'; 4 - import EmptyState from '../EmptyState.svelte'; 5 - import type { StarData } from '../types'; 2 + import { resolve } from "$app/paths"; 3 + import RepoCard from "$lib/components/repo/RepoCard.svelte"; 4 + import EmptyState from "../EmptyState.svelte"; 5 + import type { StarData } from "../types"; 6 6 7 7 let { stars }: { stars: StarData[] } = $props(); 8 8 </script> ··· 14 14 {:else} 15 15 <div class="flex flex-col gap-4"> 16 16 {#each stars as star (star.uri)} 17 - {#if star.kind === 'repo'} 17 + {#if star.kind === "repo"} 18 18 <RepoCard repo={star.repo} /> 19 19 {:else} 20 20 <div 21 - class="flex items-center gap-2 rounded border border-border bg-surface px-6 py-4 shadow-sm" 21 + class="flex items-center gap-2 rounded border border-border-default bg-background-default px-6 py-4 shadow-sm" 22 22 > 23 23 <a 24 - href={resolve(`/${star.ownerHandle}/strings/${star.rkey}` as '/')} 25 - class="truncate font-bold text-fg-strong no-underline hover:underline" 24 + href={resolve(`/${star.ownerHandle}/strings/${star.rkey}` as "/")} 25 + class="truncate font-bold text-foreground-default no-underline hover:underline" 26 26 > 27 27 {star.ownerHandle}/{star.rkey} 28 28 </a>
+18 -16
web/src/lib/components/repo/RepoCard.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from '$app/paths'; 3 - import BookMarked from '$icon/book-marked'; 4 - import Star from '$icon/star'; 5 - import GitFork from '$icon/git-fork'; 6 - import CircleDot from '$icon/circle-dot'; 7 - import GitPullRequest from '$icon/git-pull-request'; 8 - import StarButton from './StarButton.svelte'; 9 - import type { RepoCardData } from '$lib/components/profile/types'; 2 + import { resolve } from "$app/paths"; 3 + import BookMarked from "$icon/book-marked"; 4 + import Star from "$icon/star"; 5 + import GitFork from "$icon/git-fork"; 6 + import CircleDot from "$icon/circle-dot"; 7 + import GitPullRequest from "$icon/git-pull-request"; 8 + import StarButton from "./StarButton.svelte"; 9 + import type { RepoCardData } from "$lib/components/profile/types"; 10 10 let { 11 11 repo, 12 12 starButton = true, ··· 24 24 </script> 25 25 26 26 <div 27 - class="flex min-h-32 flex-col gap-1 rounded border border-border bg-surface px-6 py-4 shadow-sm" 27 + class="flex min-h-32 flex-col gap-1 rounded border border-border-default bg-background-default px-6 py-4 shadow-sm" 28 28 > 29 - <div class="flex items-start justify-between font-medium text-fg-strong"> 29 + <div class="flex items-start justify-between font-medium text-foreground-default"> 30 30 <div class="mr-2 flex min-w-0 flex-1 items-center mt-1"> 31 - <BookMarked class="mr-1.5 size-4 shrink-0 text-fg-subtle" aria-hidden="true" /> 31 + <BookMarked class="mr-1.5 size-4 shrink-0 text-foreground-subtle" aria-hidden="true" /> 32 32 <a 33 - href={resolve(`/${repo.ownerHandle}/${repo.name}` as '/')} 34 - class="min-w-0 truncate text-fg-strong no-underline hover:underline" 33 + href={resolve(`/${repo.ownerHandle}/${repo.name}` as "/")} 34 + class="min-w-0 truncate text-foreground-default no-underline hover:underline" 35 35 > 36 36 {showOwner ? `${repo.ownerHandle}/${repo.name}` : repo.name} 37 37 </a> ··· 50 50 </div> 51 51 52 52 {#if repo.description} 53 - <p class="line-clamp-2 text-sm text-fg-muted">{repo.description}</p> 53 + <p class="line-clamp-2 text-sm text-foreground-muted">{repo.description}</p> 54 54 {/if} 55 55 56 56 {#if repo.language !== undefined || stats.length > 0} 57 - <div class="mt-auto flex flex-wrap items-center gap-4 pt-2 font-mono text-xs text-fg-subtle"> 57 + <div 58 + class="mt-auto flex flex-wrap items-center gap-4 pt-2 font-mono text-xs text-foreground-subtle" 59 + > 58 60 {#if repo.language} 59 61 <span class="flex items-center gap-2"> 60 62 <span 61 63 class="inline-block size-2.5 shrink-0 rounded-full" 62 - style="background-color: var(--color-primary);" 64 + style="background-color: var(--color-background-success-emphasis);" 63 65 ></span> 64 66 <span>{repo.language}</span> 65 67 </span>
+13 -13
web/src/lib/components/repo/StarButton.svelte
··· 1 1 <script lang="ts"> 2 - import Star from '$icon/star'; 3 - import { getAuth } from '$lib/auth.svelte'; 4 - import { createStar, deleteStar } from '$lib/api/graph'; 5 - import Button from '$lib/components/ui/Button.svelte'; 6 - import { getProfileCounts } from '$lib/components/profile/counts.svelte'; 7 - import { createOptimisticRelation, createOptimisticCount } from '$lib/optimistic.svelte'; 2 + import Star from "$icon/star"; 3 + import { getAuth } from "$lib/auth.svelte"; 4 + import { createStar, deleteStar } from "$lib/api/graph"; 5 + import Button from "$lib/components/ui/Button.svelte"; 6 + import { getProfileCounts } from "$lib/components/profile/counts.svelte"; 7 + import { createOptimisticRelation, createOptimisticCount } from "$lib/optimistic.svelte"; 8 8 9 9 interface Props { 10 10 repoDid: string; ··· 20 20 const profileCounts = getProfileCounts(); 21 21 const signedIn = $derived(Boolean(auth.currentDid)); 22 22 const relation = createOptimisticRelation({ 23 - key: () => `${auth.currentDid ?? ''}:${repoDid}`, 23 + key: () => `${auth.currentDid ?? ""}:${repoDid}`, 24 24 loadedRkey: () => initialRkey 25 25 }); 26 26 const starCount = createOptimisticCount({ ··· 43 43 await deleteStar(agent, relation.rkey); 44 44 relation.deleted(); 45 45 starCount.adjust(-1); 46 - profileCounts?.adjust(agent.sub, 'stars', -1); 46 + profileCounts?.adjust(agent.sub, "stars", -1); 47 47 } else { 48 48 const rkey = await createStar(agent, repoDid); 49 49 relation.created(rkey); 50 50 starCount.adjust(1); 51 - profileCounts?.adjust(agent.sub, 'stars', 1); 51 + profileCounts?.adjust(agent.sub, "stars", 1); 52 52 } 53 53 } catch { 54 54 relation.fail(); ··· 62 62 {#if signedIn && repoDid} 63 63 <div class="flex flex-col items-end"> 64 64 <div 65 - class="inline-flex max-h-8 items-stretch divide-x divide-border overflow-clip rounded border border-border" 65 + class="inline-flex max-h-8 items-stretch divide-x divide-border-default overflow-clip rounded border border-border-default" 66 66 > 67 67 <Button 68 68 variant="default" ··· 72 72 disabled={!relation.known} 73 73 onclick={toggle} 74 74 > 75 - <Star class={`size-4 shrink-0 ${starred ? 'fill-current' : ''}`} aria-hidden="true" /> 76 - <span>{starred ? 'Unstar' : 'Star'}</span> 75 + <Star class={`size-4 shrink-0 ${starred ? "fill-current" : ""}`} aria-hidden="true" /> 76 + <span>{starred ? "Unstar" : "Star"}</span> 77 77 </Button> 78 78 <Button 79 79 href={`/${repoOwnerHandle}/${repoName}/stars`} ··· 86 86 </Button> 87 87 </div> 88 88 {#if failed} 89 - <p class="mt-1 text-xs text-danger">Something went wrong. Try again.</p> 89 + <p class="mt-1 text-xs text-foreground-danger">Something went wrong. Try again.</p> 90 90 {/if} 91 91 </div> 92 92 {/if}
+92 -91
web/src/lib/components/shell/Footer.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from '$app/paths'; 3 - import LogotypeSmall from '../ui/LogotypeSmall.svelte'; 2 + import { resolve } from "$app/paths"; 3 + import LogotypeSmall from "../ui/LogotypeSmall.svelte"; 4 4 5 5 interface Props { 6 - variant?: 'full' | 'minimal'; 6 + variant?: "full" | "minimal"; 7 7 } 8 8 9 9 type FooterIcon = 10 - | 'terms' 11 - | 'privacy' 12 - | 'blog' 13 - | 'docs' 14 - | 'source' 15 - | 'brand' 16 - | 'discord' 17 - | 'irc' 18 - | 'bluesky' 19 - | 'twitter' 20 - | 'email' 21 - | 'security'; 22 - type FooterLayout = 'desktop' | 'mobile' | 'minimal'; 10 + | "terms" 11 + | "privacy" 12 + | "blog" 13 + | "docs" 14 + | "source" 15 + | "brand" 16 + | "discord" 17 + | "irc" 18 + | "bluesky" 19 + | "twitter" 20 + | "email" 21 + | "security"; 22 + type FooterLayout = "desktop" | "mobile" | "minimal"; 23 23 24 24 interface FooterLinkAttributes { 25 - target?: '_blank'; 25 + target?: "_blank"; 26 26 } 27 27 28 28 interface FooterLink { ··· 41 41 links: FooterLink[]; 42 42 } 43 43 44 - let { variant = 'full' }: Props = $props(); 44 + let { variant = "full" }: Props = $props(); 45 45 46 - const appPath = (path: string) => resolve(path as '/'); 47 - const headerStyle = 'text-fg font-bold text-sm tracking-wide mb-1'; 48 - const mobileHeaderStyle = 'text-fg font-bold text-xs tracking-wide mb-1'; 49 - const linkStyle = 'inline-flex items-center gap-1 text-fg-subtle hover:text-fg hover:underline'; 50 - const minimalLinkStyle = 'text-fg-subtle hover:text-fg hover:underline'; 51 - const externalLinkAttributes = { target: '_blank' } as const; 46 + const appPath = (path: string) => resolve(path as "/"); 47 + const headerStyle = "text-foreground-default font-bold text-sm tracking-wide mb-1"; 48 + const mobileHeaderStyle = "text-foreground-default font-bold text-xs tracking-wide mb-1"; 49 + const linkStyle = 50 + "inline-flex items-center gap-1 text-foreground-subtle hover:text-foreground-default hover:underline"; 51 + const minimalLinkStyle = "text-foreground-subtle hover:text-foreground-default hover:underline"; 52 + const externalLinkAttributes = { target: "_blank" } as const; 52 53 53 54 const footerSections: FooterSection[] = [ 54 55 { 55 - title: 'Legal', 56 + title: "Legal", 56 57 links: [ 57 58 { 58 - label: 'Terms of service', 59 - minimalLabel: 'Terms', 60 - href: '/terms', 59 + label: "Terms of service", 60 + minimalLabel: "Terms", 61 + href: "/terms", 61 62 internal: true, 62 - icon: 'terms', 63 + icon: "terms", 63 64 minimalOrder: 8 64 65 }, 65 66 { 66 - label: 'Privacy policy', 67 - minimalLabel: 'Privacy', 68 - href: '/privacy', 67 + label: "Privacy policy", 68 + minimalLabel: "Privacy", 69 + href: "/privacy", 69 70 internal: true, 70 - icon: 'privacy', 71 + icon: "privacy", 71 72 minimalOrder: 9 72 73 } 73 74 ] 74 75 }, 75 76 { 76 - title: 'Resources', 77 + title: "Resources", 77 78 links: [ 78 79 { 79 - label: 'Blog', 80 - href: 'https://blog.tangled.org', 81 - icon: 'blog', 80 + label: "Blog", 81 + href: "https://blog.tangled.org", 82 + icon: "blog", 82 83 minimalOrder: 1, 83 84 desktopAttributes: externalLinkAttributes 84 85 }, 85 86 { 86 - label: 'Docs', 87 - href: 'https://docs.tangled.org', 88 - icon: 'docs', 87 + label: "Docs", 88 + href: "https://docs.tangled.org", 89 + icon: "docs", 89 90 minimalOrder: 2 90 91 }, 91 92 { 92 - label: 'Source', 93 - href: 'https://tangled.org/@tangled.org/core', 94 - icon: 'source', 93 + label: "Source", 94 + href: "https://tangled.org/@tangled.org/core", 95 + icon: "source", 95 96 minimalOrder: 3 96 97 }, 97 98 { 98 - label: 'Brand', 99 - href: 'https://tangled.org/brand', 100 - icon: 'brand', 99 + label: "Brand", 100 + href: "https://tangled.org/brand", 101 + icon: "brand", 101 102 minimalOrder: 4 102 103 } 103 104 ] 104 105 }, 105 106 { 106 - title: 'Social', 107 + title: "Social", 107 108 links: [ 108 109 { 109 - label: 'Discord', 110 - href: 'https://chat.tangled.org', 111 - icon: 'discord', 110 + label: "Discord", 111 + href: "https://chat.tangled.org", 112 + icon: "discord", 112 113 minimalOrder: 5, 113 114 attributes: externalLinkAttributes 114 115 }, 115 116 { 116 - label: 'IRC', 117 - href: 'https://web.libera.chat/#tangled', 118 - icon: 'irc', 117 + label: "IRC", 118 + href: "https://web.libera.chat/#tangled", 119 + icon: "irc", 119 120 attributes: externalLinkAttributes 120 121 }, 121 122 { 122 - label: 'Bluesky', 123 - href: 'https://bsky.app/profile/tangled.org', 124 - icon: 'bluesky', 123 + label: "Bluesky", 124 + href: "https://bsky.app/profile/tangled.org", 125 + icon: "bluesky", 125 126 minimalOrder: 6, 126 127 attributes: externalLinkAttributes 127 128 }, 128 129 { 129 - label: 'Twitter (X)', 130 - href: 'https://x.com/tangled_org', 131 - icon: 'twitter', 130 + label: "Twitter (X)", 131 + href: "https://x.com/tangled_org", 132 + icon: "twitter", 132 133 minimalOrder: 7, 133 134 attributes: externalLinkAttributes 134 135 } 135 136 ] 136 137 }, 137 138 { 138 - title: 'Contact', 139 + title: "Contact", 139 140 links: [ 140 141 { 141 - label: 'team@tangled.org', 142 - href: 'mailto:team@tangled.org', 143 - icon: 'email' 142 + label: "team@tangled.org", 143 + href: "mailto:team@tangled.org", 144 + icon: "email" 144 145 }, 145 146 { 146 - label: 'security@tangled.org', 147 - href: 'mailto:security@tangled.org', 148 - icon: 'security' 147 + label: "security@tangled.org", 148 + href: "mailto:security@tangled.org", 149 + icon: "security" 149 150 } 150 151 ] 151 152 } ··· 153 154 154 155 const minimalFooterLinks = footerSections 155 156 .flatMap((section) => section.links) 156 - .filter((link) => typeof link.minimalOrder === 'number') 157 + .filter((link) => typeof link.minimalOrder === "number") 157 158 .sort((a, b) => (a.minimalOrder ?? 0) - (b.minimalOrder ?? 0)); 158 159 159 160 const linkAttributes = (link: FooterLink, layout: FooterLayout) => 160 - layout === 'desktop' ? (link.desktopAttributes ?? link.attributes) : link.attributes; 161 + layout === "desktop" ? (link.desktopAttributes ?? link.attributes) : link.attributes; 161 162 const linkTarget = (link: FooterLink, layout: FooterLayout) => 162 163 linkAttributes(link, layout)?.target; 163 164 </script> 164 165 165 166 {#snippet footerIcon(icon: FooterIcon)} 166 - {#if icon === 'terms'} 167 + {#if icon === "terms"} 167 168 <svg 168 169 class="size-4 shrink-0" 169 170 viewBox="0 0 24 24" ··· 179 180 <path d="M16 13H8" /> 180 181 <path d="M16 17H8" /> 181 182 </svg> 182 - {:else if icon === 'privacy'} 183 + {:else if icon === "privacy"} 183 184 <svg 184 185 class="size-4 shrink-0" 185 186 viewBox="0 0 24 24" ··· 193 194 d="M20 13c0 5-3.5 7.5-7.66 9.7a1 1 0 0 1-.68 0C7.5 20.5 4 18 4 13V6a1 1 0 0 1 .76-.97l8-2a1 1 0 0 1 .48 0l8 2c.47.12.76.54.76.97Z" 194 195 /> 195 196 </svg> 196 - {:else if icon === 'blog'} 197 + {:else if icon === "blog"} 197 198 <svg 198 199 class="size-4 shrink-0" 199 200 viewBox="0 0 24 24" ··· 206 207 <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /> 207 208 <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" /> 208 209 </svg> 209 - {:else if icon === 'docs'} 210 + {:else if icon === "docs"} 210 211 <svg 211 212 class="size-4 shrink-0" 212 213 viewBox="0 0 24 24" ··· 220 221 d="M4 19.5V15a2 2 0 0 1 2-2h2M20 19.5V5a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2z" 221 222 /> 222 223 </svg> 223 - {:else if icon === 'source'} 224 + {:else if icon === "source"} 224 225 <svg 225 226 class="size-4 shrink-0" 226 227 viewBox="0 0 24 24" ··· 233 234 <polyline points="16 18 22 12 16 6" /> 234 235 <polyline points="8 6 2 12 8 18" /> 235 236 </svg> 236 - {:else if icon === 'brand'} 237 + {:else if icon === "brand"} 237 238 <svg 238 239 class="size-4 shrink-0" 239 240 viewBox="0 0 24 24" ··· 255 256 /> 256 257 <path d="M6 14c0-2 2-3 4-3 2.5 0 4.5 1.5 4.5 3.5S12.5 18 10 18c-2.5 0-4-2-4-4z" /> 257 258 </svg> 258 - {:else if icon === 'discord'} 259 + {:else if icon === "discord"} 259 260 <svg 260 261 class="size-4 shrink-0" 261 262 viewBox="0 0 24 24" ··· 267 268 > 268 269 <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /> 269 270 </svg> 270 - {:else if icon === 'irc'} 271 + {:else if icon === "irc"} 271 272 <svg 272 273 class="size-4 shrink-0" 273 274 viewBox="0 0 24 24" ··· 282 283 <line x1="10" y1="3" x2="8" y2="21" /> 283 284 <line x1="16" y1="3" x2="14" y2="21" /> 284 285 </svg> 285 - {:else if icon === 'bluesky'} 286 + {:else if icon === "bluesky"} 286 287 <svg 287 288 class="size-4 shrink-0" 288 289 viewBox="0 0 24 24" ··· 295 296 <path d="M12 21a9 9 0 0 0 9-9 9 9 0 0 0-9-9 9 9 0 0 0-9 9 9 9 0 0 0 9 9Z" /> 296 297 <path d="M12 12c2-3 5-3 5 0s-3 3-5 0Zm0 0c-2-3-5-3-5 0s3 3 5 0Z" /> 297 298 </svg> 298 - {:else if icon === 'twitter'} 299 + {:else if icon === "twitter"} 299 300 <svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="currentColor"> 300 301 <path 301 302 d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" 302 303 /> 303 304 </svg> 304 - {:else if icon === 'email'} 305 + {:else if icon === "email"} 305 306 <svg 306 307 class="size-4 shrink-0" 307 308 viewBox="0 0 24 24" ··· 314 315 <rect width="20" height="16" x="2" y="4" rx="2" /> 315 316 <path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /> 316 317 </svg> 317 - {:else if icon === 'security'} 318 + {:else if icon === "security"} 318 319 <svg 319 320 class="size-4 shrink-0" 320 321 viewBox="0 0 24 24" ··· 332 333 {/if} 333 334 {/snippet} 334 335 335 - {#if variant === 'minimal'} 336 + {#if variant === "minimal"} 336 337 <footer 337 - class="w-full border-t border-border-subtle bg-surface px-6 py-4 pb-[calc(env(safe-area-inset-bottom)+1rem)]" 338 + class="w-full border-t border-border-subtle bg-background-default px-6 py-4 pb-[calc(env(safe-area-inset-bottom)+1rem)]" 338 339 > 339 340 <div 340 - class="mx-auto flex max-w-screen-lg flex-wrap items-center justify-center gap-x-4 gap-y-2 text-sm text-fg-subtle" 341 + class="mx-auto flex max-w-screen-lg flex-wrap items-center justify-center gap-x-4 gap-y-2 text-sm text-foreground-subtle" 341 342 > 342 343 <div 343 344 class="order-last flex w-full items-center justify-center gap-x-2 sm:order-first sm:w-auto" 344 345 > 345 - <a href={resolve('/')} class="flex items-center no-underline hover:no-underline"> 346 + <a href={resolve("/")} class="flex items-center no-underline hover:no-underline"> 346 347 <img src="/logos/dolly.svg" alt="tangled" class="size-5 opacity-70" /> 347 348 </a> 348 349 <span>&copy; 2026 Tangled Labs Oy.</span> ··· 355 356 <a 356 357 href={link.href} 357 358 class={minimalLinkStyle} 358 - target={linkTarget(link, 'minimal')} 359 + target={linkTarget(link, "minimal")} 359 360 rel="external noopener noreferrer">{link.minimalLabel ?? link.label}</a 360 361 > 361 362 {/if} ··· 364 365 </footer> 365 366 {:else} 366 367 <footer class="z-10 pb-[env(safe-area-inset-bottom)] select-none"> 367 - <div class="w-full bg-surface p-8"> 368 + <div class="w-full bg-background-default p-8"> 368 369 <div class="mx-auto px-4"> 369 - <div class="flex flex-col gap-8 text-fg-subtle"> 370 + <div class="flex flex-col gap-8 text-foreground-subtle"> 370 371 <div 371 372 class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:items-start lg:gap-8" 372 373 > 373 374 <div> 374 - <a href={resolve('/')} class="flex gap-2 no-underline hover:no-underline"> 375 + <a href={resolve("/")} class="flex gap-2 no-underline hover:no-underline"> 375 376 <LogotypeSmall /> 376 377 </a> 377 378 </div> ··· 390 391 <a 391 392 href={link.href} 392 393 class={linkStyle} 393 - target={linkTarget(link, 'desktop')} 394 + target={linkTarget(link, "desktop")} 394 395 rel="external noopener noreferrer" 395 396 > 396 397 {@render footerIcon(link.icon)} ··· 409 410 410 411 <div class="flex flex-col gap-8 lg:hidden"> 411 412 <div class="mb-4 md:mb-0"> 412 - <a href={resolve('/')} class="flex gap-2 no-underline hover:no-underline"> 413 + <a href={resolve("/")} class="flex gap-2 no-underline hover:no-underline"> 413 414 <LogotypeSmall /> 414 415 </a> 415 416 </div> ··· 425 426 <a 426 427 href={link.href} 427 428 class={linkStyle} 428 - target={linkTarget(link, 'mobile')} 429 + target={linkTarget(link, "mobile")} 429 430 rel="external noopener noreferrer">{link.label}</a 430 431 > 431 432 {/if}
+55 -47
web/src/lib/components/shell/Topbar.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from '$app/paths'; 3 - import Button from '../ui/Button.svelte'; 4 - import Dropdown from '../ui/Dropdown.svelte'; 5 - import DropdownItem from '../ui/DropdownItem.svelte'; 6 - import ArrowRight from '$icon/arrow-right'; 7 - import Bell from '$icon/bell'; 8 - import BookMarked from '$icon/book-marked'; 9 - import BookPlus from '$icon/book-plus'; 10 - import Cog from '$icon/cog'; 11 - import LineSquiggle from '$icon/line-squiggle'; 12 - import LogOut from '$icon/log-out'; 13 - import Plus from '$icon/plus'; 14 - import Search from '$icon/search'; 15 - import User from '$icon/user'; 16 - import UserRound from '$icon/user-round'; 17 - import Spinner from '../ui/Spinner.svelte'; 18 - import LogotypeSmall from '../ui/LogotypeSmall.svelte'; 19 - import TopbarSearch from './TopbarSearch.svelte'; 2 + import { resolve } from "$app/paths"; 3 + import Button from "../ui/Button.svelte"; 4 + import Dropdown from "../ui/Dropdown.svelte"; 5 + import DropdownItem from "../ui/DropdownItem.svelte"; 6 + import ArrowRight from "$icon/arrow-right"; 7 + import Bell from "$icon/bell"; 8 + import BookMarked from "$icon/book-marked"; 9 + import BookPlus from "$icon/book-plus"; 10 + import Cog from "$icon/cog"; 11 + import LineSquiggle from "$icon/line-squiggle"; 12 + import LogOut from "$icon/log-out"; 13 + import Plus from "$icon/plus"; 14 + import Search from "$icon/search"; 15 + import User from "$icon/user"; 16 + import UserRound from "$icon/user-round"; 17 + import Spinner from "../ui/Spinner.svelte"; 18 + import LogotypeSmall from "../ui/LogotypeSmall.svelte"; 19 + import TopbarSearch from "./TopbarSearch.svelte"; 20 20 21 21 interface User { 22 22 handle: string; ··· 25 25 } 26 26 27 27 interface Props { 28 - variant: 'marketing' | 'app'; 28 + variant: "marketing" | "app"; 29 29 onSignOut?: () => void | Promise<void>; 30 30 user: User | null; 31 31 loading?: boolean; ··· 33 33 34 34 let { variant, onSignOut, user, loading = false }: Props = $props(); 35 35 36 - const appPath = (path: string) => resolve(path as '/'); 37 - const navClass = $derived( 38 - variant === 'marketing' 39 - ? 'mx-auto w-full max-w-screen-xl space-x-4 px-4 py-2 sm:px-6' 40 - : 'mx-auto w-full space-x-4 px-6 py-2' 41 - ); 36 + const appPath = (path: string) => resolve(path as "/"); 42 37 </script> 43 38 44 - <nav class={navClass} aria-label="main"> 45 - <div class="flex items-center justify-between gap-4 p-0"> 39 + <nav 40 + class="h-11 w-full border-b border-border-default bg-background-default px-5 shadow-[0_1px_1px_0_rgba(0,0,0,0.05)]" 41 + aria-label="main" 42 + > 43 + <div class="flex h-full items-center justify-between gap-4"> 46 44 <div class="flex shrink-0 items-center gap-2"> 47 45 <a 48 - href={resolve('/')} 46 + href={resolve("/")} 49 47 class="flex shrink-0 items-center gap-2 no-underline hover:no-underline" 50 48 > 51 49 <span class="hidden sm:inline-flex"> ··· 57 55 </a> 58 56 </div> 59 57 60 - <div class="flex items-center gap-4"> 61 - {#if variant === 'marketing'} 62 - <a href={appPath('/login')} class="text-fg transition-colors hover:text-fg-muted"> 58 + <div class={variant === "marketing" ? "flex items-center gap-3.5" : "flex items-center gap-4"}> 59 + {#if variant === "marketing"} 60 + <TopbarSearch /> 61 + <div class="hidden h-9 w-px rounded-md bg-background-inset md:block"></div> 62 + <a 63 + href={appPath("/login")} 64 + class="px-3 text-sm text-foreground-default transition-colors hover:text-foreground-muted" 65 + > 63 66 Login 64 67 </a> 65 - <span class="text-fg-subtle">or</span> 66 - <Button variant="success" size="sm" href="/signup" class="!py-0"> 68 + <span class="text-sm text-foreground-muted">or</span> 69 + <Button variant="primary" size="sm" href="/signup" class="px-3"> 67 70 <span>Join now</span> 68 - <ArrowRight class="size-4" aria-hidden="true" /> 71 + <ArrowRight class="size-3.5" aria-hidden="true" /> 69 72 </Button> 70 73 {:else} 71 74 {#if user || loading} ··· 76 79 <div class="order-1 flex items-center md:order-3"> 77 80 <Dropdown id="new-menu" group="topbar" variant="button"> 78 81 {#snippet trigger()} 79 - <Plus class="size-4" aria-hidden="true" /> 82 + <Plus class="size-3.5" aria-hidden="true" /> 80 83 <span class="hidden md:inline">New</span> 81 84 {/snippet} 82 85 <DropdownItem href="/repo/new" icon={BookPlus}>New repository</DropdownItem> ··· 86 89 87 90 <div class="order-2 flex items-center md:order-4"> 88 91 <a 89 - href={appPath('/notifications')} 90 - class="flex items-center text-fg-subtle transition-colors hover:text-fg" 92 + href={appPath("/notifications")} 93 + class="flex items-center text-foreground-default transition-colors hover:text-foreground-muted" 91 94 aria-label="Notifications" 92 95 > 93 - <Bell class="size-5" aria-hidden="true" /> 96 + <Bell class="size-4" aria-hidden="true" /> 94 97 </a> 95 98 </div> 96 99 97 100 <div class="order-3 md:hidden"> 98 - <a href={appPath('/search')} class="text-fg-subtle" aria-label="Search"> 101 + <a href={appPath("/search")} class="text-foreground-subtle" aria-label="Search"> 99 102 <Search class="size-5" aria-hidden="true" /> 100 103 </a> 101 104 </div> 102 105 103 - <div class="hidden h-6 w-px self-center bg-border md:order-2 md:block"></div> 106 + <div 107 + class="hidden h-9 w-px self-center rounded-md bg-background-inset md:order-2 md:block" 108 + ></div> 104 109 {/if} 105 110 106 111 <div class="order-4 flex items-center md:order-5"> 107 112 {#if loading} 108 113 <div class="flex items-center gap-1" role="status" aria-label="Signing in"> 109 114 <span 110 - class="flex size-8 items-center justify-center rounded-full border border-border bg-surface-muted text-fg-faint md:size-6" 115 + class="flex size-8 items-center justify-center rounded-full border border-border-default bg-background-inset text-foreground-placeholder md:size-[21px]" 111 116 > 112 117 <Spinner class="size-4" /> 113 118 </span> 114 - <span class="hidden h-4 w-24 rounded bg-surface-muted md:block" aria-hidden="true" 119 + <span class="hidden h-4 w-24 rounded bg-background-inset md:block" aria-hidden="true" 115 120 ></span> 116 121 </div> 117 122 {:else if user} ··· 121 126 <img 122 127 src={user.avatar} 123 128 alt={user.handle} 124 - class="size-8 rounded-full border border-border object-cover md:size-6" 129 + class="size-8 rounded-full border border-border-default object-cover md:size-[21px]" 125 130 /> 126 131 {:else} 127 132 <span 128 - class="flex size-8 items-center justify-center rounded-full border border-border bg-surface-muted md:size-6" 133 + class="flex size-8 items-center justify-center rounded-full border border-border-default bg-background-inset md:size-[21px]" 129 134 > 130 - <UserRound class="size-5 text-fg-faint md:size-4" aria-hidden="true" /> 135 + <UserRound 136 + class="size-5 text-foreground-placeholder md:size-4" 137 + aria-hidden="true" 138 + /> 131 139 </span> 132 140 {/if} 133 141 <span class="hidden max-w-48 truncate md:inline">{user.handle}</span> ··· 144 152 </Dropdown> 145 153 {:else} 146 154 <div class="flex items-center gap-2" aria-hidden="true"> 147 - <span class="size-8 rounded-full bg-surface-muted md:size-6"></span> 148 - <span class="hidden h-4 w-24 rounded bg-surface-muted md:block"></span> 155 + <span class="size-8 rounded-full bg-background-inset md:size-[21px]"></span> 156 + <span class="hidden h-4 w-24 rounded bg-background-inset md:block"></span> 149 157 </div> 150 158 {/if} 151 159 </div>
+14 -94
web/src/lib/components/shell/TopbarSearch.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from '$app/paths'; 3 - import Search from '$icon/search'; 2 + import { resolve } from "$app/paths"; 3 + import Search from "$icon/search"; 4 + import Input from "../ui/Input.svelte"; 4 5 5 6 interface Props { 6 7 class?: string; 7 8 } 8 9 9 - let { class: className = '' }: Props = $props(); 10 + let { class: className = "" }: Props = $props(); 10 11 11 - const appPath = (path: string) => resolve(path as '/'); 12 + const appPath = (path: string) => resolve(path as "/"); 12 13 </script> 13 14 14 - <form action={appPath('/search')} method="GET" class={`relative hidden md:block ${className}`}> 15 + <form action={appPath("/search")} method="GET" class={`relative hidden md:block ${className}`}> 15 16 <label class="sr-only" for="topbar-search">Search</label> 16 - <div class="search-box"> 17 - <Search class="size-4 shrink-0 text-fg-faint" aria-hidden="true" /> 18 - <input id="topbar-search" name="q" type="search" placeholder="Search..." class="search-input" /> 19 - <kbd class="search-kbd">Ctrl+K</kbd> 20 - </div> 17 + <Input 18 + id="topbar-search" 19 + name="q" 20 + type="search" 21 + placeholder="Search" 22 + iconLeft={Search} 23 + class="h-8 w-80 gap-1.5 px-2 py-0" 24 + /> 21 25 </form> 22 - 23 - <style> 24 - .search-box { 25 - --topbar-search-surface: #ffffff; 26 - --topbar-search-fg: #1f2937; 27 - --topbar-search-placeholder: #9ca3af; 28 - --topbar-search-kbd-fg: #9ca3af; 29 - --topbar-search-kbd-border: #e5e7eb; 30 - position: relative; 31 - display: flex; 32 - max-height: 30px; 33 - width: 20rem; 34 - align-items: center; 35 - gap: 0.5rem; 36 - border: 1px solid var(--color-border); 37 - border-radius: 0.25rem; 38 - background: var(--topbar-search-surface); 39 - padding: 1rem 0.375rem 1rem 0.5rem; 40 - } 41 - 42 - @media (prefers-color-scheme: dark) { 43 - .search-box { 44 - --topbar-search-surface: #111827; 45 - --topbar-search-fg: #e5e7eb; 46 - --topbar-search-placeholder: #4b5563; 47 - --topbar-search-kbd-fg: #6b7280; 48 - --topbar-search-kbd-border: #4b5563; 49 - } 50 - } 51 - 52 - @media (min-width: 1024px) { 53 - .search-box { 54 - width: 24rem; 55 - } 56 - } 57 - 58 - .search-box:focus-within { 59 - z-index: 51; 60 - outline: 2px solid var(--color-ring); 61 - outline-offset: 1px; 62 - } 63 - 64 - .search-input { 65 - min-width: 0; 66 - flex: 1; 67 - border: 0; 68 - border-radius: 0; 69 - background: transparent; 70 - padding: 0; 71 - color: var(--topbar-search-fg); 72 - font-size: 0.875rem; 73 - line-height: 1.25rem; 74 - outline: none; 75 - } 76 - 77 - .search-input:focus { 78 - box-shadow: none; 79 - outline: none; 80 - } 81 - 82 - .search-input::placeholder { 83 - color: var(--topbar-search-placeholder); 84 - } 85 - 86 - .search-kbd { 87 - display: flex; 88 - align-items: center; 89 - border: 1px solid var(--topbar-search-kbd-border); 90 - border-bottom-width: 2px; 91 - border-radius: 0.25rem; 92 - padding: 0 0.25rem; 93 - font-family: var(--font-sans); 94 - color: var(--topbar-search-kbd-fg); 95 - font-size: 0.75rem; 96 - line-height: 1.25rem; 97 - pointer-events: none; 98 - } 99 - 100 - .search-input:focus + .search-kbd, 101 - .search-box:focus-within .search-kbd, 102 - .search-input:not(:placeholder-shown) + .search-kbd { 103 - display: none; 104 - } 105 - </style>
+19 -19
web/src/routes/+error.svelte
··· 1 1 <script lang="ts"> 2 - import { page } from '$app/state'; 3 - import Button from '$lib/components/ui/Button.svelte'; 2 + import { page } from "$app/state"; 3 + import Button from "$lib/components/ui/Button.svelte"; 4 4 5 5 const copy = $derived.by(() => { 6 6 switch (page.status) { 7 7 case 404: 8 8 return { 9 - status: '404', 10 - title: 'Page not found', 9 + status: "404", 10 + title: "Page not found", 11 11 message: 12 12 "The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL.", 13 - mark: '?', 14 - tone: 'bg-surface-muted text-fg-subtle' 13 + mark: "?", 14 + tone: "bg-background-inset text-foreground-subtle" 15 15 }; 16 16 case 503: 17 17 return { 18 - status: '503', 19 - title: 'Service unavailable', 18 + status: "503", 19 + title: "Service unavailable", 20 20 message: 21 - 'We were unable to reach the service backing this page. It may be temporarily unavailable.', 22 - mark: '503', 23 - tone: 'bg-info-soft text-info' 21 + "We were unable to reach the service backing this page. It may be temporarily unavailable.", 22 + mark: "503", 23 + tone: "bg-background-info-subtle text-foreground-info" 24 24 }; 25 25 default: 26 26 return { 27 - status: '500', 28 - title: 'Internal server error', 27 + status: "500", 28 + title: "Internal server error", 29 29 message: 30 30 page.error?.message ?? 31 - 'We encountered an error while processing your request. Please try again later.', 32 - mark: '!', 33 - tone: 'bg-danger-soft text-danger' 31 + "We encountered an error while processing your request. Please try again later.", 32 + mark: "!", 33 + tone: "bg-background-danger-subtle text-foreground-danger" 34 34 }; 35 35 } 36 36 }); ··· 41 41 </svelte:head> 42 42 43 43 <section class="flex min-h-[60vh] flex-col items-center justify-center text-center"> 44 - <div class="mx-auto max-w-lg rounded-lg bg-surface p-8 shadow-sm"> 44 + <div class="mx-auto max-w-lg rounded-lg bg-background-default p-8 shadow-sm"> 45 45 <div class="mb-6"> 46 46 <div 47 47 class={`mx-auto mb-4 flex size-16 items-center justify-center rounded-full ${copy.tone}`} ··· 51 51 </div> 52 52 53 53 <div class="space-y-4"> 54 - <h1 class="text-2xl font-bold text-fg-strong sm:text-3xl"> 54 + <h1 class="text-2xl font-bold text-foreground-default sm:text-3xl"> 55 55 {copy.status} &mdash; {copy.title} 56 56 </h1> 57 - <p class="text-fg-muted">{copy.message}</p> 57 + <p class="text-foreground-muted">{copy.message}</p> 58 58 <div class="mt-6 flex flex-col items-center justify-center gap-3 sm:flex-row"> 59 59 <Button href="/" variant="default">Back to home</Button> 60 60 </div>
+17 -17
web/src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 - import { page } from '$app/state'; 3 - import { createAuth, AUTH_KEY } from '$lib/auth.svelte'; 4 - import Footer from '$lib/components/shell/Footer.svelte'; 5 - import Topbar from '$lib/components/shell/Topbar.svelte'; 6 - import { onMount, setContext, untrack } from 'svelte'; 7 - import '../app.css'; 2 + import { page } from "$app/state"; 3 + import { createAuth, AUTH_KEY } from "$lib/auth.svelte"; 4 + import Footer from "$lib/components/shell/Footer.svelte"; 5 + import Topbar from "$lib/components/shell/Topbar.svelte"; 6 + import { onMount, setContext, untrack } from "svelte"; 7 + import "../app.css"; 8 8 9 9 let { children, data } = $props(); 10 10 ··· 15 15 setContext(AUTH_KEY, auth); 16 16 17 17 onMount(() => { 18 - if (page.url.pathname !== '/oauth/callback') void auth.refresh(); 18 + if (page.url.pathname !== "/oauth/callback") void auth.refresh(); 19 19 }); 20 20 21 21 const signedIn = $derived(Boolean(auth.currentUser)); 22 - const isAuthShell = $derived(page.url.pathname === '/login'); 22 + const isAuthShell = $derived(page.url.pathname === "/login"); 23 23 const isMarketingShell = $derived( 24 - !signedIn && (page.url.pathname === '/about' || page.url.pathname === '/') 24 + !signedIn && (page.url.pathname === "/about" || page.url.pathname === "/") 25 25 ); 26 - const topbarVariant = $derived(isMarketingShell ? 'marketing' : 'app'); 26 + const topbarVariant = $derived(isMarketingShell ? "marketing" : "app"); 27 27 </script> 28 28 29 29 <svelte:head> ··· 32 32 33 33 <div 34 34 class={isAuthShell 35 - ? 'flex min-h-screen flex-col bg-bg-auth text-fg' 35 + ? "flex min-h-screen flex-col bg-background-default text-foreground-default" 36 36 : isMarketingShell 37 - ? 'flex min-h-screen flex-col bg-gradient-to-b from-bg-auth to-bg text-fg' 38 - : 'flex min-h-screen flex-col bg-bg text-fg'} 37 + ? "flex min-h-screen flex-col bg-gradient-to-b from-background-default to-background-canvas text-foreground-default" 38 + : "flex min-h-screen flex-col bg-background-canvas text-foreground-default"} 39 39 > 40 40 {#if !isAuthShell} 41 41 <header 42 42 class={isMarketingShell 43 - ? 'z-20 w-full bg-transparent pt-[env(safe-area-inset-top)]' 44 - : 'sticky top-0 z-20 w-full bg-surface pt-[env(safe-area-inset-top)] shadow-sm'} 43 + ? "z-20 w-full bg-transparent pt-[env(safe-area-inset-top)]" 44 + : "sticky top-0 z-20 w-full bg-background-default pt-[env(safe-area-inset-top)]"} 45 45 > 46 46 <Topbar 47 47 user={auth.currentUser} ··· 52 52 </header> 53 53 {/if} 54 54 55 - <main class={isAuthShell ? 'flex flex-grow items-center justify-center px-7' : 'flex-grow'}> 55 + <main class={isAuthShell ? "flex flex-grow items-center justify-center px-7" : "flex-grow"}> 56 56 {@render children()} 57 57 </main> 58 58 59 59 {#if !isAuthShell} 60 - <Footer variant={isMarketingShell ? 'full' : 'minimal'} /> 60 + <Footer variant={isMarketingShell ? "full" : "minimal"} /> 61 61 {/if} 62 62 </div>
+10 -10
web/src/routes/[handle]/+layout.svelte
··· 1 1 <script lang="ts"> 2 - import { page } from '$app/state'; 3 - import { setContext } from 'svelte'; 4 - import { createProfileCounts, PROFILE_COUNTS_KEY } from '$lib/components/profile/counts.svelte'; 5 - import EmptyState from '$lib/components/profile/EmptyState.svelte'; 6 - import ProfileCard from '$lib/components/profile/ProfileCard.svelte'; 7 - import ProfileTabs from '$lib/components/profile/ProfileTabs.svelte'; 2 + import { page } from "$app/state"; 3 + import { setContext } from "svelte"; 4 + import { createProfileCounts, PROFILE_COUNTS_KEY } from "$lib/components/profile/counts.svelte"; 5 + import EmptyState from "$lib/components/profile/EmptyState.svelte"; 6 + import ProfileCard from "$lib/components/profile/ProfileCard.svelte"; 7 + import ProfileTabs from "$lib/components/profile/ProfileTabs.svelte"; 8 8 9 9 let { children, data } = $props(); 10 10 ··· 14 14 ); 15 15 setContext(PROFILE_COUNTS_KEY, profileCounts); 16 16 const counts = $derived(profileCounts.value); 17 - const tabParam = $derived(page.url.searchParams.get('tab') ?? 'overview'); 17 + const tabParam = $derived(page.url.searchParams.get("tab") ?? "overview"); 18 18 const activeTab = $derived( 19 - ['repos', 'starred', 'strings', 'vouches'].includes(tabParam) ? tabParam : 'overview' 19 + ["repos", "starred", "strings", "vouches"].includes(tabParam) ? tabParam : "overview" 20 20 ); 21 21 </script> 22 22 ··· 29 29 <EmptyState message={`${data.identity.handle} hasn't joined Tangled yet.`} /> 30 30 {:else} 31 31 <ProfileTabs handle={data.identity.handle} active={activeTab} {counts} /> 32 - <div class="grid rounded bg-surface shadow-sm md:grid-cols-11 md:gap-4 md:p-6"> 32 + <div class="grid rounded bg-background-default shadow-sm md:grid-cols-11 md:gap-4 md:p-6"> 33 33 <aside class="p-6 md:col-span-3 md:p-0"> 34 34 <ProfileCard 35 35 identity={data.identity} ··· 39 39 viewerFollowRkey={data.viewerFollowRkey} 40 40 /> 41 41 </aside> 42 - <div class="min-w-0 border-t border-border p-6 md:col-span-8 md:border-t-0 md:p-0"> 42 + <div class="min-w-0 border-t border-border-default p-6 md:col-span-8 md:border-t-0 md:p-0"> 43 43 {@render children()} 44 44 </div> 45 45 </div>
+30 -26
web/src/routes/login/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { browser } from '$app/environment'; 3 - import { resolve } from '$app/paths'; 4 - import { page } from '$app/state'; 5 - import { getAuth } from '$lib/auth.svelte'; 6 - import Button from '$lib/components/ui/Button.svelte'; 7 - import CircleAlert from '$icon/circle-alert'; 8 - import { onMount } from 'svelte'; 9 - import { SvelteURL } from 'svelte/reactivity'; 2 + import { browser } from "$app/environment"; 3 + import { resolve } from "$app/paths"; 4 + import { page } from "$app/state"; 5 + import { getAuth } from "$lib/auth.svelte"; 6 + import Button from "$lib/components/ui/Button.svelte"; 7 + import CircleAlert from "$icon/circle-alert"; 8 + import { onMount } from "svelte"; 9 + import { SvelteURL } from "svelte/reactivity"; 10 10 11 - const appPath = (path: string) => resolve(path as '/'); 11 + const appPath = (path: string) => resolve(path as "/"); 12 12 const auth = getAuth(); 13 - const returnTo = $derived(page.url.searchParams.get('return_url') ?? '/'); 13 + const returnTo = $derived(page.url.searchParams.get("return_url") ?? "/"); 14 14 15 - let identifier = $state(page.url.searchParams.get('identifier') ?? ''); 15 + let identifier = $state(page.url.searchParams.get("identifier") ?? ""); 16 16 let isSubmitting = $state(false); 17 17 18 18 onMount(() => { 19 - if (browser && location.hostname === 'localhost') { 19 + if (browser && location.hostname === "localhost") { 20 20 const url = new SvelteURL(location.href); 21 - url.hostname = '127.0.0.1'; 21 + url.hostname = "127.0.0.1"; 22 22 location.replace(url); 23 23 } 24 24 }); ··· 40 40 41 41 <section class="w-full max-w-md"> 42 42 <div class="mb-4 flex flex-col items-center"> 43 - <a href={appPath('/')} class="flex items-center gap-2 no-underline hover:no-underline"> 43 + <a href={appPath("/")} class="flex items-center gap-2 no-underline hover:no-underline"> 44 44 <img src="/logos/logotype.svg" alt="tangled" class="h-24 w-48 dark:invert" /> 45 45 </a> 46 - <p class="text-center text-xl italic text-fg-strong">tightly-knit social coding.</p> 46 + <p class="text-center text-xl italic text-foreground-default">tightly-knit social coding.</p> 47 47 </div> 48 48 49 49 <form class="mt-4" onsubmit={submit}> 50 50 <div class="flex flex-col"> 51 - <label for="identifier" class="py-2 text-sm text-fg">Handle</label> 51 + <label for="identifier" class="py-2 text-sm text-foreground-default">Handle</label> 52 52 <input 53 53 id="identifier" 54 54 bind:value={identifier} ··· 60 60 inputmode="url" 61 61 required 62 62 placeholder="akshay.tngl.sh" 63 - class="w-full rounded border border-border bg-surface p-3 text-lg text-fg placeholder:text-fg-faint focus:outline-none focus:ring-1 focus:ring-border-strong md:text-base" 63 + class="w-full rounded border border-border-default bg-background-default p-3 text-lg text-foreground-default placeholder:text-foreground-placeholder focus:outline-none focus:ring-1 focus:ring-border-strong md:text-base" 64 64 /> 65 - <p class="mt-1 text-sm text-fg-subtle"> 66 - Use your <span class="text-fg">AT Protocol</span> handle to log in. If you're unsure, this 67 - is likely your Tangled 68 - <code class="rounded bg-surface-muted px-1 font-mono text-xs text-fg">(.tngl.sh)</code> 65 + <p class="mt-1 text-sm text-foreground-subtle"> 66 + Use your <span class="text-foreground-default">AT Protocol</span> handle to log in. If 67 + you're unsure, this is likely your Tangled 68 + <code class="rounded bg-background-inset px-1 font-mono text-xs text-foreground-default" 69 + >(.tngl.sh)</code 70 + > 69 71 or Bluesky 70 - <code class="rounded bg-surface-muted px-1 font-mono text-xs text-fg">(.bsky.social)</code> 72 + <code class="rounded bg-background-inset px-1 font-mono text-xs text-foreground-default" 73 + >(.bsky.social)</code 74 + > 71 75 account. 72 76 </p> 73 77 </div> ··· 78 82 disabled={isSubmitting} 79 83 loading={isSubmitting} 80 84 spinnerClass="size-4" 81 - aria-label={isSubmitting ? 'Logging in' : undefined} 85 + aria-label={isSubmitting ? "Logging in" : undefined} 82 86 class="my-2 mt-6 w-full text-base disabled:opacity-100" 83 87 > 84 88 Login 85 89 </Button> 86 90 </form> 87 91 88 - <p class="text-sm text-fg-subtle"> 89 - Don't have an account? <a href={appPath('/signup')} class="text-fg underline" 92 + <p class="text-sm text-foreground-subtle"> 93 + Don't have an account? <a href={appPath("/signup")} class="text-foreground-default underline" 90 94 >Create an account</a 91 95 > 92 96 on Tangled now! ··· 94 98 95 99 {#if auth.error} 96 100 <div 97 - class="my-2 flex gap-2 rounded border border-danger bg-danger-soft px-3 py-2 text-danger drop-shadow-sm" 101 + class="my-2 flex gap-2 rounded border border-border-danger bg-background-danger-subtle px-3 py-2 text-foreground-danger drop-shadow-sm" 98 102 > 99 103 <span class="py-1" aria-hidden="true"><CircleAlert class="size-4" /></span> 100 104 <div>
+7 -7
web/src/routes/oauth/callback/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { resolve } from '$app/paths'; 3 - import { goto } from '$app/navigation'; 4 - import { getAuth } from '$lib/auth.svelte'; 5 - import { onMount } from 'svelte'; 2 + import { resolve } from "$app/paths"; 3 + import { goto } from "$app/navigation"; 4 + import { getAuth } from "$lib/auth.svelte"; 5 + import { onMount } from "svelte"; 6 6 7 7 const auth = getAuth(); 8 - const appPath = (path: string) => resolve(path as '/'); 8 + const appPath = (path: string) => resolve(path as "/"); 9 9 10 10 onMount(() => { 11 11 void (async () => { ··· 25 25 26 26 {#if auth.error} 27 27 <section class="mx-auto flex max-w-md flex-col items-center gap-4 px-7 py-16 text-center"> 28 - <p class="text-base text-danger">{auth.error}</p> 29 - <a href={appPath('/login')} class="inline-flex underline">Try again</a> 28 + <p class="text-base text-foreground-danger">{auth.error}</p> 29 + <a href={appPath("/login")} class="inline-flex underline">Try again</a> 30 30 </section> 31 31 {:else} 32 32 <p class="sr-only" role="status">Signing you in&hellip;</p>