diff --git a/web/src/lib/components/settings/SettingsNav.svelte b/web/src/lib/components/settings/SettingsNav.svelte
index 38a2e4fd..de642ad5 100644
--- a/web/src/lib/components/settings/SettingsNav.svelte
+++ b/web/src/lib/components/settings/SettingsNav.svelte
@@ -1,19 +1,14 @@
-
- {#if ActiveGlyph}
-
- {/if}
-
-
-
+
+
-
+
diff --git a/web/src/lib/components/ui/Tabs.stories.svelte b/web/src/lib/components/ui/Tabs.stories.svelte
index 8f4e83aa..11de46a3 100644
--- a/web/src/lib/components/ui/Tabs.stories.svelte
+++ b/web/src/lib/components/ui/Tabs.stories.svelte
@@ -30,3 +30,20 @@
+
+
+ {#snippet template(args)}
+
+
+
+ {/snippet}
+
+
+
+ {#snippet template(args)}
+
+
+
+ {/snippet}
+
diff --git a/web/src/lib/components/ui/Tabs.svelte b/web/src/lib/components/ui/Tabs.svelte
index 9551ac97..e1983a97 100644
--- a/web/src/lib/components/ui/Tabs.svelte
+++ b/web/src/lib/components/ui/Tabs.svelte
@@ -14,26 +14,32 @@
export const tabs = tv({
slots: {
nav: "",
- plate:
- "pointer-events-none absolute top-0 left-0 rounded-t border border-b-0 border-border-default bg-background-default",
- item: "flex items-center no-underline hover:no-underline",
+ plate: "pointer-events-none absolute top-0 left-0",
+ item: "relative flex items-center no-underline hover:no-underline",
icon: "size-4",
- label: "flex flex-col",
+ label: "",
ghost: "invisible h-0 overflow-hidden font-medium select-none",
count: "rounded-sm px-1 typography-paragraph-small"
},
variants: {
vertical: {
true: {
- nav: "h-fit divide-y divide-border-default overflow-hidden rounded-sm border border-border-default",
- item: "gap-3 px-3 py-2 typography-paragraph-small",
- icon: "shrink-0",
+ nav: "relative flex w-full flex-col gap-1 bg-background-navigation-frame p-2",
+ plate:
+ "rounded-sm border border-border-navigation-item-active bg-background-navigation-item",
+ item: "min-h-8 w-full gap-1.5 overflow-hidden rounded-sm border border-transparent px-3 py-1.5 typography-paragraph-regular text-foreground-default transition-colors duration-150 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus",
+ icon: "size-3.5 shrink-0",
+ // Figma clips the item rather than letting a long label wrap it taller; an
+ // ellipsis keeps that height without hiding the label outright
+ label: "min-w-0 truncate",
count: "ml-auto"
},
false: {
nav: "relative z-10 flex w-full overflow-x-auto overflow-y-hidden pl-4",
- item: "relative mr-1 rounded-t border border-b-0 border-transparent px-4 pt-1 pb-1.25 whitespace-nowrap text-foreground-default",
+ plate: "rounded-t border border-b-0 border-border-default bg-background-default",
+ item: "mr-1 rounded-t border border-b-0 border-transparent px-4 pt-1 pb-1.25 whitespace-nowrap text-foreground-default",
icon: "mr-2",
+ label: "flex flex-col",
count: "ml-1"
}
},
@@ -52,19 +58,15 @@
{
vertical: true,
selected: true,
- class: { item: "bg-background-default text-foreground-default dark:bg-background-inset" }
- },
- {
- vertical: true,
- selected: false,
+ ready: false,
class: {
- item: "bg-background-inset text-foreground-muted hover:text-foreground-default dark:bg-background-default"
+ item: "border-border-navigation-item-active bg-background-navigation-item"
}
},
{
- vertical: false,
- selected: true,
- class: { item: "[-webkit-text-stroke:0.3px_currentColor]" }
+ vertical: true,
+ selected: false,
+ class: { item: "hover:bg-background-inset" }
},
{
vertical: false,
@@ -101,10 +103,19 @@
label?: string;
vertical?: boolean;
overlapBottom?: boolean;
+ class?: string;
}
- let { tabs: defs, active, label, vertical = false, overlapBottom = false }: Props = $props();
+ let {
+ tabs: defs,
+ active,
+ label,
+ vertical = false,
+ overlapBottom = false,
+ class: navClass
+ }: Props = $props();
+ // resolved up front so the plate can match hrefs against the destination url
const items = $derived(
defs.map((tab) => ({
...tab,
@@ -112,6 +123,9 @@
}))
);
+ // the plate follows where we're heading rather than where we are, so it leaves
+ // the moment you click instead of waiting on the route's load; aria-current
+ // stays behind on the page that's still on screen
const selected = $derived.by(() => {
const dest = navigating.to?.url;
if (!dest) return active;
@@ -120,6 +134,8 @@
let bestLength = -1;
for (const item of items) {
if (item.url === dest.pathname + dest.search) return item.id;
+ // a plain path also owns everything nested under it, so an issue page
+ // keeps the issues tab lit; a query-scoped tab only matches exactly
if (item.url.includes("?")) continue;
const owns = dest.pathname === item.url || dest.pathname.startsWith(`${item.url}/`);
if (owns && item.url.length > bestLength) {
@@ -132,50 +148,63 @@
let nav = $state();
let nodes = $state<(HTMLElement | undefined)[]>([]);
- let box = $state<{ x: number; width: number; height: number }>();
+ let box = $state<{ top: number; left: number; width: number; height: number }>();
let ready = $state(false);
- const plate = new Spring({ x: 0, width: 0 }, { stiffness: 0.145, damping: 0.65, precision: 0.1 });
+ const plate = new Spring(
+ { pos: 0, size: 0 },
+ { stiffness: 0.145, damping: 0.65, precision: 0.1 }
+ );
const measure = () => {
const node = nodes[items.findIndex((item) => item.id === selected)];
- if (!node) return;
- box = { x: node.offsetLeft, width: node.offsetWidth, height: node.offsetHeight };
+ if (!node?.offsetParent) return;
+ box = {
+ top: node.offsetTop,
+ left: node.offsetLeft,
+ width: node.offsetWidth,
+ height: node.offsetHeight
+ };
};
$effect(() => {
- if (vertical) return;
measure();
});
+ // watch every tab, not just the row: a preceding tab changing width (a count
+ // arriving, a webfont landing) shifts the plate without resizing the row
$effect(() => {
- if (vertical || !nav) return;
+ if (!nav) return;
const observer = new ResizeObserver(measure);
observer.observe(nav);
for (const node of nodes) if (node) observer.observe(node);
return () => observer.disconnect();
});
+ // the first placement lands instantly so the plate never slides in from nowhere;
+ // until it happens the selected tab wears the chrome itself, which is also what
+ // server-rendered and script-less pages get
let placed = false;
$effect(() => {
if (!box) return;
- plate.set({ x: box.x, width: box.width }, { instant: !placed || prefersReducedMotion.current });
+ plate.set(vertical ? { pos: box.top, size: box.height } : { pos: box.left, size: box.width }, {
+ instant: !placed || prefersReducedMotion.current
+ });
placed = true;
ready = true;
});
const style = $derived(tabs({ vertical, ready, overlapBottom }));
+
+ const plateStyle = $derived(
+ vertical
+ ? `translate: ${box?.left ?? 0}px ${plate.current.pos}px; width: ${box?.width ?? 0}px; height: ${plate.current.size}px`
+ : `translate: ${plate.current.pos}px; width: ${plate.current.size}px; height: ${box?.height ?? 0}px`
+ );
-