diff --git a/web/src/app.css b/web/src/app.css
index e77fabe1..de08cf6d 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -253,18 +253,61 @@
line-height: 1.25rem;
}
+/* One tab's content giving way to another's. TabContent publishes the motion as
+ custom properties on the region it wraps; the rules below decide which boxes
+ actually travel, because a panel welded to the tab bar above it has to hold
+ still — a border sliding out from under the active tab breaks the join. */
+@keyframes tab-in {
+ from {
+ translate: var(--tab-from, 0);
+ opacity: 0;
+ }
+}
+
+@layer components {
+ /* everything a tab route lays down travels as one, so a page carrying content
+ below its card — an overview's readme, an issue list — moves with it. only a
+ panel's own box is held back, which means everything inside it still travels:
+ a settings sidebar is chrome for its own sub-nav but content for this move,
+ arriving on screen for the first time along with the rest */
+ [data-tab-motion] > *:not([data-tab-panel]),
+ [data-tab-motion] > [data-tab-panel] > * {
+ translate: var(--tab-shift, 0);
+ opacity: var(--tab-fade, 1);
+ transition:
+ translate var(--tab-out-duration, 0ms) var(--tab-out-ease, linear),
+ opacity var(--tab-out-duration, 0ms) var(--tab-out-ease, linear);
+ animation: var(--tab-enter, none) var(--tab-in-duration, 0ms) var(--tab-in-ease, linear) both;
+ }
+
+ /* the panel keeps its box and clips whatever its body slides past. `clip` and
+ not `hidden`, so the vertical axis stays visible and the sticky diff headers
+ inside still resolve against the document */
+ [data-tab-motion] > [data-tab-panel] {
+ overflow-x: clip;
+ }
+}
+
@utility popover-transition {
opacity: 0;
scale: 0.98;
+ /* a transition reads off the state it lands on, so this one is the exit. the
+ two discrete properties are what keep a closing panel painted long enough
+ to run it, and firefox has neither — it leans on the popoverExit
+ attachment instead, which every panel using this utility needs */
transition:
- opacity 120ms ease-out,
- scale 120ms ease-out,
- overlay 120ms allow-discrete,
- display 120ms allow-discrete;
+ opacity 100ms ease-in,
+ scale 100ms ease-in,
+ overlay 100ms allow-discrete,
+ display 100ms allow-discrete;
&:popover-open {
opacity: 1;
scale: 1;
+ /* and this one is the entry */
+ transition:
+ opacity 75ms ease-out,
+ scale 75ms ease-out;
}
@starting-style {
diff --git a/web/src/lib/components/profile/ProfileTabs.svelte b/web/src/lib/components/profile/ProfileTabs.svelte
index 1b9b7b61..db1c792a 100644
--- a/web/src/lib/components/profile/ProfileTabs.svelte
+++ b/web/src/lib/components/profile/ProfileTabs.svelte
@@ -1,10 +1,6 @@
diff --git a/web/src/lib/components/profile/tabs/defs.ts b/web/src/lib/components/profile/tabs/defs.ts
new file mode 100644
index 00000000..febbb4d6
--- /dev/null
+++ b/web/src/lib/components/profile/tabs/defs.ts
@@ -0,0 +1,34 @@
+// the profile tab bar, shared by the nav that draws it and the layout's content
+// transition — both have to agree on the ids and on their left-to-right order,
+// which is what decides the direction the content slides
+
+import SquareChartGantt from "$icon/square-chart-gantt";
+import BookMarked from "$icon/book-marked";
+import Star from "$icon/star";
+import LineSquiggle from "$icon/line-squiggle";
+import Shield from "$icon/shield";
+import type { TabDef } from "$lib/components/ui/Tabs.svelte";
+import type { ProfileCounts } from "../types";
+
+export const profileTabs = (handle: string, counts: ProfileCounts): TabDef[] => {
+ const href = (id: string) => (id === "overview" ? `/${handle}` : `/${handle}?tab=${id}`);
+ return [
+ { id: "overview", label: "Overview", icon: SquareChartGantt, href: href("overview") },
+ {
+ id: "repos",
+ label: "Repositories",
+ icon: BookMarked,
+ count: counts.repos,
+ href: href("repos")
+ },
+ { id: "starred", label: "Starred", icon: Star, count: counts.stars, href: href("starred") },
+ {
+ id: "strings",
+ label: "Strings",
+ icon: LineSquiggle,
+ count: counts.strings,
+ href: href("strings")
+ },
+ { id: "vouches", label: "Vouches", icon: Shield, href: href("vouches") }
+ ];
+};
diff --git a/web/src/lib/components/repo/RepoTabs.svelte b/web/src/lib/components/repo/RepoTabs.svelte
index b7826854..b6254c63 100644
--- a/web/src/lib/components/repo/RepoTabs.svelte
+++ b/web/src/lib/components/repo/RepoTabs.svelte
@@ -1,10 +1,6 @@
diff --git a/web/src/lib/components/repo/tabs.ts b/web/src/lib/components/repo/tabs.ts
new file mode 100644
index 00000000..44fccfc5
--- /dev/null
+++ b/web/src/lib/components/repo/tabs.ts
@@ -0,0 +1,36 @@
+// the repository tab bar, shared by the nav that draws it and the layout's
+// content transition — both have to agree on the ids and on their left-to-right
+// order, which is what decides the direction the content slides
+
+import SquareChartGantt from "$icon/square-chart-gantt";
+import CircleDot from "$icon/circle-dot";
+import GitPullRequest from "$icon/git-pull-request";
+import Layers2 from "$icon/layers-2";
+import Settings from "$icon/settings";
+import type { TabDef } from "$lib/components/ui/Tabs.svelte";
+import type { RepoCounts, RepoInfo } from "./types";
+
+export const repoTabs = (repo: RepoInfo, counts: RepoCounts): TabDef[] => {
+ const base = `/${repo.ownerHandle}/${repo.name}`;
+ return [
+ { id: "overview", label: "Overview", icon: SquareChartGantt, href: base },
+ {
+ id: "issues",
+ label: "Issues",
+ icon: CircleDot,
+ count: counts.issues,
+ href: `${base}/issues`
+ },
+ {
+ id: "pulls",
+ label: "Pulls",
+ icon: GitPullRequest,
+ count: counts.pulls,
+ href: `${base}/pulls`
+ },
+ { id: "pipelines", label: "Pipelines", icon: Layers2, href: `${base}/pipelines` },
+ // todo: only show this to collaborators — needs the viewer's push access on
+ // RepoInfo, which the layout load doesn't resolve yet
+ { id: "settings", label: "Settings", icon: Settings, href: `${base}/settings` }
+ ];
+};
diff --git a/web/src/lib/components/settings/SettingsPanel.svelte b/web/src/lib/components/settings/SettingsPanel.svelte
index 217fe963..cde6e50d 100644
--- a/web/src/lib/components/settings/SettingsPanel.svelte
+++ b/web/src/lib/components/settings/SettingsPanel.svelte
@@ -15,8 +15,11 @@
+
diff --git a/web/src/lib/components/ui/TabContent.svelte b/web/src/lib/components/ui/TabContent.svelte
new file mode 100644
index 00000000..2d78a8df
--- /dev/null
+++ b/web/src/lib/components/ui/TabContent.svelte
@@ -0,0 +1,114 @@
+
+
+
+
+
+ {@render children()}
+
diff --git a/web/src/lib/components/ui/TabPanel.svelte b/web/src/lib/components/ui/TabPanel.svelte
index c8d3ad0d..5396a281 100644
--- a/web/src/lib/components/ui/TabPanel.svelte
+++ b/web/src/lib/components/ui/TabPanel.svelte
@@ -2,12 +2,20 @@
import { tv } from "tailwind-variants";
export const tabPanel = tv({
- base: "rounded-sm border border-border-default bg-background-default text-foreground-default",
+ slots: {
+ // the card itself: chrome only, so its box can hold still and stay welded to
+ // the tab bar above it while a transition moves what's inside
+ surface:
+ "rounded-sm border border-border-default bg-background-default text-foreground-default",
+ // everything the caller lays out lives in here, which is also what a tab
+ // transition travels — see the [data-tab-motion] rules in app.css
+ body: ""
+ },
variants: {
// the common surface padding; turn off to lay out children yourself
padded: {
- true: "px-6 py-4",
- false: ""
+ true: { body: "px-6 py-4" },
+ false: {}
}
},
defaultVariants: {
@@ -28,8 +36,12 @@
}
let { as = "section", padded = true, class: className, children }: Props = $props();
+
+ const style = $derived(tabPanel({ padded }));
-
- {@render children()}
+
+
+ {@render children()}
+
diff --git a/web/src/lib/components/ui/Tabs.svelte b/web/src/lib/components/ui/Tabs.svelte
index f1858e7e..7de792ab 100644
--- a/web/src/lib/components/ui/Tabs.svelte
+++ b/web/src/lib/components/ui/Tabs.svelte
@@ -1,4 +1,6 @@
+
- {@render children()}
+
+ {@render children()}
+
diff --git a/web/src/routes/settings/+layout.svelte b/web/src/routes/settings/+layout.svelte
index a5b82740..36256f6a 100644
--- a/web/src/routes/settings/+layout.svelte
+++ b/web/src/routes/settings/+layout.svelte
@@ -2,6 +2,7 @@
import { page } from "$app/state";
import type { SettingsNavItem } from "$lib/components/settings/SettingsNav.svelte";
import SettingsPanel from "$lib/components/settings/SettingsPanel.svelte";
+ import TabContent from "$lib/components/ui/TabContent.svelte";
import User from "$icon/user-round";
import Key from "$icon/key";
import Mail from "$icon/mail";
@@ -16,7 +17,12 @@
{ id: "profile", label: "Profile", href: "/settings", icon: User },
{ id: "keys", label: "Keys", href: "/settings/keys", icon: Key },
{ id: "emails", label: "Emails", href: "/settings/emails", icon: Mail },
- { id: "notifications", label: "Notifications", href: "/settings/notifications", icon: Bell },
+ {
+ id: "notifications",
+ label: "Notifications",
+ href: "/settings/notifications",
+ icon: Bell
+ },
{ id: "knots", label: "Knots", href: "/settings/knots", icon: Volleyball },
{ id: "spindles", label: "Spindles", href: "/settings/spindles", icon: Spool },
{ id: "sites", label: "Sites", href: "/settings/sites", icon: Globe }
@@ -34,7 +40,16 @@
- {@render children()}
+
+
+ {@render children()}
+