This repository has no description
0

Configure Feed

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

web/settings: rebuild the user settings screens from the redesign

Ports the desktop user-settings screens from the Settings Redesign
Figma (TFhrx7FBh2Ue5SGH8CUeBU) onto a shared set of primitives: a
sidebar Navigation matching the DS component's four Active x Hovered
states, plus header/toolbar/list/row/entry pieces and a drill-down
shell for the in-place sub-views.

Seven tabs and nine drill-downs. Keys keeps its existing
`tangled.publicKey` wiring and moves the add form from a popover to
/settings/keys/new; the rest are mocked, since nothing behind them
exists yet.

Three token pairs are new because the navigation aliases *invert*
between modes -- light recesses the frame and raises the active item,
dark does the opposite -- so they cannot be spelled with an existing
pair. Button gains per-variant disabled surfaces: Figma models
disabled as its own fill/text pair rather than a dimmed enabled state,
which is what the disabled Save and Add buttons in the designs need.
Tag grows the DS badge geometry as an additive `sm` size plus the
status colours, leaving its existing callers untouched.

Verified light and dark against the Figma renders; nav, card and
surface tokens plus nav/card geometry and heading size all match.

Refs TAN-575.

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

author
eti
committer
dawn
date (Jul 31, 2026, 10:57 PM +0300) commit 3365804b parent 52895e0f change-id oqnoqmxs
+1585 -268
+17
web/src/app.css
··· 36 36 --color-background-info-emphasis: #4f46e5; 37 37 --color-background-neutral-emphasis: #1f2937; 38 38 39 + /* --- background / navigation --- 40 + The settings sidebar aliases *invert* between modes, so these cannot be 41 + spelled as an existing pair: light recesses the frame (subtle) and raises 42 + the active item (default), dark does the exact opposite. */ 43 + --color-background-navigation-frame: #f9fafb; 44 + --color-background-navigation-item: #ffffff; 45 + 39 46 /* --- foreground --- */ 40 47 --color-foreground-default: #111827; 41 48 --color-foreground-muted: #4b5563; ··· 50 57 --color-foreground-danger: #dc2626; 51 58 --color-foreground-danger-strong: #991b1b; 52 59 --color-foreground-danger-subtle: #f87171; 60 + --color-foreground-danger-disabled: #fca5a5; 53 61 --color-foreground-warning: #d97706; 54 62 --color-foreground-warning-strong: #92400e; 63 + --color-foreground-warning-disabled: #fcd34d; 55 64 --color-foreground-info: #4338ca; 56 65 --color-foreground-info-strong: #3730a3; 57 66 --color-foreground-link-default: #111827; ··· 77 86 --color-border-success: #15803d; 78 87 --color-border-danger: #fca5a5; 79 88 --color-border-danger-subtle: #fecaca; 89 + --color-border-navigation-item-active: #e5e7eb; 80 90 81 91 /* --- shadow (inner depth) --- */ 82 92 --color-shadow-inner-strong: #d1d5db; ··· 299 309 --color-background-info-emphasis: #4f46e5; 300 310 --color-background-neutral-emphasis: #374151; 301 311 312 + /* --- background / navigation (Dark) --- inverted vs light, see above */ 313 + --color-background-navigation-frame: #1f2937; 314 + --color-background-navigation-item: #374151; 315 + 302 316 /* --- foreground (Dark) --- */ 303 317 --color-foreground-default: #f3f4f6; 304 318 --color-foreground-muted: #9ca3af; ··· 312 326 --color-foreground-success-disabled: #15803d; 313 327 --color-foreground-danger: #f87171; 314 328 --color-foreground-danger-strong: #fecaca; 329 + --color-foreground-danger-disabled: #f87171; 315 330 --color-foreground-warning: #fbbf24; 316 331 --color-foreground-warning-strong: #fde68a; 332 + --color-foreground-warning-disabled: #fbbf24; 317 333 --color-foreground-info: #818cf8; 318 334 --color-foreground-info-strong: #c7d2fe; 319 335 --color-foreground-link-default: #f3f4f6; ··· 338 354 --color-border-focus-danger: #ef4444; 339 355 --color-border-success: #15803d; 340 356 --color-border-danger: #991b1b; 357 + --color-border-navigation-item-active: #4b5563; 341 358 342 359 /* --- shadow (Dark) --- */ 343 360 --color-shadow-inner-strong: #030712;
-70
web/src/lib/components/settings/AddKeyForm.svelte
··· 1 - <script lang="ts"> 2 - import Button from "$lib/components/ui/Button.svelte"; 3 - import Spinner from "$lib/components/ui/Spinner.svelte"; 4 - import ErrorAlert from "$lib/components/ui/Error.svelte"; 5 - import Plus from "$icon/plus"; 6 - import X from "$icon/x"; 7 - 8 - interface Props { 9 - name?: string; 10 - key?: string; 11 - submitting?: boolean; 12 - error?: string | null; 13 - onSubmit: () => void; 14 - onCancel: () => void; 15 - } 16 - 17 - let { 18 - name = $bindable(""), 19 - key = $bindable(""), 20 - submitting = false, 21 - error = null, 22 - onSubmit, 23 - onCancel 24 - }: Props = $props(); 25 - 26 - const handleSubmit = (e: SubmitEvent) => { 27 - e.preventDefault(); 28 - onSubmit(); 29 - }; 30 - </script> 31 - 32 - <form onsubmit={handleSubmit} class="flex w-full flex-col gap-2"> 33 - <label for="key-name" class="font-medium text-foreground-default">Add SSH key</label> 34 - <p class="text-sm text-foreground-subtle"> 35 - SSH keys allow you to push to repositories in knots you're a member of. 36 - </p> 37 - <input 38 - id="key-name" 39 - type="text" 40 - placeholder="Key name" 41 - required 42 - bind:value={name} 43 - disabled={submitting} 44 - class="rounded border border-border-default bg-background-default px-2 py-2 text-sm text-foreground-default placeholder:text-foreground-placeholder focus:ring-1 focus:ring-border-strong focus:outline-none disabled:opacity-50" 45 - /> 46 - <textarea 47 - id="key-value" 48 - placeholder="ssh-ed25519 AAAAC3NzaC1..." 49 - required 50 - rows={3} 51 - spellcheck={false} 52 - bind:value={key} 53 - disabled={submitting} 54 - class="resize-y rounded border border-border-default bg-background-default px-2 py-2 font-mono text-sm text-foreground-default placeholder:text-foreground-placeholder focus:ring-1 focus:ring-border-strong focus:outline-none disabled:opacity-50" 55 - ></textarea> 56 - <div class="flex justify-end gap-2 pt-1"> 57 - <Button type="button" icon={X} onclick={onCancel} disabled={submitting}>Cancel</Button> 58 - <Button 59 - type="submit" 60 - variant="primary" 61 - icon={submitting ? Spinner : Plus} 62 - disabled={submitting} 63 - > 64 - Add 65 - </Button> 66 - </div> 67 - {#if error} 68 - <ErrorAlert label={error} /> 69 - {/if} 70 - </form>
+17
web/src/lib/components/settings/CodeChip.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + 4 + interface Props { 5 + class?: string; 6 + children: Snippet; 7 + } 8 + 9 + let { class: className, children }: Props = $props(); 10 + </script> 11 + 12 + <span 13 + class="inline-flex items-center rounded-sm bg-background-subtle px-2 py-0.5 font-mono typography-monospace-regular text-foreground-default {className ?? 14 + ''}" 15 + > 16 + {@render children()} 17 + </span>
+22
web/src/lib/components/settings/DocsButton.svelte
··· 1 + <script lang="ts"> 2 + import { button } from "$lib/components/ui/Button.svelte"; 3 + import Book from "$icon/book"; 4 + 5 + interface Props { 6 + href: string; 7 + label?: string; 8 + } 9 + 10 + let { href, label = "Documentation" }: Props = $props(); 11 + </script> 12 + 13 + <!-- a plain anchor rather than <Button href>: Button's href is a ResolvedPathname 14 + so the router can check it, and these point out of the app --> 15 + <a {href} rel="external noopener noreferrer" class={button()}> 16 + <span 17 + class="inline-flex min-w-0 items-center justify-center gap-[inherit] transition-transform duration-150 ease-in-out" 18 + > 19 + <Book class="size-4 shrink-0" aria-hidden="true" /> 20 + {label} 21 + </span> 22 + </a>
+38
web/src/lib/components/settings/DrillDown.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + import { resolve } from "$app/paths"; 4 + import ArrowLeft from "$icon/arrow-left"; 5 + 6 + interface Props { 7 + /** label of the tab being returned to, e.g. "Keys" */ 8 + backLabel: string; 9 + backHref: string; 10 + title: string; 11 + description?: string; 12 + children: Snippet; 13 + } 14 + 15 + let { backLabel, backHref, title, description, children }: Props = $props(); 16 + </script> 17 + 18 + <!-- Drill-downs are in-place content swaps: the sidebar stays put and the parent 19 + tab stays active, so the only wayfinding is this back link above the title. --> 20 + <div class="flex items-center gap-2"> 21 + <a 22 + href={resolve(backHref as "/")} 23 + class="inline-flex items-center gap-1.5 typography-paragraph-regular text-foreground-link-default no-underline hover:text-foreground-link-hover hover:no-underline" 24 + > 25 + <ArrowLeft class="size-3.5 shrink-0" aria-hidden="true" /> 26 + {backLabel} 27 + </a> 28 + </div> 29 + 30 + <div class="flex w-full flex-col gap-4"> 31 + <div class="flex flex-col gap-1"> 32 + <h1 class="typography-paragraph-large text-foreground-default">{title}</h1> 33 + {#if description} 34 + <p class="typography-paragraph-regular text-foreground-muted">{description}</p> 35 + {/if} 36 + </div> 37 + {@render children()} 38 + </div>
+19
web/src/lib/components/settings/FormActions.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + 4 + interface Props { 5 + /** destructive escape hatch that sits opposite the confirm pair, as on the 6 + * avatar screen's "Remove avatar" */ 7 + start?: Snippet; 8 + children: Snippet; 9 + } 10 + 11 + let { start, children }: Props = $props(); 12 + </script> 13 + 14 + <div class="flex w-full items-start gap-2 {start ? 'justify-between' : 'justify-end'}"> 15 + {#if start} 16 + <div class="flex items-center gap-2">{@render start()}</div> 17 + {/if} 18 + <div class="flex items-center gap-2">{@render children()}</div> 19 + </div>
+24
web/src/lib/components/settings/FormRow.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + 4 + interface Props { 5 + label: string; 6 + /** helper line under the label, e.g. "Confirm your identity to proceed." */ 7 + description?: string; 8 + for?: string; 9 + /** the control — Figma sizes these to 320px on the right of the row */ 10 + children: Snippet; 11 + } 12 + 13 + let { label, description, for: forId, children }: Props = $props(); 14 + </script> 15 + 16 + <div class="flex min-h-8 w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> 17 + <div class="flex min-w-0 flex-col"> 18 + <label class="typography-paragraph-regular text-foreground-default" for={forId}>{label}</label> 19 + {#if description} 20 + <span class="typography-paragraph-regular text-foreground-muted">{description}</span> 21 + {/if} 22 + </div> 23 + <div class="w-full shrink-0 sm:w-80">{@render children()}</div> 24 + </div>
+39
web/src/lib/components/settings/SettingsEntry.svelte
··· 1 + <script lang="ts"> 2 + import type { Component, Snippet } from "svelte"; 3 + import type { SvelteHTMLElements } from "svelte/elements"; 4 + 5 + interface Props { 6 + icon?: Component<SvelteHTMLElements["svg"]>; 7 + title: string; 8 + /** badges rendered inline after the title */ 9 + tags?: Snippet; 10 + /** secondary lines under the title — fingerprints, "added 2 months ago" */ 11 + meta?: Snippet; 12 + /** trailing buttons */ 13 + actions?: Snippet; 14 + } 15 + 16 + let { icon, title, tags, meta, actions }: Props = $props(); 17 + 18 + const Glyph = $derived(icon); 19 + </script> 20 + 21 + <div class="flex min-h-8 w-full items-center justify-between gap-4"> 22 + <div class="flex min-w-0 flex-col gap-1"> 23 + <div class="flex items-center gap-2"> 24 + {#if Glyph} 25 + <Glyph class="size-3.5 shrink-0 text-foreground-default" aria-hidden="true" /> 26 + {/if} 27 + <span class="typography-paragraph-regular text-foreground-default">{title}</span> 28 + {#if tags} 29 + {@render tags()} 30 + {/if} 31 + </div> 32 + {#if meta} 33 + {@render meta()} 34 + {/if} 35 + </div> 36 + {#if actions} 37 + <div class="flex shrink-0 items-center gap-2">{@render actions()}</div> 38 + {/if} 39 + </div>
+24
web/src/lib/components/settings/SettingsHeader.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + 4 + interface Props { 5 + title: string; 6 + description?: string; 7 + /** right-aligned control that sits on the title's baseline row */ 8 + action?: Snippet; 9 + } 10 + 11 + let { title, description, action }: Props = $props(); 12 + </script> 13 + 14 + <div class="flex w-full flex-col justify-center gap-4"> 15 + <div class="flex items-start justify-between gap-4"> 16 + <h1 class="typography-heading-2 text-foreground-default">{title}</h1> 17 + {#if action} 18 + {@render action()} 19 + {/if} 20 + </div> 21 + {#if description} 22 + <p class="typography-paragraph-regular text-foreground-muted">{description}</p> 23 + {/if} 24 + </div>
+45
web/src/lib/components/settings/SettingsList.svelte
··· 1 + <script module lang="ts"> 2 + import { tv, type VariantProps } from "tailwind-variants"; 3 + 4 + export const settingsList = tv({ 5 + base: "flex w-full flex-col rounded-sm border border-border-default", 6 + variants: { 7 + // Figma spaces rows with a gap and drops the hairline in the middle of it, 8 + // so the divider is split evenly across the two rows it separates rather 9 + // than sitting flush against one of them. 10 + gap: { 11 + md: "[&>*+*]:mt-2 [&>*+*]:border-t [&>*+*]:border-border-default [&>*+*]:pt-2", 12 + sm: "[&>*+*]:mt-1 [&>*+*]:border-t [&>*+*]:border-border-default [&>*+*]:pt-1" 13 + }, 14 + padding: { 15 + default: "px-4 py-3", 16 + tight: "p-3" 17 + } 18 + }, 19 + defaultVariants: { 20 + gap: "md", 21 + padding: "default" 22 + } 23 + }); 24 + 25 + export type SettingsListVariants = VariantProps<typeof settingsList>; 26 + </script> 27 + 28 + <script lang="ts"> 29 + import type { Snippet } from "svelte"; 30 + 31 + interface Props { 32 + gap?: SettingsListVariants["gap"]; 33 + padding?: SettingsListVariants["padding"]; 34 + class?: string; 35 + children: Snippet; 36 + } 37 + 38 + let { gap = "md", padding = "default", class: className, children }: Props = $props(); 39 + 40 + const classes = $derived(settingsList({ gap, padding, class: className })); 41 + </script> 42 + 43 + <div class={classes}> 44 + {@render children()} 45 + </div>
+58
web/src/lib/components/settings/SettingsNav.svelte
··· 1 + <script module lang="ts"> 2 + import type { Component } from "svelte"; 3 + import type { SvelteHTMLElements } from "svelte/elements"; 4 + 5 + export interface SettingsNavItem { 6 + id: string; 7 + label: string; 8 + href: string; 9 + icon?: Component<SvelteHTMLElements["svg"]>; 10 + } 11 + </script> 12 + 13 + <script lang="ts"> 14 + import { resolve } from "$app/paths"; 15 + 16 + interface Props { 17 + items: SettingsNavItem[]; 18 + active: string; 19 + label?: string; 20 + } 21 + 22 + let { items, active, label = "Settings" }: Props = $props(); 23 + 24 + // Figma DS "Navigation / Button" (1009:2662) has four states across Active × 25 + // Hovered. Inactive/resting is the only one with no fill and no border, so the 26 + // hover borders have to be added rather than recoloured — otherwise the item 27 + // grows by 1px on hover. 28 + const itemClass = (isActive: boolean) => 29 + [ 30 + "flex min-h-8 w-full items-center gap-1.5 rounded-sm border px-3 py-1.5", 31 + "typography-paragraph-regular text-foreground-default no-underline hover:no-underline", 32 + "transition-colors duration-150 ease-in-out", 33 + "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus", 34 + isActive 35 + ? "border-border-navigation-item-active bg-background-navigation-item hover:border-border-strong hover:bg-background-muted" 36 + : "border-transparent hover:border-border-strong hover:bg-background-inset" 37 + ].join(" "); 38 + </script> 39 + 40 + <nav 41 + class="flex w-full shrink-0 flex-col border-b border-border-default bg-background-navigation-frame p-2 md:w-44 md:self-stretch md:border-r md:border-b-0" 42 + aria-label={label} 43 + > 44 + {#each items as item (item.id)} 45 + {@const isActive = active === item.id} 46 + {@const Glyph = item.icon} 47 + <a 48 + href={resolve(item.href as "/")} 49 + aria-current={isActive ? "page" : undefined} 50 + class={itemClass(isActive)} 51 + > 52 + {#if Glyph} 53 + <Glyph class="size-3.5 shrink-0" aria-hidden="true" /> 54 + {/if} 55 + {item.label} 56 + </a> 57 + {/each} 58 + </nav>
+35
web/src/lib/components/settings/SettingsRow.svelte
··· 1 + <script lang="ts"> 2 + import type { Component, Snippet } from "svelte"; 3 + import type { SvelteHTMLElements } from "svelte/elements"; 4 + 5 + interface Props { 6 + /** plain-text label — omit and render whatever you need through `label` */ 7 + title?: string; 8 + icon?: Component<SvelteHTMLElements["svg"]>; 9 + /** replaces `title`/`icon` when the leading side is more than a label */ 10 + label?: Snippet; 11 + /** trailing controls: buttons, toggles, code chips */ 12 + children?: Snippet; 13 + class?: string; 14 + } 15 + 16 + let { title, icon, label, children, class: className }: Props = $props(); 17 + 18 + const Glyph = $derived(icon); 19 + </script> 20 + 21 + <div class="flex min-h-8 w-full items-center justify-between gap-4 {className ?? ''}"> 22 + {#if label} 23 + {@render label()} 24 + {:else} 25 + <span class="flex min-w-0 items-center gap-2"> 26 + {#if Glyph} 27 + <Glyph class="size-3.5 shrink-0 text-foreground-default" aria-hidden="true" /> 28 + {/if} 29 + <span class="typography-paragraph-regular text-foreground-default">{title}</span> 30 + </span> 31 + {/if} 32 + {#if children} 33 + <div class="flex shrink-0 items-center gap-2">{@render children()}</div> 34 + {/if} 35 + </div>
+27
web/src/lib/components/settings/SettingsSection.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + import SettingsList from "./SettingsList.svelte"; 4 + 5 + interface Props { 6 + /** omit for the leading card, which Figma leaves unlabelled */ 7 + title?: string; 8 + /** set false when the children provide their own container */ 9 + framed?: boolean; 10 + children: Snippet; 11 + } 12 + 13 + let { title, framed = true, children }: Props = $props(); 14 + </script> 15 + 16 + <section class="flex w-full flex-col gap-3"> 17 + {#if title} 18 + <h2 class="typography-heading-4 text-foreground-default">{title}</h2> 19 + {/if} 20 + {#if framed} 21 + <SettingsList gap="sm"> 22 + {@render children()} 23 + </SettingsList> 24 + {:else} 25 + {@render children()} 26 + {/if} 27 + </section>
+25
web/src/lib/components/settings/SettingsToolbar.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + 4 + interface Props { 5 + /** left-hand actions — omit and the trailing actions stay flush right */ 6 + start?: Snippet; 7 + end?: Snippet; 8 + /** the hairline under the toolbar, drawn in every Figma tab that has one */ 9 + separator?: boolean; 10 + } 11 + 12 + let { start, end, separator = true }: Props = $props(); 13 + </script> 14 + 15 + <div class="flex min-h-8 w-full items-center gap-4 {start ? 'justify-between' : 'justify-end'}"> 16 + {#if start} 17 + {@render start()} 18 + {/if} 19 + {#if end} 20 + <div class="flex items-center gap-2">{@render end()}</div> 21 + {/if} 22 + </div> 23 + {#if separator} 24 + <hr class="w-full border-0 border-t border-border-default" /> 25 + {/if}
+37
web/src/lib/components/settings/ThemePicker.svelte
··· 1 + <script lang="ts"> 2 + import Radio from "$lib/components/ui/Radio.svelte"; 3 + import ThemePreview from "./ThemePreview.svelte"; 4 + import SunMoon from "$icon/sun-moon"; 5 + 6 + interface Props { 7 + value?: "auto" | "light" | "dark"; 8 + } 9 + 10 + let { value = $bindable("light") }: Props = $props(); 11 + 12 + const name = $props.id(); 13 + </script> 14 + 15 + <div class="flex gap-4"> 16 + <label class="flex w-[135px] cursor-pointer flex-col gap-2"> 17 + <!-- Auto is the one thumbnail that tracks the live theme, since that is 18 + exactly what the option does --> 19 + <span 20 + class="flex aspect-[135/84] w-full items-center justify-center rounded-sm border border-border-default bg-background-default" 21 + aria-hidden="true" 22 + > 23 + <SunMoon class="size-6 text-foreground-muted" /> 24 + </span> 25 + <Radio value="auto" bind:group={value} {name}>Auto</Radio> 26 + </label> 27 + 28 + <label class="flex w-[135px] cursor-pointer flex-col gap-2"> 29 + <ThemePreview palette="light" /> 30 + <Radio value="light" bind:group={value} {name}>Light</Radio> 31 + </label> 32 + 33 + <label class="flex w-[135px] cursor-pointer flex-col gap-2"> 34 + <ThemePreview palette="dark" /> 35 + <Radio value="dark" bind:group={value} {name}>Dark</Radio> 36 + </label> 37 + </div>
+102
web/src/lib/components/settings/ThemePreview.svelte
··· 1 + <script module lang="ts"> 2 + /** 3 + * The Light and Dark thumbnails in Figma are a little mock of the UI built from 4 + * plain rectangles. Both share one geometry and differ only in palette, so the 5 + * shapes live here once and the colours come in as a literal map. 6 + * 7 + * Those literals are deliberate: a Light thumbnail has to keep looking light 8 + * while the app itself is in dark mode, so it cannot use semantic tokens. The 9 + * Auto thumbnail is the exception and does use them — following the system is 10 + * the whole point of that option. 11 + */ 12 + type Role = "surface" | "strong" | "placeholder" | "muted" | "subtle" | "success" | "faint"; 13 + 14 + interface Rect { 15 + x: number; 16 + y: number; 17 + w: number; 18 + h: number; 19 + role: Role; 20 + } 21 + 22 + // coordinates are Figma's, in a 135 × 84 box 23 + const RECTS: Rect[] = [ 24 + { x: 0, y: 0, w: 135, h: 6, role: "surface" }, 25 + { x: 11.3, y: 1.5, w: 9, h: 3, role: "strong" }, 26 + { x: 117.8, y: 1.5, w: 5.3, h: 3, role: "placeholder" }, 27 + { x: 124.5, y: 1.5, w: 9, h: 3, role: "placeholder" }, 28 + { x: 11.3, y: 12, w: 38.6, h: 6, role: "muted" }, 29 + { x: 80.3, y: 12, w: 13.5, h: 6, role: "surface" }, 30 + { x: 95.3, y: 12, w: 13.5, h: 6, role: "surface" }, 31 + { x: 110.3, y: 12, w: 13.5, h: 6, role: "surface" }, 32 + { x: 11.3, y: 20.3, w: 112.5, h: 55.5, role: "surface" }, 33 + { x: 14.3, y: 23.3, w: 48, h: 4.5, role: "subtle" }, 34 + { x: 102.8, y: 23.3, w: 18, h: 4.5, role: "success" }, 35 + { x: 14.3, y: 30, w: 40.5, h: 4.5, role: "faint" }, 36 + { x: 78.8, y: 30, w: 42, h: 4.5, role: "subtle" }, 37 + { x: 14.3, y: 36.8, w: 43.5, h: 4.5, role: "faint" }, 38 + { x: 78.8, y: 36.8, w: 24, h: 4.5, role: "faint" }, 39 + { x: 14.3, y: 43.5, w: 31.5, h: 4.5, role: "faint" }, 40 + { x: 14.3, y: 50.3, w: 37.5, h: 4.5, role: "faint" }, 41 + { x: 78.8, y: 50.3, w: 36.8, h: 4.5, role: "subtle" }, 42 + { x: 14.3, y: 57, w: 45.8, h: 4.5, role: "faint" }, 43 + { x: 78.8, y: 57, w: 21, h: 4.5, role: "faint" }, 44 + { x: 14.3, y: 63.8, w: 23.3, h: 4.5, role: "faint" }, 45 + { x: 78.8, y: 63.8, w: 24, h: 4.5, role: "faint" } 46 + ]; 47 + 48 + export const PALETTES = { 49 + light: { 50 + canvas: "#f3f4f6", 51 + border: "#e5e7eb", 52 + surface: "#ffffff", 53 + strong: "#111827", 54 + placeholder: "#9ca3af", 55 + muted: "#4b5563", 56 + subtle: "#6b7280", 57 + success: "#16a34a", 58 + faint: "#d1d5db" 59 + }, 60 + dark: { 61 + canvas: "#111827", 62 + border: "#374151", 63 + surface: "#1f2937", 64 + strong: "#f3f4f6", 65 + placeholder: "#9ca3af", 66 + // dark drops the muted/subtle distinction — both resolve to gray-400 67 + muted: "#9ca3af", 68 + subtle: "#9ca3af", 69 + success: "#4ade80", 70 + faint: "#6b7280" 71 + } 72 + } as const; 73 + 74 + export type PaletteName = keyof typeof PALETTES; 75 + </script> 76 + 77 + <script lang="ts"> 78 + interface Props { 79 + palette: PaletteName; 80 + } 81 + 82 + let { palette }: Props = $props(); 83 + 84 + const colors = $derived(PALETTES[palette]); 85 + const pct = (n: number, total: number) => `${(n / total) * 100}%`; 86 + </script> 87 + 88 + <div 89 + class="relative aspect-[135/84] w-full overflow-hidden rounded-sm border" 90 + style="background-color: {colors.canvas}; border-color: {colors.border}" 91 + aria-hidden="true" 92 + > 93 + {#each RECTS as rect, i (i)} 94 + <span 95 + class="absolute" 96 + style="left: {pct(rect.x, 135)}; top: {pct(rect.y, 84)}; width: {pct( 97 + rect.w, 98 + 135 99 + )}; height: {pct(rect.h, 84)}; background-color: {colors[rect.role]}" 100 + ></span> 101 + {/each} 102 + </div>
+88 -11
web/src/lib/components/settings/tabs/EmailsTab.svelte
··· 1 - <div> 2 - <h2 class="typography-paragraph-large font-medium text-foreground-default">Email addresses</h2> 3 - <p class="text-sm text-foreground-subtle"> 4 - Commits authored using emails listed here will be associated with your Tangled profile. 5 - </p> 6 - <!-- TODO: list, add and verify email addresses --> 7 - <div 8 - class="mt-2 flex items-center justify-center rounded border border-border-default p-4 text-sm text-foreground-subtle" 9 - > 10 - Coming soon 11 - </div> 1 + <script lang="ts"> 2 + import Button from "$lib/components/ui/Button.svelte"; 3 + import Tag from "$lib/components/ui/Tag.svelte"; 4 + import EmptyState from "$lib/components/ui/EmptyState.svelte"; 5 + import SettingsHeader from "../SettingsHeader.svelte"; 6 + import SettingsToolbar from "../SettingsToolbar.svelte"; 7 + import SettingsList from "../SettingsList.svelte"; 8 + import SettingsEntry from "../SettingsEntry.svelte"; 9 + import Plus from "$icon/plus"; 10 + import Trash from "$icon/trash-2"; 11 + import Mail from "$icon/mail"; 12 + import Star from "$icon/star"; 13 + import Check from "$icon/check"; 14 + 15 + interface MockEmail { 16 + id: string; 17 + address: string; 18 + verified: boolean; 19 + primary: boolean; 20 + added: string; 21 + } 22 + 23 + // mocked — email management is backed by G4 (see TAN-575) 24 + let emails = $state<MockEmail[]>([ 25 + { 26 + id: "1", 27 + address: "eti+dev3@tangled.org", 28 + verified: true, 29 + primary: false, 30 + added: "Added 5 hours ago" 31 + }, 32 + { 33 + id: "2", 34 + address: "eti+dev@tangled.org", 35 + verified: true, 36 + primary: true, 37 + added: "Added 2 months ago" 38 + } 39 + ]); 40 + 41 + const setPrimary = (id: string) => { 42 + emails = emails.map((e) => ({ ...e, primary: e.id === id })); 43 + }; 44 + 45 + const remove = (id: string) => { 46 + emails = emails.filter((e) => e.id !== id); 47 + }; 48 + </script> 49 + 50 + <SettingsHeader 51 + title="Emails" 52 + description="Commits authored using emails listed here will be associated with your Tangled profile." 53 + /> 54 + 55 + <div class="flex w-full flex-col gap-4"> 56 + <SettingsToolbar> 57 + {#snippet end()} 58 + <Button href="/settings/emails/new" icon={Plus}>Add email</Button> 59 + {/snippet} 60 + </SettingsToolbar> 61 + 62 + {#if emails.length === 0} 63 + <EmptyState message="No emails added yet" /> 64 + {:else} 65 + <SettingsList> 66 + {#each emails as email (email.id)} 67 + <SettingsEntry icon={Mail} title={email.address}> 68 + {#snippet tags()} 69 + {#if email.verified} 70 + <Tag color="success" size="sm" icon={Check}>Verified</Tag> 71 + {/if} 72 + {#if email.primary} 73 + <Tag color="info" size="sm" icon={Star}>Primary</Tag> 74 + {/if} 75 + {/snippet} 76 + {#snippet meta()} 77 + <span class="typography-paragraph-regular text-foreground-muted">{email.added}</span> 78 + {/snippet} 79 + {#snippet actions()} 80 + {#if !email.primary} 81 + <Button icon={Star} onclick={() => setPrimary(email.id)}>Set as primary</Button> 82 + {/if} 83 + <Button variant="danger" icon={Trash} onclick={() => remove(email.id)}>Delete</Button> 84 + {/snippet} 85 + </SettingsEntry> 86 + {/each} 87 + </SettingsList> 88 + {/if} 12 89 </div>
+47 -88
web/src/lib/components/settings/tabs/KeysTab.svelte
··· 3 3 import { createBobbinClient } from "$lib/api/client"; 4 4 import { 5 5 listPubKeys, 6 - createPubKey, 7 6 deletePubKey, 8 7 type ListedRecord, 9 8 type PublicKeyRecord 10 9 } from "$lib/api/settings"; 11 - import Section from "$lib/components/ui/Section.svelte"; 12 10 import Button from "$lib/components/ui/Button.svelte"; 13 11 import Spinner from "$lib/components/ui/Spinner.svelte"; 14 12 import ErrorAlert from "$lib/components/ui/Error.svelte"; 15 - import KeyCard from "../KeyCard.svelte"; 16 - import AddKeyForm from "../AddKeyForm.svelte"; 13 + import EmptyState from "$lib/components/ui/EmptyState.svelte"; 14 + import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 15 + import SettingsHeader from "../SettingsHeader.svelte"; 16 + import SettingsToolbar from "../SettingsToolbar.svelte"; 17 + import SettingsList from "../SettingsList.svelte"; 18 + import SettingsEntry from "../SettingsEntry.svelte"; 17 19 import Plus from "$icon/plus"; 20 + import Trash from "$icon/trash-2"; 21 + import Key from "$icon/key"; 18 22 19 23 const auth = getAuth(); 20 24 const user = $derived(auth.currentUser); ··· 23 27 let fingerprints = $state<Record<string, string>>({}); 24 28 let loading = $state(true); 25 29 let loadError = $state<string | null>(null); 26 - 27 - let popover = $state<HTMLElement>(); 28 - let name = $state(""); 29 - let key = $state(""); 30 - let submitting = $state(false); 31 - let formError = $state<string | null>(null); 32 30 let deleting = $state<string | null>(null); 33 31 34 32 // ssh SHA256 fingerprint of the base64 key blob, matching `ssh-keygen -lf`. ··· 70 68 if (user?.did) void load(); 71 69 }); 72 70 73 - const resetForm = () => { 74 - name = ""; 75 - key = ""; 76 - formError = null; 77 - }; 78 - 79 - const add = async () => { 80 - if (!auth.agent) return; 81 - submitting = true; 82 - formError = null; 83 - try { 84 - const trimmedName = name.trim(); 85 - const trimmedKey = key.trim(); 86 - const rkey = await createPubKey(auth.agent, trimmedName, trimmedKey); 87 - const createdAt = new Date().toISOString(); 88 - // prepend to local state (newest first) instead of refetching everything. 89 - keys = [ 90 - { 91 - uri: `at://${auth.agent.sub}/sh.tangled.publicKey/${rkey}`, 92 - rkey, 93 - value: { $type: "sh.tangled.publicKey", name: trimmedName, key: trimmedKey, createdAt } 94 - }, 95 - ...keys 96 - ]; 97 - const fp = await sshFingerprint(trimmedKey); 98 - if (fp) fingerprints = { ...fingerprints, [rkey]: fp }; 99 - resetForm(); 100 - popover?.hidePopover(); 101 - } catch (err) { 102 - formError = err instanceof Error ? err.message : "Failed to add key"; 103 - } finally { 104 - submitting = false; 105 - } 106 - }; 107 - 108 71 const remove = async (k: ListedRecord<PublicKeyRecord>) => { 109 72 if (!auth.agent) return; 110 73 if (!confirm(`Are you sure you want to delete the key ${k.value.name}?`)) return; ··· 121 84 }; 122 85 </script> 123 86 124 - <div class="flex flex-col gap-4"> 125 - <div> 126 - <div class="flex items-center justify-between gap-4"> 127 - <h2 class="typography-paragraph-large font-medium text-foreground-default">SSH keys</h2> 128 - <Button variant="primary" icon={Plus} popovertarget="add-key-popover">Add</Button> 129 - </div> 130 - <p class="pt-4 text-sm text-foreground-subtle"> 131 - SSH public keys added here will be broadcasted to knots that you are a member of, allowing you 132 - to push to repositories there and verify commit signatures. 133 - </p> 134 - </div> 87 + <SettingsHeader 88 + title="Keys" 89 + description="SSH public keys added here will be broadcasted to knots that you are a member of, allowing you to push to repositories there and verify commit signatures." 90 + /> 135 91 136 - <div 137 - bind:this={popover} 138 - id="add-key-popover" 139 - popover="auto" 140 - ontoggle={(e) => { 141 - if ((e as ToggleEvent).newState === "closed") resetForm(); 142 - }} 143 - class="m-auto w-96 max-w-[90vw] rounded border border-border-default bg-background-default p-4 text-foreground-default shadow-lg backdrop:bg-black/40" 144 - > 145 - <AddKeyForm 146 - bind:name 147 - bind:key 148 - {submitting} 149 - error={formError} 150 - onSubmit={add} 151 - onCancel={() => popover?.hidePopover()} 152 - /> 153 - </div> 92 + <div class="flex w-full flex-col gap-4"> 93 + <SettingsToolbar> 94 + {#snippet end()} 95 + <Button href="/settings/keys/new" icon={Plus}>Add key</Button> 96 + {/snippet} 97 + </SettingsToolbar> 154 98 155 99 {#if loadError} 156 100 <ErrorAlert label={loadError} /> 157 101 {/if} 158 102 159 103 {#if loading} 160 - <div class="flex items-center justify-center gap-2 p-4 text-sm text-foreground-subtle"> 104 + <div 105 + class="flex items-center justify-center gap-2 p-4 typography-paragraph-regular text-foreground-subtle" 106 + > 161 107 <Spinner /> Loading… 162 108 </div> 109 + {:else if keys.length === 0} 110 + <EmptyState message="No keys added yet" /> 163 111 {:else} 164 - <Section 165 - empty={keys.length === 0} 166 - emptyMessage="No keys added yet." 167 - listClass="flex flex-col gap-4" 168 - > 112 + <SettingsList> 169 113 {#each keys as k (k.rkey)} 170 - <KeyCard 171 - name={k.value.name} 172 - fingerprint={fingerprints[k.rkey]} 173 - createdAt={k.value.createdAt} 174 - deleting={deleting === k.rkey} 175 - onDelete={() => remove(k)} 176 - /> 114 + <SettingsEntry icon={Key} title={k.value.name}> 115 + {#snippet meta()} 116 + {#if fingerprints[k.rkey]} 117 + <span class="font-mono typography-monospace-regular break-all text-foreground-muted"> 118 + {fingerprints[k.rkey]} 119 + </span> 120 + {/if} 121 + <span class="typography-paragraph-regular text-foreground-muted"> 122 + added <TimeAgo value={k.value.createdAt} variant="full" /> 123 + </span> 124 + {/snippet} 125 + {#snippet actions()} 126 + <Button 127 + variant="danger" 128 + icon={Trash} 129 + loading={deleting === k.rkey} 130 + onclick={() => remove(k)} 131 + > 132 + Delete 133 + </Button> 134 + {/snippet} 135 + </SettingsEntry> 177 136 {/each} 178 - </Section> 137 + </SettingsList> 179 138 {/if} 180 139 </div>
+79 -12
web/src/lib/components/settings/tabs/KnotsTab.svelte
··· 1 - <div> 2 - <h2 class="typography-paragraph-large font-medium text-foreground-default">Knots</h2> 3 - <p class="text-sm text-foreground-subtle"> 4 - Knots are lightweight headless servers that enable users to host Git repositories with ease. 5 - When creating a repository, you can choose a knot to store it on. 6 - </p> 7 - <!-- TODO: register and manage knots --> 8 - <div 9 - class="mt-2 flex items-center justify-center rounded border border-border-default p-4 text-sm text-foreground-subtle" 10 - > 11 - Coming soon 12 - </div> 1 + <script lang="ts"> 2 + import Button from "$lib/components/ui/Button.svelte"; 3 + import Tag from "$lib/components/ui/Tag.svelte"; 4 + import EmptyState from "$lib/components/ui/EmptyState.svelte"; 5 + import SettingsHeader from "../SettingsHeader.svelte"; 6 + import SettingsToolbar from "../SettingsToolbar.svelte"; 7 + import SettingsList from "../SettingsList.svelte"; 8 + import SettingsEntry from "../SettingsEntry.svelte"; 9 + import Plus from "$icon/plus"; 10 + import Trash from "$icon/trash-2"; 11 + import DocsButton from "../DocsButton.svelte"; 12 + import Server from "$icon/server"; 13 + import Check from "$icon/check"; 14 + import X from "$icon/x"; 15 + import RotateCcw from "$icon/rotate-ccw"; 16 + 17 + interface MockKnot { 18 + id: string; 19 + hostname: string; 20 + verified: boolean; 21 + added: string; 22 + } 23 + 24 + // mocked — knot registration is not wired up yet 25 + let knots = $state<MockKnot[]>([ 26 + { id: "1", hostname: "knot2.user.tld", verified: true, added: "Added 5 hours ago" }, 27 + { id: "2", hostname: "knot.user.tld", verified: false, added: "Added 6 months ago" } 28 + ]); 29 + 30 + const remove = (id: string) => { 31 + knots = knots.filter((k) => k.id !== id); 32 + }; 33 + 34 + const retry = (id: string) => { 35 + knots = knots.map((k) => (k.id === id ? { ...k, verified: true } : k)); 36 + }; 37 + </script> 38 + 39 + <SettingsHeader 40 + title="Knots" 41 + description="Knots are lightweight headless servers that enable users to host Git repositories with ease. When creating a repository, you can choose a knot to store it on." 42 + /> 43 + 44 + <div class="flex w-full flex-col gap-4"> 45 + <SettingsToolbar> 46 + {#snippet start()} 47 + <DocsButton href="https://docs.tangled.org/knots" /> 48 + {/snippet} 49 + {#snippet end()} 50 + <Button href="/settings/knots/new" icon={Plus}>Register a knot</Button> 51 + {/snippet} 52 + </SettingsToolbar> 53 + 54 + {#if knots.length === 0} 55 + <EmptyState message="No knots registered yet" /> 56 + {:else} 57 + <SettingsList> 58 + {#each knots as knot (knot.id)} 59 + <SettingsEntry icon={Server} title={knot.hostname}> 60 + {#snippet tags()} 61 + {#if knot.verified} 62 + <Tag color="success" size="sm" icon={Check}>Verified</Tag> 63 + {:else} 64 + <Tag color="warning" size="sm" icon={X}>Unverified</Tag> 65 + {/if} 66 + {/snippet} 67 + {#snippet meta()} 68 + <span class="typography-paragraph-regular text-foreground-muted">{knot.added}</span> 69 + {/snippet} 70 + {#snippet actions()} 71 + {#if !knot.verified} 72 + <Button icon={RotateCcw} onclick={() => retry(knot.id)}>Retry</Button> 73 + {/if} 74 + <Button variant="danger" icon={Trash} onclick={() => remove(knot.id)}>Delete</Button> 75 + {/snippet} 76 + </SettingsEntry> 77 + {/each} 78 + </SettingsList> 79 + {/if} 13 80 </div>
+129 -15
web/src/lib/components/settings/tabs/NotificationsTab.svelte
··· 1 - <div> 2 - <h2 class="typography-paragraph-large font-medium text-foreground-default"> 3 - Notification preferences 4 - </h2> 5 - <p class="text-sm text-foreground-subtle"> 6 - Choose which notifications you want to receive when activity happens on your repositories and 7 - profile. 8 - </p> 9 - <!-- TODO: notification preference toggles --> 10 - <div 11 - class="mt-2 flex items-center justify-center rounded border border-border-default p-4 text-sm text-foreground-subtle" 12 - > 13 - Coming soon 14 - </div> 15 - </div> 1 + <script lang="ts"> 2 + import type { Component } from "svelte"; 3 + import type { SvelteHTMLElements } from "svelte/elements"; 4 + import Button from "$lib/components/ui/Button.svelte"; 5 + import Toggle from "$lib/components/ui/Toggle.svelte"; 6 + import SettingsHeader from "../SettingsHeader.svelte"; 7 + import SettingsList from "../SettingsList.svelte"; 8 + import Save from "$icon/save"; 9 + import Star from "$icon/star"; 10 + import CircleDot from "$icon/circle-dot"; 11 + import MessageSquare from "$icon/message-square"; 12 + import CircleSlash from "$icon/circle-slash"; 13 + import GitPullRequest from "$icon/git-pull-request"; 14 + import GitMerge from "$icon/git-merge"; 15 + import UserRound from "$icon/user-round"; 16 + import AtSign from "$icon/at-sign"; 17 + import Mail from "$icon/mail"; 18 + 19 + interface Pref { 20 + id: string; 21 + icon: Component<SvelteHTMLElements["svg"]>; 22 + label: string; 23 + description: string; 24 + enabled: boolean; 25 + } 26 + 27 + // mocked — notification preferences are backed by G2 (see TAN-575) 28 + let prefs = $state<Pref[]>([ 29 + { 30 + id: "starred", 31 + icon: Star, 32 + label: "Repository starred", 33 + description: "When someone stars your repository.", 34 + enabled: true 35 + }, 36 + { 37 + id: "new-issues", 38 + icon: CircleDot, 39 + label: "New issues", 40 + description: "When someone creates an issue on your repository.", 41 + enabled: true 42 + }, 43 + { 44 + id: "issue-comments", 45 + icon: MessageSquare, 46 + label: "Issue comments", 47 + description: "When someone comments on an issue you're involved with.", 48 + enabled: true 49 + }, 50 + { 51 + id: "issue-closed", 52 + icon: CircleSlash, 53 + label: "Issue closed", 54 + description: "When an issue on your repository is closed.", 55 + enabled: true 56 + }, 57 + { 58 + id: "new-pulls", 59 + icon: GitPullRequest, 60 + label: "New pull requests", 61 + description: "When someone creates a pull request on your repository.", 62 + enabled: true 63 + }, 64 + { 65 + id: "pull-comments", 66 + icon: MessageSquare, 67 + label: "Pull request comments", 68 + description: "When someone comments on a pull request you're involved with.", 69 + enabled: true 70 + }, 71 + { 72 + id: "pull-merged", 73 + icon: GitMerge, 74 + label: "Pull request merged", 75 + description: "When your pull request is merged.", 76 + enabled: true 77 + }, 78 + { 79 + id: "followers", 80 + icon: UserRound, 81 + label: "New followers", 82 + description: "When someone follows you.", 83 + enabled: true 84 + }, 85 + { 86 + id: "mentions", 87 + icon: AtSign, 88 + label: "Mentions", 89 + description: "When someone mentions you.", 90 + enabled: true 91 + }, 92 + { 93 + id: "email", 94 + icon: Mail, 95 + label: "Email notifications", 96 + description: "Receive digest emails for issue and pull request activity.", 97 + enabled: true 98 + } 99 + ]); 100 + 101 + // Figma shows Save disabled on load — it only lights up once something changed 102 + const initial = prefs.map((p) => p.enabled); 103 + const dirty = $derived(prefs.some((p, i) => p.enabled !== initial[i])); 104 + </script> 105 + 106 + <SettingsHeader 107 + title="Notifications" 108 + description="Choose which notifications you want to receive when activity happens on your repositories and profile." 109 + > 110 + {#snippet action()} 111 + <Button variant="primary" icon={Save} disabled={!dirty}>Save</Button> 112 + {/snippet} 113 + </SettingsHeader> 114 + 115 + <SettingsList> 116 + {#each prefs as pref (pref.id)} 117 + {@const Glyph = pref.icon} 118 + <div class="flex w-full items-center justify-between gap-4"> 119 + <div class="flex min-w-0 flex-col gap-1"> 120 + <span class="flex items-center gap-2"> 121 + <Glyph class="size-3.5 shrink-0 text-foreground-default" aria-hidden="true" /> 122 + <span class="typography-paragraph-regular text-foreground-default">{pref.label}</span> 123 + </span> 124 + <span class="typography-paragraph-regular text-foreground-muted">{pref.description}</span> 125 + </div> 126 + <Toggle bind:checked={pref.enabled} aria-label={pref.label} /> 127 + </div> 128 + {/each} 129 + </SettingsList>
+136 -18
web/src/lib/components/settings/tabs/ProfileTab.svelte
··· 2 2 import { getAuth } from "$lib/auth.svelte"; 3 3 import { createBobbinClient } from "$lib/api/client"; 4 4 import { resolveMiniDoc } from "$lib/api/identity"; 5 + import Avatar from "$lib/components/ui/Avatar.svelte"; 6 + import Button from "$lib/components/ui/Button.svelte"; 7 + import Input from "$lib/components/ui/Input.svelte"; 8 + import Select from "$lib/components/ui/Select.svelte"; 9 + import Toggle from "$lib/components/ui/Toggle.svelte"; 10 + import SettingsSection from "../SettingsSection.svelte"; 11 + import SettingsRow from "../SettingsRow.svelte"; 12 + import CodeChip from "../CodeChip.svelte"; 13 + import ThemePicker from "../ThemePicker.svelte"; 14 + import Pencil from "$icon/pencil"; 15 + import Save from "$icon/save"; 16 + import RotateCcw from "$icon/rotate-ccw"; 17 + import Key from "$icon/key"; 18 + import Pause from "$icon/pause"; 19 + import Trash from "$icon/trash-2"; 5 20 6 21 const auth = getAuth(); 7 22 const user = $derived(auth.currentUser); ··· 24 39 cancelled = true; 25 40 }; 26 41 }); 42 + 43 + // everything below is mocked — nothing is persisted yet 44 + const EMPTY = { 45 + bio: "", 46 + pronouns: "", 47 + location: "", 48 + bluesky: true, 49 + links: ["", "", ""], 50 + stats: ["", ""], 51 + hideMine: false, 52 + hideOthers: false, 53 + theme: "light" as "auto" | "light" | "dark" 54 + }; 55 + 56 + let form = $state(structuredClone(EMPTY)); 57 + 58 + // Figma keeps Save disabled until something actually changed, and only then 59 + // reveals Reset and the "Unsaved changes" note 60 + const dirty = $derived(JSON.stringify(form) !== JSON.stringify(EMPTY)); 61 + const reset = () => (form = structuredClone(EMPTY)); 62 + 63 + const STATS = ["Repositories", "Followers", "Stars", "Pull requests", "Issues"]; 27 64 </script> 28 65 29 - <div> 30 - <h2 class="typography-paragraph-large font-medium text-foreground-default">Profile</h2> 31 - <p class="pb-2 text-sm text-foreground-subtle"> 32 - Your account information from your AT Protocol identity. 33 - </p> 66 + {#snippet actions()} 67 + <div class="flex items-center gap-4"> 68 + {#if dirty} 69 + <Button icon={RotateCcw} onclick={reset}>Reset</Button> 70 + <span class="typography-paragraph-regular text-foreground-muted">Unsaved changes</span> 71 + {/if} 72 + <Button variant="primary" icon={Save} disabled={!dirty}>Save</Button> 73 + </div> 74 + {/snippet} 34 75 35 - <div class="flex flex-col divide-y divide-border-default rounded border border-border-default"> 36 - <div class="flex flex-col gap-1 p-4"> 37 - <span class="text-sm text-foreground-subtle">Handle</span> 38 - <span class="font-bold text-foreground-default">{user?.handle ?? "…"}</span> 39 - </div> 40 - <div class="flex flex-col gap-1 p-4"> 41 - <span class="text-sm text-foreground-subtle">Decentralized Identifier (DID)</span> 42 - <span class="font-mono font-bold break-all text-foreground-default">{user?.did ?? "…"}</span> 76 + <div class="flex min-h-9 w-full items-center justify-between gap-4"> 77 + <h1 class="typography-heading-2 text-foreground-default">Profile</h1> 78 + {@render actions()} 79 + </div> 80 + 81 + <div class="flex w-full flex-col gap-10"> 82 + <SettingsSection> 83 + <div class="flex w-full flex-col items-center gap-2 pb-4"> 84 + <Avatar did={user?.did} handle={user?.handle} size="size-[158px]" /> 85 + <Button icon={Pencil} href="/settings/profile/avatar">Edit</Button> 43 86 </div> 44 - <div class="flex flex-col gap-1 p-4"> 45 - <span class="text-sm text-foreground-subtle">Personal Data Server (PDS)</span> 46 - <span class="font-bold break-all text-foreground-default">{pds ?? "…"}</span> 47 - </div> 48 - </div> 87 + <SettingsRow title="Bio"> 88 + <Input bind:value={form.bio} placeholder="Write a bio" class="w-80" /> 89 + </SettingsRow> 90 + <SettingsRow title="Pronouns"> 91 + <Input bind:value={form.pronouns} placeholder="they/them" class="w-80" /> 92 + </SettingsRow> 93 + <SettingsRow title="Location"> 94 + <Input bind:value={form.location} placeholder="Anywhere" class="w-80" /> 95 + </SettingsRow> 96 + <SettingsRow title="Link to Bluesky account"> 97 + <Toggle bind:checked={form.bluesky} aria-label="Link to Bluesky account" /> 98 + </SettingsRow> 99 + <SettingsRow title="Social links" class="!items-start"> 100 + <div class="flex w-80 flex-col gap-2"> 101 + {#each form.links.keys() as i (i)} 102 + <Input bind:value={form.links[i]} placeholder="Social link" /> 103 + {/each} 104 + </div> 105 + </SettingsRow> 106 + <SettingsRow title="Vanity stats" class="!items-start"> 107 + <div class="flex w-80 flex-col gap-2"> 108 + {#each form.stats.keys() as i (i)} 109 + <Select bind:value={form.stats[i]} aria-label="Vanity stat {i + 1}"> 110 + <option value="">Choose stat</option> 111 + {#each STATS as stat (stat)} 112 + <option value={stat}>{stat}</option> 113 + {/each} 114 + </Select> 115 + {/each} 116 + </div> 117 + </SettingsRow> 118 + </SettingsSection> 119 + 120 + <SettingsSection title="Account"> 121 + <SettingsRow title="Handle"> 122 + <CodeChip>{user?.handle ?? "…"}</CodeChip> 123 + <Button variant="ghost" href="/settings/profile/handle">Change</Button> 124 + </SettingsRow> 125 + <SettingsRow title="Decentralized Identifier (DID)"> 126 + <CodeChip class="break-all">{user?.did ?? "…"}</CodeChip> 127 + </SettingsRow> 128 + <SettingsRow title="Personal Data Server (PDS)"> 129 + <CodeChip class="break-all">{pds ?? "…"}</CodeChip> 130 + </SettingsRow> 131 + </SettingsSection> 132 + 133 + <SettingsSection title="Interface"> 134 + <SettingsRow title="Theme"> 135 + <ThemePicker bind:value={form.theme} /> 136 + </SettingsRow> 137 + </SettingsSection> 138 + 139 + <SettingsSection title="Punchcard"> 140 + <SettingsRow title="Hide mine"> 141 + <Toggle bind:checked={form.hideMine} aria-label="Hide mine" /> 142 + </SettingsRow> 143 + <SettingsRow title="Hide other from me"> 144 + <Toggle bind:checked={form.hideOthers} aria-label="Hide other from me" /> 145 + </SettingsRow> 146 + </SettingsSection> 147 + 148 + <hr class="w-full border-0 border-t border-border-default" /> 149 + 150 + <SettingsSection title="Danger zone"> 151 + <SettingsRow title="Password"> 152 + <Button icon={Key} href="/settings/profile/password">Change password</Button> 153 + </SettingsRow> 154 + <SettingsRow title="Deactivate"> 155 + <Button variant="warning" icon={Pause} href="/settings/profile/deactivate"> 156 + Deactivate account 157 + </Button> 158 + </SettingsRow> 159 + <SettingsRow title="Delete"> 160 + <Button variant="danger" icon={Trash} href="/settings/profile/delete">Delete account</Button> 161 + </SettingsRow> 162 + </SettingsSection> 163 + </div> 164 + 165 + <div class="flex w-full justify-end"> 166 + {@render actions()} 49 167 </div>
+57 -3
web/src/lib/components/settings/tabs/SitesTab.svelte
··· 1 - <div> 2 - <h2 class="typography-paragraph-large font-medium text-foreground-default">Git sites</h2> 3 - <!-- TODO: claim and manage a static site subdomain --> 1 + <script lang="ts"> 2 + import Button from "$lib/components/ui/Button.svelte"; 3 + import Tag from "$lib/components/ui/Tag.svelte"; 4 + import EmptyState from "$lib/components/ui/EmptyState.svelte"; 5 + import SettingsHeader from "../SettingsHeader.svelte"; 6 + import SettingsToolbar from "../SettingsToolbar.svelte"; 7 + import SettingsList from "../SettingsList.svelte"; 8 + import SettingsEntry from "../SettingsEntry.svelte"; 9 + import DocsButton from "../DocsButton.svelte"; 10 + import Globe from "$icon/globe"; 11 + import Check from "$icon/check"; 12 + import Unlink from "$icon/unlink"; 13 + 14 + interface MockSite { 15 + id: string; 16 + domain: string; 17 + active: boolean; 18 + } 19 + 20 + // mocked — domain claims are backed by G7 (see TAN-575) 21 + let sites = $state<MockSite[]>([{ id: "1", domain: "user.tngl.sh", active: true }]); 22 + 23 + const release = (id: string) => { 24 + sites = sites.filter((s) => s.id !== id); 25 + }; 26 + </script> 27 + 28 + <SettingsHeader 29 + title="Sites" 30 + description="Since your handle is on tngl.sh, it doubles as your sites domain—your site will be served from that subdomain automatically." 31 + /> 32 + 33 + <div class="flex w-full flex-col gap-4"> 34 + <SettingsToolbar> 35 + {#snippet start()} 36 + <DocsButton href="https://docs.tangled.org/sites" /> 37 + {/snippet} 38 + </SettingsToolbar> 39 + 40 + {#if sites.length === 0} 41 + <EmptyState message="No sites claimed yet" /> 42 + {:else} 43 + <SettingsList> 44 + {#each sites as site (site.id)} 45 + <SettingsEntry icon={Globe} title={site.domain}> 46 + {#snippet tags()} 47 + {#if site.active} 48 + <Tag color="success" size="sm" icon={Check}>Active</Tag> 49 + {/if} 50 + {/snippet} 51 + {#snippet actions()} 52 + <Button variant="danger" icon={Unlink} onclick={() => release(site.id)}>Release</Button> 53 + {/snippet} 54 + </SettingsEntry> 55 + {/each} 56 + </SettingsList> 57 + {/if} 4 58 </div>
+76 -11
web/src/lib/components/settings/tabs/SpindlesTab.svelte
··· 1 - <div> 2 - <h2 class="typography-paragraph-large font-medium text-foreground-default">Spindles</h2> 3 - <p class="text-sm text-foreground-subtle"> 4 - Spindles are servers that run continuous integration pipelines for your repositories. 5 - </p> 6 - <!-- TODO: register and manage spindles --> 7 - <div 8 - class="mt-2 flex items-center justify-center rounded border border-border-default p-4 text-sm text-foreground-subtle" 9 - > 10 - Coming soon 11 - </div> 1 + <script lang="ts"> 2 + import Button from "$lib/components/ui/Button.svelte"; 3 + import Tag from "$lib/components/ui/Tag.svelte"; 4 + import EmptyState from "$lib/components/ui/EmptyState.svelte"; 5 + import SettingsHeader from "../SettingsHeader.svelte"; 6 + import SettingsToolbar from "../SettingsToolbar.svelte"; 7 + import SettingsList from "../SettingsList.svelte"; 8 + import SettingsEntry from "../SettingsEntry.svelte"; 9 + import Plus from "$icon/plus"; 10 + import Trash from "$icon/trash-2"; 11 + import DocsButton from "../DocsButton.svelte"; 12 + import Spool from "$icon/spool"; 13 + import Check from "$icon/check"; 14 + import X from "$icon/x"; 15 + import RotateCcw from "$icon/rotate-ccw"; 16 + 17 + interface MockSpindle { 18 + id: string; 19 + hostname: string; 20 + verified: boolean; 21 + added: string; 22 + } 23 + 24 + // mocked — spindle registration is not wired up yet 25 + let spindles = $state<MockSpindle[]>([ 26 + { id: "1", hostname: "spindle.user.tld", verified: true, added: "Added 5 hours ago" }, 27 + { id: "2", hostname: "spindle2.user.tld", verified: false, added: "Added 6 months ago" } 28 + ]); 29 + 30 + const remove = (id: string) => { 31 + spindles = spindles.filter((s) => s.id !== id); 32 + }; 33 + 34 + const retry = (id: string) => { 35 + spindles = spindles.map((s) => (s.id === id ? { ...s, verified: true } : s)); 36 + }; 37 + </script> 38 + 39 + <SettingsHeader title="Spindles" description="Spindles are small CI runners." /> 40 + 41 + <div class="flex w-full flex-col gap-4"> 42 + <SettingsToolbar> 43 + {#snippet start()} 44 + <DocsButton href="https://docs.tangled.org/spindles" /> 45 + {/snippet} 46 + {#snippet end()} 47 + <Button href="/settings/spindles/new" icon={Plus}>Register a spindle</Button> 48 + {/snippet} 49 + </SettingsToolbar> 50 + 51 + {#if spindles.length === 0} 52 + <EmptyState message="No spindles registered yet" /> 53 + {:else} 54 + <SettingsList> 55 + {#each spindles as spindle (spindle.id)} 56 + <SettingsEntry icon={Spool} title={spindle.hostname}> 57 + {#snippet tags()} 58 + {#if spindle.verified} 59 + <Tag color="success" size="sm" icon={Check}>Verified</Tag> 60 + {:else} 61 + <Tag color="warning" size="sm" icon={X}>Unverified</Tag> 62 + {/if} 63 + {/snippet} 64 + {#snippet meta()} 65 + <span class="typography-paragraph-regular text-foreground-muted">{spindle.added}</span> 66 + {/snippet} 67 + {#snippet actions()} 68 + {#if !spindle.verified} 69 + <Button icon={RotateCcw} onclick={() => retry(spindle.id)}>Retry</Button> 70 + {/if} 71 + <Button variant="danger" icon={Trash} onclick={() => remove(spindle.id)}>Delete</Button> 72 + {/snippet} 73 + </SettingsEntry> 74 + {/each} 75 + </SettingsList> 76 + {/if} 12 77 </div>
+24 -12
web/src/lib/components/ui/Button.svelte
··· 7 7 "transition-colors duration-150 ease-in-out", 8 8 "hover:no-underline", 9 9 "focus-visible:outline-2 focus-visible:outline-offset-2", 10 - "disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50" 10 + // each variant paints its own disabled surface (see the variant blocks) — 11 + // Figma models disabled as a distinct fill/text pair, not a dimmed enabled state 12 + "disabled:pointer-events-none aria-disabled:pointer-events-none" 11 13 ], 12 14 variants: { 13 15 variant: { 14 16 default: [ 15 17 "border border-border-default bg-background-default text-foreground-default", 16 18 "hover:bg-background-subtle", 17 - "focus-visible:outline-border-focus" 19 + "focus-visible:outline-border-focus", 20 + "disabled:border-transparent disabled:bg-background-subtle disabled:text-foreground-subtle", 21 + "aria-disabled:border-transparent aria-disabled:bg-background-subtle aria-disabled:text-foreground-subtle" 18 22 ], 19 23 primary: [ 20 24 "border border-border-success bg-background-success-emphasis text-foreground-on-emphasis", 21 25 "hover:bg-background-success-emphasis-hover", 22 - "focus-visible:outline-background-success-emphasis" 26 + "focus-visible:outline-background-success-emphasis", 27 + "disabled:border-transparent disabled:bg-background-success-subtle disabled:text-foreground-success-disabled", 28 + "aria-disabled:border-transparent aria-disabled:bg-background-success-subtle aria-disabled:text-foreground-success-disabled" 23 29 ], 24 30 ghost: [ 25 31 "border border-transparent bg-transparent", 26 32 "hover:bg-background-inset", 27 - "focus-visible:outline-border-focus" 33 + "focus-visible:outline-border-focus", 34 + "disabled:text-foreground-disabled aria-disabled:text-foreground-disabled" 28 35 ], 29 36 // danger / success / warning are outline buttons in Figma: default surface + 30 - // border, colored text only (bound to the fg-* tokens), no accent fill. 37 + // border, colored text only (bound to the fg-* tokens), no accent fill. They 38 + // keep that surface when disabled and only fade the label — the outline is 39 + // what carries the variant, so dropping it would read as a different button. 31 40 danger: [ 32 41 "border border-border-default bg-background-default text-foreground-danger", 33 42 "hover:bg-background-subtle", 34 - "focus-visible:outline-foreground-danger" 43 + "focus-visible:outline-foreground-danger", 44 + "disabled:text-foreground-danger-disabled aria-disabled:text-foreground-danger-disabled" 35 45 ], 36 46 success: [ 37 47 "border border-border-default bg-background-default text-foreground-success", 38 48 "hover:bg-background-subtle", 39 - "focus-visible:outline-foreground-success" 49 + "focus-visible:outline-foreground-success", 50 + "disabled:text-foreground-success-disabled aria-disabled:text-foreground-success-disabled" 40 51 ], 41 52 warning: [ 42 53 "border border-border-default bg-background-default text-foreground-warning", 43 54 "hover:bg-background-subtle", 44 - "focus-visible:outline-foreground-warning" 55 + "focus-visible:outline-foreground-warning", 56 + "disabled:text-foreground-warning-disabled aria-disabled:text-foreground-warning-disabled" 45 57 ] 46 58 }, 47 59 size: { ··· 61 73 variant: "default", 62 74 insetShadow: true, 63 75 class: [ 64 - "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-l-[max(calc(var(--btn-radius-l)-2px),0px)] before:rounded-r-[max(calc(var(--btn-radius-r)-2px),0px)] before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-subtle)] dark:before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-subtle)] before:transition-all before:duration-150 before:content-['']", 76 + "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-l-[max(calc(var(--btn-radius-l)-2px),0px)] before:rounded-r-[max(calc(var(--btn-radius-r)-2px),0px)] before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-subtle)] before:transition-all before:duration-150 before:content-[''] dark:before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-subtle)]", 65 77 "hover:before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-strong-hover)]", 66 78 "active:*:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_var(--color-shadow-inner-subtle)]", 67 - "disabled:active:*:translate-y-0" 79 + "disabled:before:shadow-none disabled:active:*:translate-y-0" 68 80 ] 69 81 }, 70 82 { 71 83 variant: "primary", 72 84 insetShadow: true, 73 85 class: [ 74 - "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-l-[max(calc(var(--btn-radius-l)-2px),0px)] before:rounded-r-[max(calc(var(--btn-radius-r)-2px),0px)] before:shadow-[inset_0_-2px_0_0_var(--color-alpha-dark-25)] dark:before:shadow-[inset_0_-2px_0_0_var(--color-alpha-light-25)] before:transition-all before:duration-150 before:content-['']", 86 + "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-l-[max(calc(var(--btn-radius-l)-2px),0px)] before:rounded-r-[max(calc(var(--btn-radius-r)-2px),0px)] before:shadow-[inset_0_-2px_0_0_var(--color-alpha-dark-25)] before:transition-all before:duration-150 before:content-[''] dark:before:shadow-[inset_0_-2px_0_0_var(--color-alpha-light-25)]", 75 87 "active:*:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_var(--color-alpha-dark-50)] dark:active:before:shadow-[inset_0_2px_2px_0_var(--color-alpha-light-50)]", 76 - "disabled:active:*:translate-y-0" 88 + "disabled:before:shadow-none disabled:active:*:translate-y-0" 77 89 ] 78 90 } 79 91 ],
+1 -1
web/src/lib/components/ui/Radio.svelte
··· 31 31 {value} 32 32 bind:group 33 33 {disabled} 34 - class="peer size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-border-strong 34 + class="peer size-4 shrink-0 cursor-pointer appearance-none rounded-full border border-foreground-placeholder 35 35 bg-background-default transition-colors 36 36 duration-150 checked:border-foreground-default 37 37 hover:bg-background-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground-default
+24 -5
web/src/lib/components/ui/Tag.svelte
··· 2 2 import { tv, type VariantProps } from "tailwind-variants"; 3 3 4 4 export const tag = tv({ 5 - base: "inline-flex items-center gap-1 rounded px-1.5 py-0.5 typography-paragraph-small leading-none font-medium select-none", 5 + base: "inline-flex items-center gap-1 rounded leading-none font-medium select-none", 6 6 variants: { 7 7 color: { 8 8 default: "border border-border-default bg-background-default text-foreground-default", 9 - gray: "border border-transparent bg-background-inset text-foreground-muted" 9 + gray: "border border-transparent bg-background-inset text-foreground-muted", 10 + // status badges, per Figma's Tag usage on the settings screens 11 + success: 12 + "border border-transparent bg-background-success-subtle text-foreground-success-strong", 13 + warning: "border border-transparent bg-background-warning-subtle text-foreground-warning", 14 + info: "border border-transparent bg-background-info-subtle text-foreground-info-strong" 15 + }, 16 + size: { 17 + /** the DS badge: 16px tall, mini type — what the settings screens use */ 18 + sm: "px-1 typography-paragraph-mini", 19 + md: "px-1.5 py-0.5 typography-paragraph-small" 10 20 } 11 21 }, 12 22 defaultVariants: { 13 - color: "default" 23 + color: "default", 24 + size: "md" 14 25 } 15 26 }); 16 27 ··· 24 35 25 36 interface Props extends Omit<HTMLAttributes<HTMLSpanElement>, "class" | "children"> { 26 37 color?: TagVariants["color"]; 38 + size?: TagVariants["size"]; 27 39 /** Optional leading icon component (e.g. `import Hash from "$icon/hash"`). */ 28 40 icon?: Component<SvelteHTMLElements["svg"]>; 29 41 class?: string; 30 42 children?: Snippet; 31 43 } 32 44 33 - let { color = "default", icon, class: className, children, ...rest }: Props = $props(); 45 + let { 46 + color = "default", 47 + size = "md", 48 + icon, 49 + class: className, 50 + children, 51 + ...rest 52 + }: Props = $props(); 34 53 35 - const classes = $derived(tag({ color, class: className })); 54 + const classes = $derived(tag({ color, size, class: className })); 36 55 const Icon = $derived(icon); 37 56 </script> 38 57
+13 -22
web/src/routes/settings/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import { page } from "$app/state"; 3 - import Tabs, { type TabDef } from "$lib/components/ui/Tabs.svelte"; 4 - import User from "$icon/user"; 3 + import SettingsNav, { type SettingsNavItem } from "$lib/components/settings/SettingsNav.svelte"; 4 + import User from "$icon/user-round"; 5 5 import Key from "$icon/key"; 6 6 import Mail from "$icon/mail"; 7 7 import Bell from "$icon/bell"; ··· 11 11 12 12 let { children } = $props(); 13 13 14 - const tabs: TabDef[] = [ 14 + const items: SettingsNavItem[] = [ 15 15 { id: "profile", label: "Profile", href: "/settings", icon: User }, 16 16 { id: "keys", label: "Keys", href: "/settings/keys", icon: Key }, 17 17 { id: "emails", label: "Emails", href: "/settings/emails", icon: Mail }, ··· 21 21 { id: "sites", label: "Sites", href: "/settings/sites", icon: Globe } 22 22 ]; 23 23 24 - const tabSegment = $derived(page.url.pathname.replace(/^\/settings\/?/, "").split("/")[0]); 25 - const active = $derived( 26 - ["keys", "emails", "notifications", "knots", "spindles", "sites"].includes(tabSegment) 27 - ? tabSegment 28 - : "profile" 29 - ); 24 + // a drill-down keeps its parent tab active, so match on the first segment only 25 + const segment = $derived(page.url.pathname.replace(/^\/settings\/?/, "").split("/")[0]); 26 + const active = $derived(segment && items.some((i) => i.id === segment) ? segment : "profile"); 30 27 </script> 31 28 32 29 <svelte:head> 33 30 <title>Settings &middot; Tangled</title> 34 31 </svelte:head> 35 32 36 - <div class="mx-auto w-full max-w-screen-lg px-4 py-12"> 37 - <div class="px-2 py-2"> 38 - <h1 class="typography-heading-4 font-bold text-foreground-default">Settings</h1> 39 - </div> 33 + <div class="flex w-full flex-col items-center px-4 pt-16 pb-32"> 34 + <div 35 + class="flex min-h-[600px] w-full max-w-[1200px] flex-col items-stretch gap-4 overflow-hidden rounded-sm border border-border-default bg-background-default md:flex-row md:items-start" 36 + > 37 + <SettingsNav {items} {active} /> 40 38 41 - <div class="mt-4 rounded border border-border-default bg-background-default p-6"> 42 - <div class="grid grid-cols-1 gap-6 md:grid-cols-4"> 43 - <div class="col-span-1"> 44 - <Tabs {tabs} {active} label="Settings" vertical /> 45 - </div> 46 - 47 - <div class="col-span-1 flex flex-col gap-6 md:col-span-3"> 48 - {@render children()} 49 - </div> 39 + <div class="flex min-w-0 flex-1 flex-col gap-8 px-4 py-6"> 40 + {@render children()} 50 41 </div> 51 42 </div> 52 43 </div>
+36
web/src/routes/settings/emails/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from "$app/navigation"; 3 + import { resolve } from "$app/paths"; 4 + import Button from "$lib/components/ui/Button.svelte"; 5 + import Input from "$lib/components/ui/Input.svelte"; 6 + import DrillDown from "$lib/components/settings/DrillDown.svelte"; 7 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 8 + import FormRow from "$lib/components/settings/FormRow.svelte"; 9 + import FormActions from "$lib/components/settings/FormActions.svelte"; 10 + import Plus from "$icon/plus"; 11 + import X from "$icon/x"; 12 + 13 + let email = $state(""); 14 + const valid = $derived(/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())); 15 + 16 + // mocked — sends nothing until the G4 email service is wired up 17 + const submit = () => goto(resolve("/settings/emails")); 18 + </script> 19 + 20 + <DrillDown 21 + backLabel="Emails" 22 + backHref="/settings/emails" 23 + title="Add email" 24 + description="Commits using this email will be associated with your profile." 25 + > 26 + <SettingsList padding="tight"> 27 + <FormRow label="Email" for="email-address"> 28 + <Input id="email-address" type="email" bind:value={email} placeholder="your@email.com" /> 29 + </FormRow> 30 + </SettingsList> 31 + 32 + <FormActions> 33 + <Button href="/settings/emails" icon={X}>Cancel</Button> 34 + <Button variant="primary" icon={Plus} disabled={!valid} onclick={submit}>Add</Button> 35 + </FormActions> 36 + </DrillDown>
+64
web/src/routes/settings/keys/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from "$app/navigation"; 3 + import { resolve } from "$app/paths"; 4 + import { getAuth } from "$lib/auth.svelte"; 5 + import { createPubKey } from "$lib/api/settings"; 6 + import Button from "$lib/components/ui/Button.svelte"; 7 + import Input from "$lib/components/ui/Input.svelte"; 8 + import ErrorAlert from "$lib/components/ui/Error.svelte"; 9 + import DrillDown from "$lib/components/settings/DrillDown.svelte"; 10 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 11 + import FormRow from "$lib/components/settings/FormRow.svelte"; 12 + import FormActions from "$lib/components/settings/FormActions.svelte"; 13 + import Plus from "$icon/plus"; 14 + import X from "$icon/x"; 15 + 16 + const auth = getAuth(); 17 + 18 + let name = $state(""); 19 + let key = $state(""); 20 + let submitting = $state(false); 21 + let error = $state<string | null>(null); 22 + 23 + const valid = $derived(name.trim().length > 0 && key.trim().length > 0); 24 + 25 + const submit = async () => { 26 + if (!auth.agent || !valid) return; 27 + submitting = true; 28 + error = null; 29 + try { 30 + await createPubKey(auth.agent, name.trim(), key.trim()); 31 + await goto(resolve("/settings/keys")); 32 + } catch (err) { 33 + error = err instanceof Error ? err.message : "Failed to add key"; 34 + submitting = false; 35 + } 36 + }; 37 + </script> 38 + 39 + <DrillDown 40 + backLabel="Keys" 41 + backHref="/settings/keys" 42 + title="Add SSH key" 43 + description="SSH keys allow you to push to repositories in knots you're a member of." 44 + > 45 + {#if error} 46 + <ErrorAlert label={error} /> 47 + {/if} 48 + 49 + <SettingsList padding="tight"> 50 + <FormRow label="Title" for="key-title"> 51 + <Input id="key-title" bind:value={name} placeholder="Work laptop" /> 52 + </FormRow> 53 + <FormRow label="Key" for="key-value"> 54 + <Input id="key-value" bind:value={key} placeholder="ssh-ed25519 AAAAB3NzaC1yc2E…" /> 55 + </FormRow> 56 + </SettingsList> 57 + 58 + <FormActions> 59 + <Button href="/settings/keys" icon={X}>Cancel</Button> 60 + <Button variant="primary" icon={Plus} disabled={!valid} loading={submitting} onclick={submit}> 61 + Add 62 + </Button> 63 + </FormActions> 64 + </DrillDown>
+36
web/src/routes/settings/knots/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from "$app/navigation"; 3 + import { resolve } from "$app/paths"; 4 + import Button from "$lib/components/ui/Button.svelte"; 5 + import Input from "$lib/components/ui/Input.svelte"; 6 + import DrillDown from "$lib/components/settings/DrillDown.svelte"; 7 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 8 + import FormRow from "$lib/components/settings/FormRow.svelte"; 9 + import FormActions from "$lib/components/settings/FormActions.svelte"; 10 + import Plus from "$icon/plus"; 11 + import X from "$icon/x"; 12 + 13 + let address = $state(""); 14 + const valid = $derived(address.trim().length > 0); 15 + 16 + // mocked — knot registration is not wired up yet 17 + const submit = () => goto(resolve("/settings/knots")); 18 + </script> 19 + 20 + <DrillDown 21 + backLabel="Knots" 22 + backHref="/settings/knots" 23 + title="Register a knot" 24 + description="Enter the hostname of your knot to get started." 25 + > 26 + <SettingsList padding="tight"> 27 + <FormRow label="Knot address" for="knot-address"> 28 + <Input id="knot-address" bind:value={address} placeholder="knot.example.com" /> 29 + </FormRow> 30 + </SettingsList> 31 + 32 + <FormActions> 33 + <Button href="/settings/knots" icon={X}>Cancel</Button> 34 + <Button variant="primary" icon={Plus} disabled={!valid} onclick={submit}>Add</Button> 35 + </FormActions> 36 + </DrillDown>
+52
web/src/routes/settings/profile/avatar/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from "$app/navigation"; 3 + import { resolve } from "$app/paths"; 4 + import Button from "$lib/components/ui/Button.svelte"; 5 + import DrillDown from "$lib/components/settings/DrillDown.svelte"; 6 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 7 + import FormRow from "$lib/components/settings/FormRow.svelte"; 8 + import FormActions from "$lib/components/settings/FormActions.svelte"; 9 + import Image from "$icon/image"; 10 + import Upload from "$icon/upload"; 11 + import Trash from "$icon/trash-2"; 12 + import X from "$icon/x"; 13 + 14 + let input = $state<HTMLInputElement>(); 15 + let file = $state<File | null>(null); 16 + 17 + // mocked — no upload endpoint yet 18 + const submit = () => goto(resolve("/settings")); 19 + </script> 20 + 21 + <DrillDown 22 + backLabel="Profile" 23 + backHref="/settings" 24 + title="Upload or remove avatar" 25 + description="Upload a new image (PNG or JPEG, max 5MB) or remove your current avatar." 26 + > 27 + <SettingsList padding="tight"> 28 + <FormRow label="Avatar"> 29 + <div class="flex items-center justify-end gap-4"> 30 + <span class="typography-paragraph-regular text-foreground-muted"> 31 + {file ? file.name : "No file selected."} 32 + </span> 33 + <input 34 + bind:this={input} 35 + type="file" 36 + accept="image/png,image/jpeg" 37 + class="hidden" 38 + onchange={(e) => (file = e.currentTarget.files?.[0] ?? null)} 39 + /> 40 + <Button icon={Image} onclick={() => input?.click()}>Browse…</Button> 41 + </div> 42 + </FormRow> 43 + </SettingsList> 44 + 45 + <FormActions> 46 + {#snippet start()} 47 + <Button variant="danger" icon={Trash}>Remove avatar</Button> 48 + {/snippet} 49 + <Button href="/settings" icon={X}>Cancel</Button> 50 + <Button variant="primary" icon={Upload} disabled={!file} onclick={submit}>Upload</Button> 51 + </FormActions> 52 + </DrillDown>
+36
web/src/routes/settings/profile/deactivate/+page.svelte
··· 1 + <script lang="ts"> 2 + import Button from "$lib/components/ui/Button.svelte"; 3 + import Input from "$lib/components/ui/Input.svelte"; 4 + import DrillDown from "$lib/components/settings/DrillDown.svelte"; 5 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 6 + import FormRow from "$lib/components/settings/FormRow.svelte"; 7 + import FormActions from "$lib/components/settings/FormActions.svelte"; 8 + import Pause from "$icon/pause"; 9 + import X from "$icon/x"; 10 + 11 + let password = $state(""); 12 + const valid = $derived(password.length > 0); 13 + </script> 14 + 15 + <DrillDown 16 + backLabel="Profile" 17 + backHref="/settings" 18 + title="Deactivate account" 19 + description="Your profile and repositories will become inaccessible. You can reactivate by logging in again." 20 + > 21 + <SettingsList padding="tight"> 22 + <FormRow 23 + label="Password" 24 + description="Confirm your identity to proceed." 25 + for="deactivate-password" 26 + > 27 + <Input id="deactivate-password" type="password" bind:value={password} /> 28 + </FormRow> 29 + </SettingsList> 30 + 31 + <FormActions> 32 + <Button href="/settings" icon={X}>Cancel</Button> 33 + <!-- mocked — account lifecycle is backed by G4 --> 34 + <Button variant="warning" icon={Pause} disabled={!valid}>Deactivate</Button> 35 + </FormActions> 36 + </DrillDown>
+32
web/src/routes/settings/profile/delete/+page.svelte
··· 1 + <script lang="ts"> 2 + import Button from "$lib/components/ui/Button.svelte"; 3 + import Input from "$lib/components/ui/Input.svelte"; 4 + import DrillDown from "$lib/components/settings/DrillDown.svelte"; 5 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 6 + import FormRow from "$lib/components/settings/FormRow.svelte"; 7 + import FormActions from "$lib/components/settings/FormActions.svelte"; 8 + import Mail from "$icon/mail"; 9 + import X from "$icon/x"; 10 + 11 + let password = $state(""); 12 + const valid = $derived(password.length > 0); 13 + </script> 14 + 15 + <DrillDown 16 + backLabel="Profile" 17 + backHref="/settings" 18 + title="Delete account" 19 + description="This permanently deletes your account and all associated data. This cannot be undone." 20 + > 21 + <SettingsList padding="tight"> 22 + <FormRow label="Password" description="Confirm your identity to proceed." for="delete-password"> 23 + <Input id="delete-password" type="password" bind:value={password} /> 24 + </FormRow> 25 + </SettingsList> 26 + 27 + <FormActions> 28 + <Button href="/settings" icon={X}>Cancel</Button> 29 + <!-- mocked — account lifecycle is backed by G4 --> 30 + <Button variant="danger" icon={Mail} disabled={!valid}>Send deletion code</Button> 31 + </FormActions> 32 + </DrillDown>
+59
web/src/routes/settings/profile/handle/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from "$app/navigation"; 3 + import { resolve } from "$app/paths"; 4 + import Button from "$lib/components/ui/Button.svelte"; 5 + import Input, { inputField } from "$lib/components/ui/Input.svelte"; 6 + import Toggle from "$lib/components/ui/Toggle.svelte"; 7 + import DrillDown from "$lib/components/settings/DrillDown.svelte"; 8 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 9 + import FormRow from "$lib/components/settings/FormRow.svelte"; 10 + import FormActions from "$lib/components/settings/FormActions.svelte"; 11 + import Check from "$icon/check"; 12 + import X from "$icon/x"; 13 + 14 + const SUFFIX = ".tngl.sh"; 15 + 16 + let ownDomain = $state(false); 17 + let handle = $state(""); 18 + 19 + const valid = $derived(handle.trim().length > 0); 20 + 21 + // mocked — a real handle change needs an OAuth re-auth round trip 22 + const submit = () => goto(resolve("/settings")); 23 + </script> 24 + 25 + <DrillDown backLabel="Profile" backHref="/settings" title="Change handle"> 26 + <SettingsList padding="tight"> 27 + <FormRow label="I have my own domain"> 28 + <div class="flex sm:justify-end"> 29 + <Toggle bind:checked={ownDomain} aria-label="I have my own domain" /> 30 + </div> 31 + </FormRow> 32 + <FormRow label="Handle" for="handle"> 33 + {#if ownDomain} 34 + <Input id="handle" bind:value={handle} placeholder="example.com" /> 35 + {:else} 36 + <!-- the subdomain suffix lives inside the field, so borrow Input's own 37 + surface styling rather than re-deriving it --> 38 + <div class={inputField({ class: "gap-0 overflow-hidden py-0 pr-0" })}> 39 + <input 40 + id="handle" 41 + bind:value={handle} 42 + placeholder="handle" 43 + class="min-w-0 flex-1 bg-transparent py-1 typography-paragraph-regular outline-none placeholder:text-foreground-placeholder" 44 + /> 45 + <span 46 + class="self-stretch border-l border-border-default bg-background-muted px-2 py-1 typography-paragraph-regular text-foreground-muted" 47 + > 48 + {SUFFIX} 49 + </span> 50 + </div> 51 + {/if} 52 + </FormRow> 53 + </SettingsList> 54 + 55 + <FormActions> 56 + <Button href="/settings" icon={X}>Cancel</Button> 57 + <Button variant="primary" icon={Check} disabled={!valid} onclick={submit}>Save</Button> 58 + </FormActions> 59 + </DrillDown>
+31
web/src/routes/settings/profile/password/+page.svelte
··· 1 + <script lang="ts"> 2 + import Button from "$lib/components/ui/Button.svelte"; 3 + import Input from "$lib/components/ui/Input.svelte"; 4 + import DrillDown from "$lib/components/settings/DrillDown.svelte"; 5 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 6 + import FormRow from "$lib/components/settings/FormRow.svelte"; 7 + import FormActions from "$lib/components/settings/FormActions.svelte"; 8 + import Key from "$icon/key"; 9 + import X from "$icon/x"; 10 + 11 + let password = $state(""); 12 + const valid = $derived(password.length > 0); 13 + </script> 14 + 15 + <DrillDown backLabel="Profile" backHref="/settings" title="Change password"> 16 + <SettingsList padding="tight"> 17 + <FormRow 18 + label="Current password" 19 + description="Confirm your identity to proceed." 20 + for="current-password" 21 + > 22 + <Input id="current-password" type="password" bind:value={password} /> 23 + </FormRow> 24 + </SettingsList> 25 + 26 + <FormActions> 27 + <Button href="/settings" icon={X}>Cancel</Button> 28 + <!-- mocked — the reset mail is sent by the G4 account service --> 29 + <Button variant="primary" icon={Key} disabled={!valid}>Send reset code</Button> 30 + </FormActions> 31 + </DrillDown>
+36
web/src/routes/settings/spindles/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from "$app/navigation"; 3 + import { resolve } from "$app/paths"; 4 + import Button from "$lib/components/ui/Button.svelte"; 5 + import Input from "$lib/components/ui/Input.svelte"; 6 + import DrillDown from "$lib/components/settings/DrillDown.svelte"; 7 + import SettingsList from "$lib/components/settings/SettingsList.svelte"; 8 + import FormRow from "$lib/components/settings/FormRow.svelte"; 9 + import FormActions from "$lib/components/settings/FormActions.svelte"; 10 + import Plus from "$icon/plus"; 11 + import X from "$icon/x"; 12 + 13 + let address = $state(""); 14 + const valid = $derived(address.trim().length > 0); 15 + 16 + // mocked — spindle registration is not wired up yet 17 + const submit = () => goto(resolve("/settings/spindles")); 18 + </script> 19 + 20 + <DrillDown 21 + backLabel="Spindles" 22 + backHref="/settings/spindles" 23 + title="Register a spindle" 24 + description="Enter the hostname of your spindle to get started." 25 + > 26 + <SettingsList padding="tight"> 27 + <FormRow label="Spindle address" for="spindle-address"> 28 + <Input id="spindle-address" bind:value={address} placeholder="spindle.example.com" /> 29 + </FormRow> 30 + </SettingsList> 31 + 32 + <FormActions> 33 + <Button href="/settings/spindles" icon={X}>Cancel</Button> 34 + <Button variant="primary" icon={Plus} disabled={!valid} onclick={submit}>Add</Button> 35 + </FormActions> 36 + </DrillDown>